The Roundup mail gateway automatically transforms incoming email messages into issues to be tracked. This is A Good Thing (TM). However, as the famous Johan Cruyff said: every upside has its downside. More specifically, you'll find that some people are reply-button-challenged. They will not use reply. They will compose new emails without In-Reply-To references, without issue id in the subject. They'll happily invent new subject lines for existing issues.

As a consequence, Roundup will be unable to recognize such mail for what it is, a followup to an existing issue. It will open a new issue instead. So now you've got two issue threads in Roundup for what is a single issue in real life.

The solution: a new Merge action that takes a source issue, copies all messages and files from it to a target issue, then retires the source issue. So you'll have everything relevant in the consolidated target issue. Meanwhile, the source issue is retired and will not accept any followup changes. Neat.

Credits to Marlon van den Berg for prototyping this.

Change dbinit.py:

Here we go. First, create a new property merged on IssueClass.

In 'dbinit.py':

 issue = IssueClass(db, "issue",
     assignedto=Link("user"), topic=Multilink("keyword"),
     priority=Link("priority"), status=Link("status"),
     merged=Link("issue"))

We'll use this merged property to store a reference to the target issue on retiring the source issue.

Create a merge action class:

Next, we need a MergeAction class to perform the heavy lifting.

In 'interfaces.py':

 from roundup.cgi.exceptions import Redirect
 from roundup.cgi import actions

 class MergeAction(actions.Action):
     def handle(self):
         source_issue = self.nodeid

         # find out if the form has a target issue edit field
         if self.form.has_key('target_issue'):
             # yes it does. Get the value.
             target_issue = self.form['target_issue'].value.strip()
         else:
             # nope
             target_issue = None
         if not target_issue or target_issue == '':
             self.client.error_message.append('Unknown target issue')
             return
         elif target_issue == source_issue:        
             self.client.error_message.append('Cannot merge issue %s into myself (%s)' % (target_issue, source_issue))
             return

         # get the message lists of the two issues
         source_messages = self.db.issue.get(source_issue, 'messages')
         target_messages = self.db.issue.get(target_issue, 'messages')
         # merge them
         for msg in source_messages:
             target_messages.append(msg)
         # update the target issue message list
         self.db.issue.set(target_issue, messages=target_messages)

         # get the file lists of the two issues
         source_files = self.db.issue.get(source_issue, 'files')
         target_files = self.db.issue.get(target_issue, 'files')
         # merge them
         for file in source_files:
             target_files.append(file)
         # update the target issue file list
         self.db.issue.set(target_issue, files=target_files)

 # uncomment this if you're using timelogs
 #        # get the timelog lists of the two issues
 #        source_timelog = self.db.issue.get(source_issue, 'timelog')
 #        target_timelog = self.db.issue.get(target_issue, 'timelog')
 #        # merge them
 #        for time in source_timelog:
 #            target_timelog.append(time)
 #        # update the target issue time list
 #        self.db.issue.set(target_issue, timelog=target_timelog)

         # store the 'merged-into' issue id
         self.db.issue.set(source_issue, merged=target_issue)
         # retire the source issue
         self.db.issue.retire(source_issue)
         # commit all database changes
         self.db.commit()
         # confirm the merge and redirect to the target issue      
         raise Redirect, """%sissue%s?@ok_message=Merging of issue %s into issue
         %s successful""" % (self.base, target_issue, source_issue, target_issue)

Put a merge form in your issue template:

Finally, you'll need a way to plug all this logic into your webinterface. In your template file $TRACKER_HOME/html/issue.item.html you'll add a new form below the main editing form. Make sure you locate the correct /form tag.

After that tag (outside the main form) add a new form:

 <form method=POST tal:condition="python: context.merged == None">
  <input type="hidden" name="@action" value="merge">
  <table class="form">
   <tr>
    <th>Merge into</th>
    <td>
      <tal:block tal:content="structure context/issue/menu" />
    </td>
    <td>
     <input type="submit" value="  Merge  ">
    </td>
   </tr>
  </table>
 </form>

You can of course replace the very boring issue selector you get by:

      <tal:block metal:use-macro="templates/lib/macros/issue_menu" />

Which does suppose you have defined a custom macro in $TRACKER_HOME/html/lib.html for generating the issue list. YMMV.

This should work. Test.

To be continued...

In the next installment of this series, we'll build upon this foundation to beaf up this form so we'll not only be able to merge into target issue, but also to supersede from older issue :-)

Superseding differs from merging, in that superseding handles related, but still different issues (brothers and sisters) whereas merging handles identical split personality issues (clones).

Stay tuned.


comments:

Make it work with roundup 1.x --wiki, Sat, 03 Feb 2007 20:42:27 +1100 reply
With some small changes, this works with Roundup 1.x, too:

Restart the Roundup server to recognise the schema change and the new extension.

Of course, this can be improved further; there is no GUI method so far to undo a merge. You can restore and unmerge issueXXXX using the "roundup-admin" script, using the following commands:

 restore issueXXXX
 set issueXXXX merged=
 commit

The file attachments and messages will stay double-linked, but this might be ok and can be changed TTW.