Index: mergebot/branches/ticket-2/COPYING
===================================================================
--- mergebot/branches/ticket-2/COPYING	(revision 20)
+++ mergebot/branches/ticket-2/COPYING	(revision 20)
@@ -0,0 +1,20 @@
+The MIT License
+
+Copyright (c) 2007 CommProve, Inc.
+Copyright (c) 2008-2009 Eli Carter <eli.carter@retracile.net>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
Index: mergebot/branches/ticket-2/README.txt
===================================================================
--- mergebot/branches/ticket-2/README.txt	(revision 20)
+++ mergebot/branches/ticket-2/README.txt	(revision 20)
@@ -0,0 +1,30 @@
+= Installing =
+# Checkout the source.
+$ svn co <url> mergebot-0.11
+$ cd mergebot-0.11
+$ ./rpm/makerpm
+$ su -c "rpm --install dist/TracMergeBot*.noarch.rpm"
+# Create the mergebot work area
+$ mkdir <mytracenv>/mergebot
+$ chown <webserver>:<webserver> <mytracenv>/mergebot
+
+Enable mergebot in the [components] section:
+mergebot.web_ui.* = enabled
+
+If you want to use the ticket workflow features you will also need to change
+[components]
+mergebot.ticket_actions.* = enabled
+[ticket]
+workflow = ConfigurableTicketWorkflow,MergebotActionController
+Restart your webserver.
+And then use "mergebot" in the .operations section of the relevant ticket actions.
+
+# Add needed entries to trac.ini:
+$ trac-admin <mytracenv> upgrade
+Then edit <mytracenv>/conf/trac.ini again.
+In particular, look for the [mergebot] section that the upgrade added, and be sure the repository_url is set.
+Give MERGEBOT_* permissions to your users.
+
+Be sure you have a version named 'trunk' -- and you will likely want to make that the default.
+Mergebot assumes that each component is the name of a top-level directory in the svn repository, and that the versions are 'trunk', a ticket number with a preceeding '#', or a release name with a corresponding 'branches/release-XYZ' directory.
+
Index: mergebot/branches/ticket-2/mergebot/Actor.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/Actor.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/Actor.py	(revision 20)
@@ -0,0 +1,59 @@
+import os
+
+class Actor(object):
+    def __init__(self, work_dir, repo_url, repo_dir, ticket, component,
+                 version, summary, requestor):
+        self.work_dir = work_dir
+        self.repo_url = repo_url
+        self.repo_dir = repo_dir
+        self.ticket = ticket
+        self.component = component
+        self.version = version
+        self.summary = summary
+        self.requestor = requestor
+
+    def execute(self):
+        """To be overridden by subclasses.
+        Returns a tuple containing:
+        a dictionary of fields to update in the ticket
+        a comment string
+        True on success, False on failure
+        """
+        raise NotImplementedError
+
+    def logfilename(self):
+        return os.path.abspath(os.path.join(os.path.dirname(self.work_dir),
+            'ticket-%s.log' % self.ticket))
+
+    def public_url(self):
+        return '%s/%s' % (self.repo_url, self.component)
+
+    def branch_public_url(self):
+        """Returns the public svn url of the branch for this ticket.
+        """
+        return '%s/branches/ticket-%s' % (self.public_url(), self.ticket)
+
+    def local_url(self):
+        return 'file://%s/%s' % (self.repo_dir, self.component)
+
+    def baseline_local_url(self):
+        """Returns the local svn url this ticket branches from.
+        """
+        return '%s/%s' % (self.local_url(), self.version_subdir())
+
+    def branch_local_url(self):
+        """Returns the local svn url of the branch for this ticket.
+        """
+        return '%s/branches/ticket-%s' % (self.local_url(), self.ticket)
+
+    def version_subdir(self):
+        if self.version == 'trunk':
+            subdir = 'trunk'
+        elif self.version.startswith('#'):
+            # branched from another ticket
+            subdir = 'branches/ticket-%s' % self.version.lstrip('#')
+        else:
+            # assume release branch
+            subdir = 'branches/release-%s' % self.version
+        return subdir
+
Index: mergebot/branches/ticket-2/mergebot/BranchActor.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/BranchActor.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/BranchActor.py	(revision 20)
@@ -0,0 +1,77 @@
+#!/usr/bin/env python
+"""Module for creating new branches for tickets"""
+
+import os
+import time
+
+from mergebot import SvnOps
+from mergebot.Actor import Actor
+
+class BranchActor(Actor):
+    """This class handles creating a new branch for a ticket."""
+    def execute(self):
+        """Create the branch for the given ticket.
+        """
+        results = {}
+        # Setup logging
+        logfile = self.logfilename()
+        open(logfile, "a").write("%s: branching ticket %s\n" % (time.asctime(),
+            self.ticket))
+
+        # Make sure the various urls we require do exist
+        if not SvnOps.get_branch_info(self.local_url(), logfile):
+            comment = 'Component %s does not exist in the repository.' \
+                % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.local_url() + '/branches', logfile):
+            comment = 'No directory in which to create branches for component %s in the repository.' % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.baseline_local_url(), logfile):
+            comment = 'Version %s for component %s does not exist in the repository.' % (self.version, self.component)
+            return results, comment, False
+
+        commit_header = 'Ticket #%s: %s' % (self.ticket, self.summary)
+
+        # Delete the branch if it already exists.  This can happen if the branch
+        # was merged, but we're still working on it.
+        if SvnOps.get_branch_info(self.branch_local_url(), logfile):
+            # This branch already exists.
+            commit_message = "\n".join([commit_header,
+                "    Delete old branch",
+            ])
+            new_rev = SvnOps.delete_branch(self.branch_local_url(),
+                                           commit_message, logfile)
+            if new_rev == -1:
+                results['mergebotstate'] = 'branchfailed'
+                comment = 'Deleting the existing branch failed.'
+                return results, comment, False
+
+        # Do the branch creationg
+        commit_message = "\n".join([commit_header,
+            "    Create branch from %s for %s." % (self.version,
+                                                   self.requestor),
+        ])
+        retval = SvnOps.create_branch(self.baseline_local_url(),
+            self.branch_local_url(), commit_message, logfile)
+        if retval:
+            # Failed for some reason.
+            results['mergebotstate'] = 'branchfailed'
+            comment = 'Failed to create branch.'
+            return results, comment, False
+        results['mergebotstate'] = 'branched'
+        comment = '\n'.join([
+            'Created branch from %s for %s.' % (self.version, self.requestor),
+            '',
+            'Browse branch [source:%s/branches/ticket-%s source code] and [log:%s/branches/ticket-%s commit log].' % 
+                (self.component, self.ticket, self.component, self.ticket),
+            '',
+            'To checkout, run:',
+            '{{{',
+            'svn checkout %s %s-%s' % (self.branch_public_url(),
+                                       self.component, self.ticket),
+            '}}}',
+        ])
+        return results, comment, True
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: mergebot/branches/ticket-2/mergebot/CheckMergeActor.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/CheckMergeActor.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/CheckMergeActor.py	(revision 20)
@@ -0,0 +1,79 @@
+#!/usr/bin/env python
+"""
+Verify that a branch can be merged to its trunk without conflicts, but don't
+commit the merge.
+"""
+
+import os
+import shutil
+
+from mergebot import SvnOps
+from mergebot.Actor import Actor
+
+class CheckMergeActor(Actor):
+    """Checks that this ticket can be merged to its baseline, but don't modify
+    the repository.
+    """
+    def execute(self):
+        """
+        Verify that a branch can be merged to its trunk without conflicts, but
+        don't commit the merge.
+        """
+        results = {}
+        workdir = self.work_dir
+        logfile = self.logfilename()
+
+        if os.path.exists(workdir):
+            shutil.rmtree(workdir)
+
+        # Make sure the various urls we require do exist
+        if not SvnOps.get_branch_info(self.local_url(), logfile):
+            comment = 'Component %s does not exist in the repository.' \
+                % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.local_url() + '/branches', logfile):
+            comment = 'No directory in which to create branches for ' \
+                'component %s in the repository.' % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.baseline_local_url(), logfile):
+            comment = 'Version %s for component %s does not exist in the ' \
+                'repository.' % (self.version, self.component)
+            return results, comment, False
+
+        branch_info = SvnOps.get_branch_info(self.branch_local_url(), logfile)
+        if not branch_info:
+            comment = 'Branch for ticket %s does not exist in the repository.' \
+                % (self.ticket)
+            return results, comment, False
+        startrev, endrev = branch_info
+
+        SvnOps.checkout(self.baseline_local_url(), workdir, logfile)
+        # TODO: check return code of the above
+        merge_results = SvnOps.merge(self.branch_local_url(), workdir,
+                                     (startrev, endrev), logfile)
+        conflicts = SvnOps.conflicts_from_merge_results(merge_results)
+        if conflicts:
+            message = '\n'.join([
+                'Found %s conflicts while checking merge of %s:%s to %s for ' \
+                    '%s.' % (len(conflicts), startrev, endrev, self.version,
+                    self.requestor),
+                'Files in conflict:',
+                '{{{',
+                '\n'.join(conflicts),
+                '}}}',
+                'A rebranch will be needed before this can be merged.',
+            ])
+            success = False
+        else:
+            message = 'Found no conflicts while checking merge of %s:%s to ' \
+                '%s for %s.' % (startrev, endrev, self.version, self.requestor)
+            success = True
+
+        # Clean up the work area
+        if os.path.exists(workdir):
+            shutil.rmtree(workdir)
+
+        return results, message, success
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: mergebot/branches/ticket-2/mergebot/MergeActor.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/MergeActor.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/MergeActor.py	(revision 20)
@@ -0,0 +1,106 @@
+#!/usr/bin/env python
+"""
+Syntax: MergeActor.py ticketnum component version requestor
+
+Merge a branch to its trunk.
+"""
+
+import os
+import shutil
+
+from mergebot import SvnOps
+from mergebot.Actor import Actor
+
+class MergeActor(Actor):
+    """Merges a branch to the line of development on which it is based.
+    """
+    def execute(self):
+        "Merge a branch to its trunk"
+        results = {}
+        logfile = self.logfilename()
+        checkoutdir = self.work_dir
+        # Delete the working directory so we get a completely clean working
+        # copy.
+        if os.path.exists(checkoutdir):
+            shutil.rmtree(checkoutdir)
+
+        # Make sure the various urls we require do exist
+        if not SvnOps.get_branch_info(self.local_url(), logfile):
+            comment = 'Component %s does not exist in the repository.' \
+                % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.local_url() + '/branches', logfile):
+            comment = 'No directory in which to create branches for component %s in the repository.' % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.baseline_local_url(), logfile):
+            comment = 'Version %s for component %s does not exist in the repository.' % (self.version, self.component)
+            return results, comment, False
+
+        rev_info = SvnOps.get_branch_info(self.branch_local_url(), logfile)
+        if not rev_info:
+            comment = 'Branch for ticket %s does not exist in the repository.' % (self.ticket)
+            return results, comment, False
+        startrev, endrev = rev_info
+
+        SvnOps.checkout(self.baseline_local_url(), checkoutdir, logfile)
+        # FIXME: check return code
+        merge_results = SvnOps.merge(self.branch_local_url(), checkoutdir,
+                               (startrev, endrev), logfile)
+        conflicts = SvnOps.conflicts_from_merge_results(merge_results)
+        if conflicts:
+            comment = "\n".join([
+                "Found %s conflicts in attempt to merge %s:%s to %s for %s." % \
+                    (len(conflicts), startrev, endrev, self.version,
+                     self.requestor),
+                "Files in conflict:",
+                "{{{",
+                "\n".join(conflicts),
+                "}}}",
+                "A rebranch will be needed before this can be merged.",
+            ])
+            results['mergebotstate'] = 'conflicts'
+            success = False
+        else:
+            # The merge worked, so commit the changes.
+            commitmessage = "\n".join([
+                "Ticket #%s: %s" % (self.ticket, self.summary),
+                "    Merge of %s:%s to %s for %s." % (startrev, endrev,
+                    self.version, self.requestor),
+            ])
+            committedrev = SvnOps.commit(checkoutdir, commitmessage, logfile)
+            # Sed message and endstatus
+            if committedrev == None:
+                # Apparently nothing to commit.
+                comment = "\n".join([
+                    "Merged %s:%s to %s for %s." % (startrev, endrev,
+                        self.version, self.requestor),
+                    "No changes to commit.",
+                ])
+                results['mergebotstate'] = 'merged'
+                success = True
+            elif committedrev >= 0:
+                # The commit succeeded.
+                comment = "\n".join([
+                    "Merged %s:%s to %s for %s." % (startrev, endrev,
+                        self.version, self.requestor),
+                    "Changeset [%s]. [source:%s/%s@%s]" % (committedrev,
+                        self.component, self.version_subdir(), committedrev),
+                ])
+                results['mergebotstate'] = 'merged'
+                success = True
+            else:
+                # The commit for the merge failed.
+                comment = \
+                    "Commit failed in attempt to merge %s:%s to %s for %s." \
+                    % (startrev, endrev, self.version, self.requestor)
+                #results['mergebotstate'] = 'mergefailed'
+                success = False
+
+        # Clean up the work area
+        if os.path.exists(checkoutdir):
+            shutil.rmtree(checkoutdir)
+
+        return results, comment, success
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: mergebot/branches/ticket-2/mergebot/RebranchActor.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/RebranchActor.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/RebranchActor.py	(revision 20)
@@ -0,0 +1,177 @@
+#!/usr/bin/env python
+"""
+Rebranch a branch from its trunk, pulling in the changes made on the branch if
+possible.
+"""
+
+import shutil
+import time
+import os
+
+from mergebot import SvnOps
+from mergebot.Actor import Actor
+
+class RebranchActor(Actor):
+    """Rebranches a ticket from its baseline.
+    """
+    def execute(self):
+        """
+        To rebranch from the baseline, you have to do these steps:
+            delete the branch.
+            recreate the branch.
+            merge in the changes from the deleted branch.
+            if there are no conflicts, commit those changes to the branch.
+        """
+        # FIXME: This desparately needs to be refactored.
+        # Do as much work as we can before affecting the repository so we have
+        # as little cleanup as possible and the fewest cases where we leave a
+        # broken branch.
+
+        results = {}
+
+        # We need to improve the logging of the rebranch stuff.
+        logfile = self.logfilename()
+        open(logfile, "a").write("%s rebranching ticket %s\n" %
+            (time.asctime(), self.ticket))
+
+        # Make sure the various urls we require do exist
+        if not SvnOps.get_branch_info(self.local_url(), logfile):
+            comment = 'Component %s does not exist in the repository.' \
+                % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.local_url() + '/branches', logfile):
+            comment = 'No directory in which to create branches for ' \
+                'component %s in the repository.' % self.component
+            return results, comment, False
+        if not SvnOps.get_branch_info(self.baseline_local_url(), logfile):
+            comment = 'Version %s for component %s does not exist in the ' \
+                'repository.' % (self.version, self.component)
+            return results, comment, False
+
+        rev_info = SvnOps.get_branch_info(self.branch_local_url(), logfile)
+        if not rev_info:
+            comment = \
+                'Branch for ticket %s does not exist in the repository.' % \
+                (self.ticket)
+            return results, comment, False
+        startrev, endrev = rev_info
+
+        workingcopy = self.work_dir
+        if os.path.exists(workingcopy):
+            shutil.rmtree(workingcopy)
+
+        svnmergecommand = "svn merge -r %s:%s %s@%s" % (startrev, endrev,
+            self.branch_local_url(), startrev)
+        publicsvnmergecommand = "svn merge -r %s:%s %s@%s" % (startrev, endrev,
+            self.branch_public_url(), startrev)
+        # This is used in the ticket comments when there are conflicts.
+        instructioncomment = "\n".join([
+                "You will need to fix this manually by creating the branch and "
+                    "then doing the merge with this command:",
+                "{{{",
+                publicsvnmergecommand,
+                "}}}",
+        ])
+        # These are used in the commit messages.
+        rmmessage = "\n".join([
+            "Ticket #%s: %s" % (self.ticket, self.summary),
+            "    Remove the branch to rebranch from %s for %s." % \
+                (self.version, self.requestor),
+        ])
+        copymessage = "\n".join([
+            "Ticket #%s: %s" % (self.ticket, self.summary),
+            "    Recreate branch from %s for %s." % (self.version,
+                                                     self.requestor),
+            "[log:%s/branches/ticket-%s@%s Previous log]." % \
+                (self.component, self.ticket, endrev),
+        ])
+        # This is a list of commands.  Each of these must complete
+        # successfully, in this order, to complete the rebranch.  On failure,
+        # the given comment needs to be added to the ticket log.
+        commanderrors = (
+            (lambda x: SvnOps.delete_branch(self.branch_local_url(), rmmessage,
+                                            x) == -1,
+                "Rebranch internal error: Unable to delete the old branch."),
+            (lambda x: SvnOps.create_branch(self.baseline_local_url(),
+                    self.branch_local_url(), copymessage, x),
+                "Rebranch internal error: Unable to recreate the branch.  %s" \
+                    % (instructioncomment, )),
+            (lambda x: SvnOps.checkout(self.branch_local_url(), workingcopy, x),
+                "Rebranch internal error: Unable to get working copy.  %s" % \
+                    (instructioncomment, )),
+        )
+        for cmd, error_comment in commanderrors:
+            retval = cmd(logfile)
+            if retval:
+                if os.path.exists(workingcopy):
+                    shutil.rmtree(workingcopy)
+                results['mergebotstate'] = 'rebranchfailed'
+                return results, error_comment, False
+
+        # On success, we're in the same state as if we had just branched.
+        results['mergebotstate'] = 'branched'
+        success = True
+
+        # Go ahead and try to do the merge.  If we got lucky and there are no
+        # conflicts, commit the changes.
+        # Put a full update on the ticket.
+        merge_results = SvnOps.merge("%s@%s" % (self.branch_local_url(),
+            startrev), workingcopy, (startrev, endrev), logfile)
+        conflicts = SvnOps.conflicts_from_merge_results(merge_results)
+        if conflicts:
+            ticketmessage = "\n".join([
+                "There were conflicts on rebranching.",
+                "Files in conflict:",
+                "{{{",
+                "\n".join(conflicts),
+                "}}}",
+                "You will need to resolve the conflicts manually.",
+                "To do so, update a working copy to the branch, "
+                    "and run this merge command:",
+                "{{{",
+                publicsvnmergecommand,
+                "}}}",
+                "Once you have resolved the conflicts, "
+                    "commit your work to the branch.",
+            ]) 
+        else: # No conflicts, do the commit.
+            mergemessage = "\n".join([
+                "Ticket #%s: %s" % (self.ticket, self.summary),
+                "    Merge in changes from old branch for %s." % self.requestor,
+                "    %s" % svnmergecommand,
+            ])
+            newrev = SvnOps.commit(workingcopy, mergemessage, logfile)
+            if newrev == None:
+                ticketmessage = "\n".join([
+                    "Rebranched from %s for %s." % (self.version,
+                                                    self.requestor),
+                    "There were no changes to commit to the branch.",
+                    "You will need to update your working copy.",
+                ])
+            elif newrev < 0:
+                ticketmessage = "\n".join([
+                    "Rebranch internal error:  Unable to commit merged "
+                        "changes.",
+                    "You will need to fix this manually by doing the merge "
+                        "with this command:",
+                    "{{{",
+                    publicsvnmergecommand,
+                    "}}}",
+                ])
+                results['mergebotstate'] = 'rebranchfailed'
+                success = False
+            else:
+                ticketmessage = "\n".join([
+                    "Rebranched from %s for %s." % (self.version,
+                                                    self.requestor),
+                    "There were no conflicts, so the changes were "
+                        "automatically merged and committed to the branch.",
+                    "You will need to update your working copy.",
+                ])
+
+        if os.path.exists(workingcopy):
+            shutil.rmtree(workingcopy)
+        return results, ticketmessage, success
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: mergebot/branches/ticket-2/mergebot/SvnOps.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/SvnOps.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/SvnOps.py	(revision 20)
@@ -0,0 +1,121 @@
+#!/usr/bin/python
+"""
+Encapsulate logical Subversion operations so the various MergeBot actors can
+operate at a higher level of abstraction.
+"""
+
+import os
+import time
+import re
+
+def shell_quote(string):
+    """Given a string, escape the characters interpretted by the shell."""
+    for char in ["\\", "\"", "$"]:
+        string = string.replace(char, "\\%s" % (char, ))
+    return '"%s"' % (string, )
+
+def logcmd(cmd, logfile):
+    """Log the cmd string, then execute it, appending its stdout and stderr to
+    logfile."""
+    open(logfile, "a").write("%s: %s\n" % (time.asctime(), cmd))
+    return os.system("(%s) >>%s 2>&1" % (cmd, logfile))
+
+def get_rev_from_log(logentry):
+    """Given a log entry split out of svn log, return its revision number"""
+    return int(logentry.split()[0][1:])
+
+def get_branch_info(url, logfile):
+    """Given a subversion url and a logfile, return (start_revision,
+    end_revision) or None if it does not exist."""
+    svncmd = os.popen("svn log --stop-on-copy --non-interactive %s 2>>%s" % \
+        (url, logfile), "r")
+    branchlog = svncmd.read()
+    returnval = svncmd.close()
+    if returnval:
+        # This branch apparently doesn't exist
+        return None
+    logs = branchlog.split("-"*72 + "\n")
+    # If there have been no commits on the branch since it was created, there
+    # will only be one revision listed.... but the log will split into 3 parts.
+    endrev = get_rev_from_log(logs[1])
+    startrev = get_rev_from_log(logs[-2])
+    return (startrev, endrev)
+
+def create_branch(from_url, to_url, commit_message, logfile):
+    """Create a branch copying from_url to to_url.  Commit as mergebot, and use
+    the provided commit message."""
+    svncmd = \
+        "svn copy --username=mergebot --password=mergebot -m %s %s %s" \
+        % (shell_quote(commit_message), from_url, to_url)
+    return logcmd(svncmd, logfile)
+
+def delete_branch(url, commit_message, logfile):
+    """This will generate a new revision.  Return the revision number, or -1 on
+    failure.
+    Assumes that the url exists.  You should call get_branch_info() to
+    determine that first"""
+    svncmd = "svn rm --no-auth-cache --username=mergebot --password=mergebot " \
+        "-m %s %s 2>>%s" % (shell_quote(commit_message), url, logfile)
+    return _svn_new_rev_command(svncmd)
+
+def checkout(from_url, workingdir, logfile):
+    """Checkout from the given url into workingdir"""
+    return os.system("svn checkout %s %s >>%s 2>&1" % (from_url, workingdir,
+        logfile))
+
+def merge(from_url, workingdir, revision_range, logfile):
+    """Returns a list (status, filename) tuples"""
+    # There are a couple of different 'Skipped' messages.
+    skipped_regex = re.compile("Skipped.* '(.*)'", re.M)
+    start_rev, end_rev = revision_range
+    pipe = os.popen("cd %s && svn merge --revision %s:%s %s . 2>>%s" % \
+        (workingdir, start_rev, end_rev, from_url, logfile))
+    output = pipe.readlines()
+    # FIXME: check pipe.close for errors
+    results = []
+    for line in output:
+        if line.startswith("Skipped"):
+            # This kind of conflict requires special handling.
+            filename = skipped_regex.findall(line)[0]
+            status = "C"
+        else:
+            assert line[4] == ' ', "Unexpected output from svn merge " \
+                "operation; the 5th character should always be a space." \
+                "  Output was %r." % line
+            filename = line[5:-1] # (strip trailing newline)
+            status = line[:4].rstrip()
+        results.append((status, filename))
+    return results
+
+def conflicts_from_merge_results(results):
+    "Given the output from merge, return a list of files that had conflicts."
+    conflicts = [filename for status, filename in results if 'C' in status]
+    return conflicts
+
+def commit(workingdir, commit_message, logfile):
+    """Returns newly committed revision number, or None if there was nothing to
+    commit.  -1 on error."""
+    svncmd = "cd %s && svn commit --no-auth-cache --username=mergebot " \
+        "--password=mergebot -m %s 2>>%s" % (workingdir,
+            shell_quote(commit_message), logfile)
+    return _svn_new_rev_command(svncmd)
+
+def _svn_new_rev_command(svncmd):
+    """Given an svn command that results in a new revision, return the revision
+    number, or -1 on error."""
+    pipe = os.popen(svncmd)
+    output = pipe.read()
+    retval = pipe.close()
+    if retval:
+        new_revision = -1
+    else:
+        new_revisions = re.compile("Committed revision ([0-9]+)\\.",
+            re.M).findall(output)
+        if new_revisions:
+            new_revision = new_revisions[0]
+        else:
+            new_revision = None
+    return new_revision
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: mergebot/branches/ticket-2/mergebot/__init__.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/__init__.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/__init__.py	(revision 20)
@@ -0,0 +1,1 @@
+from mergebot.web_ui import *
Index: mergebot/branches/ticket-2/mergebot/action_logic.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/action_logic.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/action_logic.py	(revision 20)
@@ -0,0 +1,37 @@
+def _get_mergebotstate(ticket):
+    if hasattr(ticket, '_old') and 'mergebotstate' in ticket._old:
+        state = ticket._old['mergebotstate']
+    else:
+        try:
+            state = ticket['mergebotstate']
+        except KeyError:
+            state = ""
+    if state == "--":
+        state = ""
+    return state
+
+# FIXME: If a ticket has another ticket as its version (#123), then we need to
+# make sure that the other ticket has an existing branch ('branched' mergebot
+# state) before we allow any actions.
+# More accurately, we need to determine if, after all queued actions upon which
+# it would depend complete successfully, that these operations would be
+# allowed.
+def is_branchable(ticket):
+    state = _get_mergebotstate(ticket)
+    return state == "" or state == "merged"
+
+def is_rebranchable(ticket):
+    # TODO: we should be able to tell if trunk (or version) has had commits
+    # since we branched, and only mark it as rebranchable if there have
+    # been.
+    state = _get_mergebotstate(ticket)
+    return state in ["branched", "conflicts"]
+
+def is_mergeable(ticket):
+    state = _get_mergebotstate(ticket)
+    return state == "branched"
+
+def is_checkmergeable(ticket):
+    state = _get_mergebotstate(ticket)
+    return state == "branched" or state == "conflicts"
+
Index: mergebot/branches/ticket-2/mergebot/daemonize.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/daemonize.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/daemonize.py	(revision 20)
@@ -0,0 +1,23 @@
+"""Implementation of the daemonizing process
+from "Advanced Programming in the UNIX Environment" by W. Richard Stevens,
+1993.
+"""
+import os
+import sys
+
+def daemonize():
+    # Let's assume nothing ever goes wrong...
+    pid = os.fork()
+    if pid:
+        sys.exit(0)
+    os.setsid()
+    os.chdir('/')
+    os.umask(000)
+    for n in range(1024):
+        try:
+            os.close(n)
+        except OSError:
+            pass
+    os.open('/dev/null', os.O_RDONLY)
+    os.open('/dev/null', os.O_WRONLY)
+    os.dup(1)
Index: mergebot/branches/ticket-2/mergebot/mergebotdaemon.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/mergebotdaemon.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/mergebotdaemon.py	(revision 20)
@@ -0,0 +1,385 @@
+#!/usr/bin/python
+"""Daemon that performs mergebot operations and manages the associated work.
+"""
+
+import sys
+import socket
+import select
+import os
+import time
+import trac
+import base64
+
+from threading import Thread
+
+from mergebot.BranchActor import BranchActor
+from mergebot.RebranchActor import RebranchActor
+from mergebot.CheckMergeActor import CheckMergeActor
+from mergebot.MergeActor import MergeActor
+
+from mergebot.daemonize import daemonize
+
+
+class Task(object):
+    """Represents a task in the queue with its dependencies.
+    """
+    task_id = 0
+    task_name_to_actor = {
+        'merge': MergeActor,
+        'branch': BranchActor,
+        'rebranch': RebranchActor,
+        'checkmerge': CheckMergeActor,
+    }
+    def __init__(self, master, ticket, action, component, version, summary,
+                 requestor):
+        self.id = Task.task_id = Task.task_id + 1
+        self.master = master
+        self.action = action.lower()
+        if action not in Task.task_name_to_actor.keys():
+            raise Exception('Invalid task type %s' % action)
+        self.ticket = ticket
+        self.component = component
+        self.version = version
+        self.summary = summary
+        self.requestor = requestor
+        self.blocked_by = []
+        self._queued = False
+        self._running = False
+        self._completed = False
+
+    def find_blockers(self, tasks):
+        """Adds existing tasks that block this task to this task's blocked_by
+        list.
+        """
+        for task in tasks:
+            # Tasks for different components are completely independent
+            if task.component == self.component:
+                is_blocked = False
+                # if self.version is a ticket ID, then any action other than
+                # 'checkmerge' on the parent ticket blocks this action
+                if self.version.startswith('#'):
+                    parent_ticket = int(self.version.lstrip('#'))
+                    if task.ticket == parent_ticket and task.action != 'checkmerge':
+                        is_blocked = True
+                # Any action other than checkmerge on a child ticket of ours blocks us
+                if task.version.startswith('#'):
+                    parent_ticket = int(task.version.lstrip('#'))
+                    if self.ticket == parent_ticket and task.action != 'checkmerge':
+                        is_blocked = True
+                # If (re)branching, then blocked by other tickets that are merging _to_ self.version
+                if self.action == 'branch' or self.action == 'rebranch':
+                    if task.action == 'merge' and task.version == self.version:
+                        is_blocked = True
+                # A merge operation targeting our version blocks us
+                if task.action == 'merge' and task.version == self.version:
+                    is_blocked = True
+                # If there is another queued operation for this same ticket,
+                # that task blocks this one
+                if self.ticket == task.ticket:
+                    is_blocked = True
+
+                if is_blocked:
+                    self.blocked_by.append(task)
+        return len(self.blocked_by)
+
+    def other_task_completed(self, task):
+        """Remove the given task from this task's blocked_by list.
+        """
+        if task in self.blocked_by:
+            self.blocked_by.remove(task)
+        return len(self.blocked_by)
+
+    def queued(self):
+        """Mark this task ask queued to run.
+        """
+        self._queued = True
+
+    def started(self):
+        """Mark this task as running/started.
+        """
+        self._running = True
+
+    def completed(self):
+        """Mark this task as completed/zombie.
+        """
+        self._completed = True
+
+    def get_state(self):
+        """Return a single-character indicator of this task's current state.
+
+        Tasks are:
+            Pending if they are ready to run.
+            Waiting if they are blocked by another task.
+            Running if they are currently being done.
+            Zombie if they have been completed.
+        """
+        if self._completed:
+            state = 'Z'#ombie
+        elif self._running:
+            state = 'R'#unning
+        elif self._queued:
+            state = 'Q'#ueued
+        elif self.blocked_by:
+            state = 'W'#aiting
+        else:
+            state = 'P'#ending
+        return state
+
+    def execute(self):
+        """Performs the actions for this task.
+        """
+        work_dir = os.path.join(self.master.work_dir, 'worker-%s' % self.id)
+        actor = Task.task_name_to_actor[self.action](work_dir,
+            self.master.repo_url, self.master.repo_dir, self.ticket,
+            self.component, self.version, self.summary, self.requestor)
+        self.results, self.result_comment, self.success = actor.execute()
+        
+    def __str__(self):
+        summary = base64.b64encode(self.summary)
+        return ','.join([str(e) for e in (self.id, self.get_state(),
+            self.ticket, self.action, self.component, self.version,
+            self.requestor, summary)])
+
+
+class Worker(object):
+    """Thread to do the work for an operation; has a work area it is
+    responsible for.
+    """
+    def __init__(self, num, work_dir):
+        self.number = num
+        self.work_dir = work_dir
+        self.task = None
+        self.inbox_read, self.inbox_write = os.pipe()
+        self.notifier_read, self.notifier_write = os.pipe()
+        self.thread = Thread(target=self.work)
+        self.thread.setDaemon(True)
+        self.thread.start()
+
+    def queue(self, task):
+        task.queued()
+        self.task = task
+        os.write(self.inbox_write, 'q')
+
+    def _dequeue(self):
+        os.read(self.inbox_read, 1)
+
+    def _completed(self):
+        self.task.completed()
+        os.write(self.notifier_write, 'd')
+
+    def ack_complete(self):
+        os.read(self.notifier_read, 1)
+        return self.task
+
+    def notifier(self):
+        return self.notifier_read
+
+    def work(self):
+        while True:
+            # get a task -- blocking read on pipe?
+            self._dequeue()
+            # do the task
+            log_filename = os.path.join(self.work_dir, 'worker.%s' % self.number)
+            open(log_filename, 'a').write(str(self.task) + ' started %s\n' % time.time())
+            self.task.started()
+            self.task.execute()
+            open(log_filename, 'a').write(str(self.task) + ' completed %s\n' % time.time())
+            # notify master of completion -- write to pipe?
+            self._completed()
+
+
+class Mergebot(object):
+    # Maybe I should just pass this the trac environment dir and have it create
+    # an environment, then pull config info from that.
+    def __init__(self, trac_dir):
+        self.trac_dir = os.path.abspath(trac_dir)
+        self.trac_env = trac.env.open_environment(self.trac_dir)
+        config = self.trac_env.config
+        self.listen_on = (config.get('mergebot', 'listen.ip'),
+                          config.getint('mergebot', 'listen.port'))
+        self.work_dir = config.get('mergebot', 'work_dir')
+        if not os.path.isabs(self.work_dir):
+            self.work_dir = os.path.join(self.trac_dir, self.work_dir)
+        self.repo_url = config.get('mergebot', 'repository_url')
+        repo_dir = config.get('trac', 'repository_dir')
+        if not os.path.isabs(repo_dir):
+            repo_dir = os.path.join(self.trac_dir, repo_dir)
+        self.repo_dir = repo_dir
+
+        self.listening = None
+        self.worker_count = config.getint('mergebot', 'worker_count')
+        self.task_list = []
+
+    def run(self):
+        """Run forever, handling requests.
+        """
+        self.listening = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.listening.bind(self.listen_on)
+        self.listening.listen(10)
+        open_sockets = []
+        workers = [Worker(i, work_dir=self.work_dir) for i in range(self.worker_count)]
+        active = []
+        try:
+            while True:
+                fds = [self.listening] + open_sockets + [w.notifier() for w in
+                                                         active]
+                readable, _writeable, _other = select.select(fds, [], [])
+                for s in readable:
+                    if s is self.listening:
+                        new_socket, _address = self.listening.accept()
+                        open_sockets.append(new_socket)
+                    elif s in open_sockets:
+                        data = s.recv(4096)
+                        for line in data.rstrip('\n').split('\n'):
+                            if line == '':
+                                open_sockets.remove(s)
+                                s.close()
+                            elif line[:4].upper() == 'QUIT':
+                                open_sockets.remove(s)
+                                s.close()
+                            else:
+                                response = self.handle_command(line)
+                                if response:
+                                    s.send(response)
+                                # I think we're going to want to make this a
+                                # single-shot deal
+                                #s.close()
+                                #open_sockets.remove(s)
+                    else:
+                        # Must be an active worker telling us it is done
+                        worker = [w for w in active if w.notifier() == s][0]
+                        task = worker.ack_complete()
+                        active.remove(worker)
+                        workers.insert(0, worker)
+                        # On failure, cancel all tasks that depend on this one.
+                        if not task.success:
+                            self._remove_dependant_tasks(task)
+                        self._remove_task(task)
+                        self._update_ticket(task)
+                # TODO: need to handle connections that the other end
+                # terminates?
+
+                # Assign a task to a worker
+                available_workers = list(workers)
+                pending = [t for t in self.task_list if t.get_state() == 'P']
+                for w, t in zip(available_workers, pending):
+                    w.queue(t)
+                    workers.remove(w)
+                    active.append(w)
+
+        except KeyboardInterrupt:
+            print 'Exiting due to keyboard interrupt.'
+        except Exception, e:
+            print 'Exiting due to: ', e
+            raise
+        self.listening.close()
+
+    def handle_command(self, command):
+        """Takes input from clients, and calls the appropriate sub-command.
+        """
+        parts = command.strip().split()
+        if not parts:
+            return '\n'
+        command = parts[0].upper()
+        args = parts[1:]
+
+        response = 'unrecognized command "%s"' % command
+        if command == 'LIST':
+            response = self.command_list()
+        elif command == 'ADD':
+            response = self.command_add(args)
+        elif command == 'CANCEL':
+            response = self.command_cancel(args)
+        # etc...
+        return response + '\n'
+
+    def command_list(self):
+        """Returns a listing of all active tasks.
+        """
+        listing = []
+        for task in self.task_list:
+            listing.append(str(task))
+        return '\n'.join(listing) + '\nOK'
+
+    def command_add(self, args):
+        # create a new task object and add it to the pool
+        try:
+            ticket, action, component, version, requestor = args
+        except ValueError:
+            return 'Error: wrong number of args: add <ticket> <action> ' \
+                '<component> <version> <requestor>\nGot: %r' % args
+        try:
+            ticket = int(ticket.strip('#'))
+        except ValueError:
+            return 'Error: invalid ticket number "%s"' % ticket
+
+        trac_ticket = trac.ticket.Ticket(self.trac_env, ticket)        
+        summary = trac_ticket['summary']
+        new_task = Task(self, ticket, action, component, version, summary,
+                        requestor)
+        new_task.find_blockers(self.task_list)
+        self.task_list.append(new_task)
+        # and trigger the worker threads if needed
+        return 'OK'
+
+    def command_cancel(self, args):
+        try:
+            tasknum = int(args[0])
+        except ValueError:
+            return 'Error'
+        except IndexError:
+            return 'Error'
+        found = [t for t in self.task_list if t.id == tasknum]
+        if len(found) != 1:
+            return 'Error: Not found'
+        dead = found[0]
+        dead_state = dead.get_state()
+        if dead_state not in ['W', 'P']:
+            return 'Error: Cannot kill task (state %s)' % dead_state
+        self._remove_dependant_tasks(dead)
+        self._remove_task(dead)
+        return 'OK'
+
+    def _remove_task(self, dead):
+        """Removes the given task from the queue.
+        Removes task as a dependency from any other tasks in the queue.
+        Assumes the task is in the task_list.
+        """
+        try:
+            self.task_list.remove(dead)
+        except ValueError:
+            self.trac_env.log.error("Task %s not in task_list when asked to remove" % dead)
+        for t in self.task_list:
+            if dead in t.blocked_by:
+                t.blocked_by.remove(dead)
+
+    def _remove_dependant_tasks(self, task):
+        for t in self.task_list:
+            if task in t.blocked_by:
+                self._remove_dependant_tasks(t)
+                self._remove_task(t)
+
+    def _update_ticket(self, task):
+        ticket = trac.ticket.Ticket(self.trac_env, task.ticket)        
+        if task.results or task.result_comment:
+            for key, value in task.results.items():
+                ticket[key] = value
+            ticket.save_changes('mergebot', task.result_comment)
+
+
+def main(args):
+    foreground = False
+    if args[0] == '-f':
+        foreground = True
+        args = args[1:]
+    trac_dir = args[0]
+    if not foreground:
+        daemonize()
+    bot = Mergebot(trac_dir)
+    bot.run()
+
+def run():
+    main(sys.argv[1:])
+
+if __name__ == '__main__':
+    run()
Index: mergebot/branches/ticket-2/mergebot/templates/mergebot.html
===================================================================
--- mergebot/branches/ticket-2/mergebot/templates/mergebot.html	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/templates/mergebot.html	(revision 20)
@@ -0,0 +1,95 @@
+<!DOCTYPE html
+    PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:py="http://genshi.edgewall.org/"
+      xmlns:xi="http://www.w3.org/2001/XInclude">
+  <xi:include href="layout.html" />
+  <xi:include href="macros.html" />
+
+  <head>
+    <title>
+        MergeBot
+    </title>
+  </head>
+
+  <body>
+  <div id="content" class="mergebot">
+
+  <div id="content" class="query">
+    <h1>Queued</h1>
+    <table class="listing tickets">
+      <thead><tr>
+        <th>Ticket</th>
+        <th>Summary</th>
+
+        <th>Requestor</th>
+        <th>Action</th>
+        <th>Task ID</th>
+        <th>Task Status</th>
+
+        <th>Component</th>
+        <th>Version</th>
+        <th>Actions</th>
+      </tr></thead>
+
+      <tr py:for="task_id, task_status, ticket_id, action, component, version, requestor, summary in queue">
+        <td><a href="${href.ticket(ticket_id)}">#${ticket_id}</a></td>
+        <td><a href="${href.ticket(ticket_id)}">${summary}</a></td>
+
+        <td>${requestor}</td>
+        <td>${action}</td>
+        <td>${task_id}</td>
+        <td>${task_status}</td>
+
+        <td>${component}</td>
+        <td>${version}</td>
+        <td>
+          <form id="cancel_tasks" method="post" name="cancel-task-%{task_id}" action="">
+            <input type="hidden" name="task" value="${task_id}"/>
+            <input type="submit" name="action" value="Cancel" py:if="task_status in ['Waiting', 'Pending']"/>
+          </form>
+        </td>
+      </tr>
+    </table>
+  </div>
+
+  <!-- Tickets that are not in an activity queue: -->
+  <div id="content" class="query">
+    <h1>Unqueued</h1>
+
+    <table class="listing tickets">
+      <thead><tr>
+        <th>Ticket</th>
+        <th>Summary</th>
+        <th>Component</th>
+        <th>Version</th>
+        <th>Status</th>
+        <th>MergeBotState</th>
+        <th>Actions</th>
+      </tr></thead>
+      <tr py:for="ticket in unqueued">
+        <td><a href="${href.ticket(ticket.info.id)}">${ticket.info.id}</a></td>
+        <td><a href="${href.ticket(ticket.info.id)}">${ticket.info.summary}</a></td>
+        <td>${ticket.info.component}</td>
+        <td>${ticket.info.version}</td>
+        <td>${ticket.info.status}</td>
+        <td>${ticket.info.mergebotstate}</td>
+        <td>
+          <form id="ops" method="post" name="ops-${ticket.info.id}" action="">
+            <input type="hidden" name="ticket" value="${ticket.info.id}" />
+            <input type="hidden" name="component" value="${ticket.info.component}" />
+            <input type="hidden" name="version" value="${ticket.info.version}" />
+            <input type="submit" name="action" value="Branch" py:if="ticket.branch"/>
+            <input type="submit" name="action" value="Rebranch" py:if="ticket.rebranch"/>
+            <input type="submit" name="action" value="Merge" py:if="ticket.merge"/>
+            <input type="submit" name="action" value="CheckMerge" py:if="ticket.checkmerge"/>
+          </form>
+        </td>
+      </tr>
+    </table>
+  </div>
+
+  </div>
+  </body>
+</html>
Index: mergebot/branches/ticket-2/mergebot/ticket_actions.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/ticket_actions.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/ticket_actions.py	(revision 20)
@@ -0,0 +1,115 @@
+import socket
+import time
+from subprocess import check_call
+
+from genshi.builder import tag
+
+from trac.core import implements, Component
+from trac.ticket.api import ITicketActionController
+from trac.ticket.default_workflow import ConfigurableTicketWorkflow
+from trac.web.chrome import add_warning
+
+from BranchActor import BranchActor
+from RebranchActor import RebranchActor
+from MergeActor import MergeActor
+from CheckMergeActor import CheckMergeActor
+from action_logic import is_branchable, is_rebranchable, is_mergeable, is_checkmergeable
+
+class MergebotActionController(Component):
+    """Support branching and merging operations for tickets.
+    """
+    implements(ITicketActionController)
+
+    # ITicketActionController
+    def get_ticket_actions(self, req, ticket):
+        controller = ConfigurableTicketWorkflow(self.env)
+        mergebot_operations = self._get_available_operations(req, ticket)
+        if mergebot_operations:
+            actions_we_handle = controller.get_actions_by_operation_for_req(req,
+                ticket, 'mergebot')
+        else:
+            actions_we_handle = []
+        return actions_we_handle
+
+    def get_all_status(self):
+        return []
+
+    def render_ticket_action_control(self, req, ticket, action):
+        actions = ConfigurableTicketWorkflow(self.env).actions
+        label = actions[action]['name']
+        hint = ''
+
+        control_id = action + '_mergebot_op'
+        mergebot_operations = self._get_available_operations(req, ticket)
+
+        # TODO: allow the configuration to specify a sub-set of permitted
+        # mergebot actions
+        #self.config.get('ticket-workflow', action + '.mergebot')
+
+        self.env.log.debug('render_ticket_action_control ops: %s', mergebot_operations)
+        selected_value = req.args.get(control_id) or mergebot_operations[0]
+        control = tag.select([tag.option(option, selected=(option == selected_value or None)) for option in mergebot_operations], name=control_id, id=control_id)
+
+        return (label, control, hint)
+
+
+    def get_ticket_changes(self, req, ticket, action):
+        control_id = action + '_mergebot_op'
+        selected_value = req.args.get(control_id)
+        add_warning(req, 'Mergebot operation %s will be triggered' % selected_value)
+        return {}
+
+    def daemon_address(self):
+        host = self.env.config.get('mergebot', 'listen.ip')
+        port = self.env.config.getint('mergebot', 'listen.port')
+        return (host, port)
+
+    def start_daemon(self):
+        check_call(['mergebotdaemon', self.env.path])
+        time.sleep(1) # bleh
+
+    def _daemon_cmd(self, cmd):
+        self.log.debug('Sending mergebotdaemon: %r' % cmd)
+        try:
+            info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            info_socket.connect(self.daemon_address())
+        except socket.error, e:
+            # if we're refused, try starting the daemon and re-try
+            if e and e[0] == 111:
+                self.log.debug('connection to mergebotdaemon refused, trying to start mergebotdaemon')
+                self.start_daemon()
+            self.log.debug('Resending mergebotdaemon: %r' % cmd)
+            info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            info_socket.connect(self.daemon_address())
+        info_socket.sendall(cmd)
+        self.log.debug('Reading mergebotdaemon response')
+        raw = info_socket.recv(4096)
+        info_socket.close()
+        self.log.debug('Reading mergebotdaemon response was %r' % raw)
+        return raw
+
+    def apply_action_side_effects(self, req, ticket, action):
+        self.log.info('applying mergebot side effect for ticket %s on action %s' % (ticket.id, action))
+        control_id = action + '_mergebot_op'
+        selected_value = req.args.get(control_id)
+
+        result = self._daemon_cmd('ADD %s %s %s %s %s\n\QUIT\n' % (ticket.id, selected_value, ticket['component'], ticket['version'], req.authname or 'anonymous'))
+        if 'OK' not in result:
+            add_warning(req, result) # adding warnings in this method doesn't seem to work
+
+    def _get_available_operations(self, req, ticket):
+        mergebot_ops = []
+        if req.perm.has_permission("MERGEBOT_BRANCH"):
+            if is_branchable(ticket):
+                mergebot_ops.append('branch')
+            if is_rebranchable(ticket):
+                mergebot_ops.append('rebranch')
+        if is_checkmergeable(ticket) and req.perm.has_permission('MERGEBOT_VIEW'):
+            mergebot_ops.append('checkmerge')
+        if ticket['version'].startswith("#"):
+            may_merge = req.perm.has_permission("MERGEBOT_MERGE_TICKET")
+        else:
+            may_merge = req.perm.has_permission("MERGEBOT_MERGE_RELEASE")
+        if may_merge and is_mergeable(ticket):
+            mergebot_ops.append('merge')
+        return mergebot_ops
Index: mergebot/branches/ticket-2/mergebot/web_ui.py
===================================================================
--- mergebot/branches/ticket-2/mergebot/web_ui.py	(revision 20)
+++ mergebot/branches/ticket-2/mergebot/web_ui.py	(revision 20)
@@ -0,0 +1,272 @@
+#!/usr/bin/env python
+
+import socket
+import time
+import os
+import base64
+from subprocess import check_call
+
+from trac.core import *
+from trac import util
+from trac.ticket.query import Query
+from trac.ticket.model import Ticket
+from trac.env import IEnvironmentSetupParticipant
+from trac.perm import IPermissionRequestor
+from trac.web.main import IRequestHandler
+from trac.web.chrome import INavigationContributor, ITemplateProvider, add_warning
+
+from action_logic import is_branchable, is_rebranchable, is_mergeable, is_checkmergeable
+
+class MergeBotModule(Component):
+    implements(INavigationContributor, IPermissionRequestor, IRequestHandler, ITemplateProvider, IEnvironmentSetupParticipant)
+
+    # IEnvironmentSetupParticipant
+    def environment_created(self):
+        self._setup_config()
+
+    def environment_needs_upgrade(self, db):
+        return not list(self.config.options('mergebot'))
+
+    def upgrade_environment(self, db):
+        self._setup_config()
+
+    def _setup_config(self):
+        self.config.set('mergebot', 'work_dir', 'mergebot')
+        self.config.set('mergebot', 'repository_url', 'http://FIXME/svn')
+        self.config.set('mergebot', 'listen.ip', 'localhost')
+        self.config.set('mergebot', 'listen.port', '12345')
+        self.config.set('mergebot', 'worker_count', '2')
+        # Set up the needed custom field for the bot
+        self.config.set('ticket-custom', 'mergebotstate', 'select')
+        self.config.set('ticket-custom', 'mergebotstate.label', 'MergeBotState')
+        self.config.set('ticket-custom', 'mergebotstate.options', '| merged | branched | conflicts')
+        self.config.set('ticket-custom', 'mergebotstate.value', '')
+        self.config.save()
+
+    # INavigationContributor
+    # so it shows up in the main nav bar
+    def get_active_navigation_item(self, req):
+        return 'mergebot'
+
+    def get_navigation_items(self, req):
+        """Generator that yields the MergeBot tab, but only if the user has
+        MERGEBOT_VIEW privs."""
+        if req.perm.has_permission("MERGEBOT_VIEW"):
+            label = util.Markup('<a href="%s">MergeBot</a>' % \
+                req.href.mergebot())
+            yield ('mainnav', 'mergebot', label)
+
+    # IPermissionRequestor methods
+    # So we can control access to this functionality
+    def get_permission_actions(self):
+        """Returns a permission structure."""
+        actions = ["MERGEBOT_VIEW", "MERGEBOT_BRANCH", "MERGEBOT_MERGE_TICKET",
+            "MERGEBOT_MERGE_RELEASE"]
+        # MERGEBOT_ADMIN implies all of the above permissions
+        allactions = actions + [
+            ("MERGEBOT_ADMIN", actions),
+            ("MERGEBOT_BRANCH", ["MERGEBOT_VIEW"]),
+            ("MERGEBOT_MERGE_TICKET", ["MERGEBOT_VIEW"]),
+            ("MERGEBOT_MERGE_RELEASE", ["MERGEBOT_VIEW"])
+            ]
+        return allactions
+
+    # IRequestHandler
+    def match_request(self, req):
+        """Returns true, if the given request path is handled by this module"""
+        # For now, we don't recognize any arguments...
+        return req.path_info == "/mergebot" or req.path_info.startswith("/mergebot/")
+
+    def _get_ticket_info(self, ticketid):
+        # grab the ticket info we care about
+        fields = ['summary', 'component', 'version', 'status']
+        info = {}
+        if ticketid:
+            ticket = Ticket(self.env, ticketid)
+            for field in fields:
+                info[field] = ticket[field]
+            info['href'] = self.env.href.ticket(ticketid)
+            self.log.debug("id=%s, info=%r" % (ticketid, info))
+        return info
+
+    def daemon_address(self):
+        host = self.env.config.get('mergebot', 'listen.ip')
+        port = int(self.env.config.get('mergebot', 'listen.port'))
+        return (host, port)
+
+    def start_daemon(self):
+        check_call(['mergebotdaemon', self.env.path])
+        time.sleep(1) # bleh
+
+    def _daemon_cmd(self, cmd):
+        self.log.debug('Sending mergebotdaemon: %r' % cmd)
+        try:
+            info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            info_socket.connect(self.daemon_address())
+        except socket.error, e:
+            # if we're refused, try starting the daemon and re-try
+            if e and e[0] == 111:
+                self.log.debug('connection to mergebotdaemon refused, trying to start mergebotdaemon')
+                self.start_daemon()
+            self.log.debug('Resending mergebotdaemon: %r' % cmd)
+            info_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            info_socket.connect(self.daemon_address())
+        info_socket.sendall(cmd)
+        self.log.debug('Reading mergebotdaemon response')
+        raw = info_socket.recv(4096)
+        info_socket.close()
+        self.log.debug('Reading mergebotdaemon response was %r' % raw)
+        return raw
+
+    def process_request(self, req):
+        """This is the function called when a user requests a mergebot page."""
+        req.perm.assert_permission("MERGEBOT_VIEW")
+
+        # 2nd redirect back to the real mergebot page to address POST and
+        # browser refresh
+        if req.path_info == "/mergebot/redir":
+            req.redirect(req.href.mergebot())
+
+        data = {}
+
+        if req.method == "POST": # The user hit a button
+            if req.args['action'] in ['Branch', 'Rebranch', 'CheckMerge', 'Merge']:
+                ticketnum = req.args['ticket']
+                component = req.args['component']
+                version = req.args['version']
+                requestor = req.authname or 'anonymous'
+                ticket = Ticket(self.env, int(ticketnum))
+                # FIXME: check for 'todo' key?
+                # If the request is not valid, just ignore it.
+                action = None
+                if req.args['action'] == "Branch":
+                    req.perm.assert_permission("MERGEBOT_BRANCH")
+                    if is_branchable(ticket):
+                        action = 'branch'
+                elif req.args['action'] == "Rebranch":
+                    req.perm.assert_permission("MERGEBOT_BRANCH")
+                    if is_rebranchable(ticket):
+                        action = 'rebranch'
+                elif req.args['action'] == "CheckMerge":
+                    req.perm.assert_permission("MERGEBOT_VIEW")
+                    if is_checkmergeable(ticket):
+                        action = 'checkmerge'
+                elif req.args['action'] == "Merge":
+                    if version.startswith("#"):
+                        req.perm.assert_permission("MERGEBOT_MERGE_TICKET")
+                    else:
+                        req.perm.assert_permission("MERGEBOT_MERGE_RELEASE")
+                    if is_mergeable(ticket):
+                        action = 'merge'
+                if action:
+                    command = 'ADD %s %s %s %s %s\nQUIT\n' % (ticketnum, action, component, version, requestor)
+                    result = self._daemon_cmd(command)
+                    if 'OK' not in result:
+                        add_warning(req, result)
+            if req.args['action'] == "Cancel":
+                command = 'CANCEL %s\nQUIT\n' % req.args['task']
+                result = self._daemon_cmd(command)
+                if 'OK' not in result:
+                    add_warning(req, result)
+            # First half of a double-redirect to make a refresh not re-send the
+            # POST data.
+            req.redirect(req.href.mergebot("redir"))
+
+        # We want to fill out the information for the page unconditionally.
+
+        # Connect to the daemon and read the current queue information
+        raw_queue_info = self._daemon_cmd('LIST\nQUIT\n')
+        # Parse the queue information into something we can display
+        queue_info = [x.split(',') for x in raw_queue_info.split('\n') if ',' in x]
+        status_map = {
+            'Z':'Zombie',
+            'R':'Running',
+            'Q':'Queued',
+            'W':'Waiting',
+            'P':'Pending',
+        }
+        for row in queue_info:
+            status = row[1]
+            row[1] = status_map[status]
+            summary = row[7]
+            row[7] = base64.b64decode(summary)
+
+        data['queue'] = queue_info
+
+        queued_tickets = set([int(q[2]) for q in queue_info])
+
+        # Provide the list of tickets at the bottom of the page, along with
+        # flags for which buttons should be enabled for each ticket.
+        # I need to get a list of tickets.  For non-admins, restrict the list
+        # to the tickets owned by that user.
+        querystring = "status!=closed&version!="
+        required_columns = ["component", "version", "mergebotstate"]
+        if req.perm.has_permission("MERGEBOT_ADMIN"):
+            required_columns.append("owner")
+        else:
+            querystring += "&owner=%s" % (req.authname,)
+        query = Query.from_string(self.env, querystring, order="id")
+        columns = query.get_columns()
+        for name in required_columns:
+            if name not in columns:
+                columns.append(name)
+        db = self.env.get_db_cnx()
+        tickets = query.execute(req, db)
+        data['unqueued'] = []
+        for ticket in tickets:
+            ticketnum = ticket['id']
+            if ticketnum in queued_tickets:
+                # Don't allow more actions to be taken on a ticket that is
+                # currently queued for something.  In the future, we may want
+                # to support a 'Cancel' button, though.  Or present actions
+                # based upon the expected state of the ticket
+                continue
+
+            ticket_info = {'info': ticket}
+            data['unqueued'].append(ticket_info)
+
+            # Select what actions this user may make on this ticket based on
+            # its current state.
+
+            # MERGE
+            ticket_info['merge'] = False
+            if ticket['version'].startswith('#'):
+                if req.perm.has_permission("MERGEBOT_MERGE_TICKET"):
+                    ticket_info['merge'] = is_mergeable(ticket)
+            else:
+                if req.perm.has_permission("MERGEBOT_MERGE_RELEASE"):
+                    ticket_info['merge'] = is_mergeable(ticket)
+            # CHECK-MERGE
+            if req.perm.has_permission("MERGEBOT_VIEW"):
+                ticket_info['checkmerge'] = is_checkmergeable(ticket)
+            else:
+                ticket_info['checkmerge'] = False
+            # BRANCH, REBRANCH
+            if req.perm.has_permission("MERGEBOT_BRANCH"):
+                ticket_info['branch'] = is_branchable(ticket)
+                ticket_info['rebranch'] = is_rebranchable(ticket)
+            else:
+                ticket_info['branch'] = False
+                ticket_info['rebranch'] = False
+
+        # proactive warnings
+        work_dir = self.env.config.get('mergebot', 'work_dir')
+        if not os.path.isabs(work_dir):
+            work_dir = os.path.join(self.env.path, work_dir)
+        if not os.path.isdir(work_dir):
+            add_warning(req, 'The Mergebot work directory "%s" does not exist.' % work_dir)
+
+        return "mergebot.html", data, None
+
+    # ITemplateProvider
+    def get_htdocs_dirs(self):
+        return []
+
+    def get_templates_dirs(self):
+        # It appears that everyone does this import here instead of at the top
+        # level... I'm not sure I understand why...
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
+# vim:foldmethod=indent foldcolumn=8
+# vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab
Index: mergebot/branches/ticket-2/setup.cfg
===================================================================
--- mergebot/branches/ticket-2/setup.cfg	(revision 20)
+++ mergebot/branches/ticket-2/setup.cfg	(revision 20)
@@ -0,0 +1,4 @@
+[egg_info]
+;tag_build = dev
+;tag_svn_revision = true
+
Index: mergebot/branches/ticket-2/setup.py
===================================================================
--- mergebot/branches/ticket-2/setup.py	(revision 20)
+++ mergebot/branches/ticket-2/setup.py	(revision 20)
@@ -0,0 +1,30 @@
+#!/usr/bin/env python
+
+from setuptools import setup
+
+setup(
+    name = "TracMergeBot",
+    version = "0.11",
+    author = "Eli Carter",
+    author_email = "eli.carter@retracile.net",
+    url = "https://retracile.net",
+    description = "Branch and merge management plugin",
+    license = """MIT""",
+    zip_safe=False,
+    packages=["mergebot"],
+    package_data={"mergebot": ["templates/*.html"]},
+
+    install_requires = [],
+
+    entry_points = {
+        "console_scripts": [
+            "mergebotdaemon = mergebot.mergebotdaemon:run",
+        ],
+        "trac.plugins": [
+            "mergebot.web_ui = mergebot.web_ui",
+            "mergebot.ticket_actions = mergebot.ticket_actions"
+        ]
+    },
+)
+# vim:foldmethod=indent foldcolumn=8
+# vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab
Index: mergebot/branches/ticket-2/utils/bdist_rpm_installscript
===================================================================
--- mergebot/branches/ticket-2/utils/bdist_rpm_installscript	(revision 20)
+++ mergebot/branches/ticket-2/utils/bdist_rpm_installscript	(revision 20)
@@ -0,0 +1,1 @@
+python setup.py install --optimize=1 --single-version-externally-managed --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES
Index: mergebot/branches/ticket-2/utils/makerpm
===================================================================
--- mergebot/branches/ticket-2/utils/makerpm	(revision 20)
+++ mergebot/branches/ticket-2/utils/makerpm	(revision 20)
@@ -0,0 +1,2 @@
+#!/bin/bash
+python setup.py bdist_rpm --install-script=utils/bdist_rpm_installscript
Index: mergebot/branches/ticket-2/utils/run_tests
===================================================================
--- mergebot/branches/ticket-2/utils/run_tests	(revision 20)
+++ mergebot/branches/ticket-2/utils/run_tests	(revision 20)
@@ -0,0 +1,2 @@
+#!/bin/bash
+PYTHONPATH=../../twill-0.9:../../genshi-0.5.0:../../trac-0.11 utils/test.py
Index: mergebot/branches/ticket-2/utils/test.py
===================================================================
--- mergebot/branches/ticket-2/utils/test.py	(revision 20)
+++ mergebot/branches/ticket-2/utils/test.py	(revision 20)
@@ -0,0 +1,295 @@
+#!/usr/bin/python
+"""Automated tests for MergeBot
+"""
+
+import os
+import unittest
+import time
+import shutil
+
+from subprocess import call, Popen #, PIPE, STDOUT
+from twill.errors import TwillAssertionError
+
+
+from trac.tests.functional import FunctionalTestSuite, FunctionalTestEnvironment, FunctionalTester, FunctionalTwillTestCaseSetup, tc, b, logfile
+from trac.tests.contentgen import random_page #, random_sentence, random_word
+
+
+#class MergeBotTestEnvironment(FunctionalTestEnvironment):
+#    """Slight change to FunctionalTestEnvironment to keep the PYTHONPATH from
+#    our environment.
+#    """
+#    def start(self):
+#        """Starts the webserver"""
+#        server = Popen(["python", "./trac/web/standalone.py",
+#                        "--port=%s" % self.port, "-s",
+#                        "--basic-auth=trac,%s," % self.htpasswd,
+#                        self.tracdir],
+#                       #env={'PYTHONPATH':'.'},
+#                       stdout=logfile, stderr=logfile,
+#                      )
+#        self.pid = server.pid
+#        time.sleep(1) # Give the server time to come up
+#
+#    def _tracadmin(self, *args):
+#        """Internal utility method for calling trac-admin"""
+#        if call(["python", "./trac/admin/console.py", self.tracdir] +
+#                list(args),
+#                #env={'PYTHONPATH':'.'},
+#                stdout=logfile, stderr=logfile):
+#            raise Exception('Failed running trac-admin with %r' % (args, ))
+#
+#
+#FunctionalTestEnvironment = MergeBotTestEnvironment
+
+
+class MergeBotFunctionalTester(FunctionalTester):
+    """Adds some MergeBot functionality to the functional tester."""
+    # FIXME: the tc.find( <various actions> ) checks are bogus: any ticket can
+    # satisfy them, not just the one we're working on.
+    def __init__(self, *args, **kwargs):
+        FunctionalTester.__init__(self, *args, **kwargs)
+        self.mergeboturl = self.url + '/mergebot'
+
+    def wait_until_find(self, search, timeout=5):
+        start = time.time()
+        while time.time() - start < timeout:
+            try:
+                tc.reload()
+                tc.find(search)
+                return
+            except TwillAssertionError:
+                pass
+        raise TwillAssertionError("Unable to find %r within %s seconds" % (search, timeout))
+
+    def wait_until_notfind(self, search, timeout=5):
+        start = time.time()
+        while time.time() - start < timeout:
+            try:
+                tc.reload()
+                tc.notfind(search)
+                return
+            except TwillAssertionError:
+                pass
+        raise TwillAssertionError("Unable to notfind %r within %s seconds" % (search, timeout))
+
+    def go_to_mergebot(self):
+        tc.go(self.mergeboturl)
+        tc.url(self.mergeboturl)
+        tc.notfind('No handler matched request to /mergebot')
+
+    def branch(self, ticket_id, component, timeout=1):
+        """timeout is in seconds."""
+        self.go_to_mergebot()
+        tc.formvalue('ops-%s' % ticket_id, 'ticket', ticket_id) # Essentially a noop to select the right form
+        tc.submit('Branch')
+        self.wait_until_notfind('Doing branch', timeout)
+        tc.find('Rebranch')
+        tc.find('Merge')
+        tc.find('CheckMerge')
+        self.go_to_ticket(ticket_id)
+        tc.find('Created branch from .* for .*')
+        retval = call(['svn', 'ls', self.repo_url + '/' + component + '/branches/ticket-%s' % ticket_id],
+                    stdout=logfile, stderr=logfile)
+        if retval:
+            raise Exception('svn ls failed with exit code %s' % retval)
+
+    def rebranch(self, ticket_id, component, timeout=5):
+        """timeout is in seconds."""
+        self.go_to_mergebot()
+        tc.formvalue('ops-%s' % ticket_id, 'ticket', ticket_id) # Essentially a noop to select the right form
+        tc.submit('Rebranch')
+        self.wait_until_notfind('Doing rebranch', timeout)
+        tc.find('Rebranch')
+        tc.find('Merge')
+        tc.find('CheckMerge')
+        self.go_to_ticket(ticket_id)
+        tc.find('Rebranched from .* for .*')
+        retval = call(['svn', 'ls', self.repo_url + '/' + component + '/branches/ticket-%s' % ticket_id],
+                    stdout=logfile, stderr=logfile)
+        if retval:
+            raise Exception('svn ls failed with exit code %s' % retval)
+
+    def merge(self, ticket_id, component, timeout=5):
+        """timeout is in seconds."""
+        self.go_to_mergebot()
+        tc.formvalue('ops-%s' % ticket_id, 'ticket', ticket_id) # Essentially a noop to select the right form
+        tc.submit('Merge')
+        self.wait_until_notfind('Doing merge', timeout)
+        tc.find('Branch')
+        self.go_to_ticket(ticket_id)
+        tc.find('Merged .* to .* for')
+        # TODO: We may want to change this to remove the "dead" branch
+        retval = call(['svn', 'ls', self.repo_url + '/' + component + '/branches/ticket-%s' % ticket_id],
+                    stdout=logfile, stderr=logfile)
+        if retval:
+            raise Exception('svn ls failed with exit code %s' % retval)
+
+    def checkmerge(self, ticket_id, component, timeout=5):
+        """timeout is in seconds."""
+        self.go_to_mergebot()
+        tc.formvalue('ops-%s' % ticket_id, 'ticket', ticket_id) # Essentially a noop to select the right form
+        tc.submit('CheckMerge')
+        self.wait_until_notfind('Doing checkmerge', timeout)
+        tc.find('Rebranch')
+        tc.find('Merge')
+        tc.find('CheckMerge')
+        self.go_to_ticket(ticket_id)
+        tc.find('while checking merge of')
+        # TODO: We may want to change this to remove the "dead" branch
+        retval = call(['svn', 'ls', self.repo_url + '/' + component + '/branches/ticket-%s' % ticket_id],
+                    stdout=logfile, stderr=logfile)
+        if retval:
+            raise Exception('svn ls failed with exit code %s' % retval)
+
+
+class MergeBotTestSuite(FunctionalTestSuite):
+    def setUp(self):
+        port = 8889
+        baseurl = "http://localhost:%s" % port
+        self._testenv = FunctionalTestEnvironment("testenv%s" % port, port, baseurl)
+
+        # Configure mergebot
+        env = self._testenv.get_trac_environment()
+        env.config.set('components', 'mergebot.web_ui.mergebotmodule', 'enabled')
+        env.config.set('mergebot', 'repository_url', self._testenv.repo_url())
+        env.config.set('mergebot', 'work_dir', self._testenv.repodir + '/mergebot')
+
+        env.config.set('ticket-custom', 'mergebotstate', 'select')
+        env.config.set('ticket-custom', 'mergebotstate.editable', '0')
+        env.config.set('ticket-custom', 'mergebotstate.label', 'MergeBotState')
+        env.config.set('ticket-custom', 'mergebotstate.options', '| tomerge | merged | tobranch | branched | conflicts')
+        env.config.set('ticket-custom', 'mergebotstate.order', '2')
+        env.config.set('ticket-custom', 'mergebotstate.value', '')
+
+        env.config.set('logging', 'log_type', 'file')
+
+        env.config.save()
+        env.config.parse_if_needed()
+
+        self._testenv.start()
+        self._tester = MergeBotFunctionalTester(baseurl, self._testenv.repo_url())
+
+        # Setup some common component stuff for MergeBot's use:
+        svnurl = self._testenv.repo_url()
+        for component in ['stuff', 'flagship', 'submarine']:
+            self._tester.create_component(component)
+            if call(['svn', '-m', 'Create tree for "%s".' % component, 'mkdir',
+                     svnurl + '/' + component,
+                     svnurl + '/' + component + '/trunk',
+                     svnurl + '/' + component + '/tags',
+                     svnurl + '/' + component + '/branches'],
+                    stdout=logfile, stderr=logfile):
+                raise Exception("svn mkdir failed")
+
+        self._tester.create_version('trunk')
+
+
+class MergeBotTestEnabled(FunctionalTwillTestCaseSetup):
+    def runTest(self):
+        self._tester.logout()
+        tc.go(self._tester.url)
+        self._tester.login('admin')
+        tc.follow('MergeBot')
+        mergeboturl = self._tester.url + '/mergebot'
+        tc.url(mergeboturl)
+        tc.notfind('No handler matched request to /mergebot')
+
+
+class MergeBotTestQueueList(FunctionalTwillTestCaseSetup):
+    def runTest(self):
+        tc.follow('MergeBot')
+        for queue in 'branch', 'rebranch', 'checkmerge', 'merge':
+            tc.find('%s Queue' % queue)
+
+
+class MergeBotTestNoVersion(FunctionalTwillTestCaseSetup):
+    """Verify that if a ticket does not have the version field set, it will not
+    appear in the MergeBot list.
+    """
+    def runTest(self):
+        ticket_id = self._tester.create_ticket(summary=self.__class__.__name__,
+            info={'component':'stuff', 'version':''})
+        tc.follow('MergeBot')
+        tc.notfind(self.__class__.__name__)
+
+
+class MergeBotTestBranch(FunctionalTwillTestCaseSetup):
+    def runTest(self):
+        """Verify that the 'branch' button works"""
+        ticket_id = self._tester.create_ticket(summary=self.__class__.__name__,
+            info={'component':'stuff', 'version':'trunk'})
+        self._tester.branch(ticket_id, 'stuff')
+
+
+class MergeBotTestRebranch(FunctionalTwillTestCaseSetup):
+    def runTest(self):
+        """Verify that the 'rebranch' button works"""
+        ticket_id = self._tester.create_ticket(summary=self.__class__.__name__,
+            info={'component':'stuff', 'version':'trunk'})
+        self._tester.branch(ticket_id, 'stuff')
+        self._tester.rebranch(ticket_id, 'stuff')
+
+
+class MergeBotTestMerge(FunctionalTwillTestCaseSetup):
+    def runTest(self):
+        """Verify that the 'merge' button works"""
+        ticket_id = self._tester.create_ticket(summary=self.__class__.__name__,
+            info={'component':'stuff', 'version':'trunk'})
+        self._tester.branch(ticket_id, 'stuff')
+        self._tester.merge(ticket_id, 'stuff')
+
+
+class MergeBotTestCheckMerge(FunctionalTwillTestCaseSetup):
+    def runTest(self):
+        """Verify that the 'checkmerge' button works"""
+        ticket_id = self._tester.create_ticket(summary=self.__class__.__name__,
+            info={'component':'stuff', 'version':'trunk'})
+        self._tester.branch(ticket_id, 'stuff')
+        self._tester.checkmerge(ticket_id, 'stuff')
+
+
+class MergeBotTestSingleUseCase(FunctionalTwillTestCaseSetup):
+    def runTest(self):
+        """Create a branch, make a change, checkmerge, and merge it."""
+        ticket_id = self._tester.create_ticket(summary=self.__class__.__name__,
+            info={'component':'stuff', 'version':'trunk'})
+        self._tester.branch(ticket_id, 'stuff')
+        # checkout a working copy & make a change
+        svnurl = self._testenv.repo_url()
+        workdir = os.path.join(self._testenv.dirname, self.__class__.__name__)
+        retval = call(['svn', 'checkout', svnurl + '/stuff/branches/ticket-%s' % ticket_id, workdir],
+            stdout=logfile, stderr=logfile)
+        self.assertEqual(retval, 0, "svn checkout failed with error %s" % (retval))
+        # Create & add a new file
+        newfile = os.path.join(workdir, self.__class__.__name__)
+        open(newfile, 'w').write(random_page())
+        retval = call(['svn', 'add', self.__class__.__name__],
+            cwd=workdir,
+            stdout=logfile, stderr=logfile)
+        self.assertEqual(retval, 0, "svn add failed with error %s" % (retval))
+        retval = call(['svn', 'commit', '-m', 'Add a new file', self.__class__.__name__],
+            cwd=workdir,
+            stdout=logfile, stderr=logfile)
+        self.assertEqual(retval, 0, "svn commit failed with error %s" % (retval))
+
+        self._tester.checkmerge(ticket_id, 'stuff')
+        self._tester.merge(ticket_id, 'stuff')
+
+        shutil.rmtree(workdir) # cleanup working copy
+
+
+def suite():
+    suite = MergeBotTestSuite()
+    suite.addTest(MergeBotTestEnabled())
+    suite.addTest(MergeBotTestQueueList())
+    suite.addTest(MergeBotTestNoVersion())
+    suite.addTest(MergeBotTestBranch())
+    suite.addTest(MergeBotTestRebranch())
+    suite.addTest(MergeBotTestMerge())
+    suite.addTest(MergeBotTestCheckMerge())
+    suite.addTest(MergeBotTestSingleUseCase())
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
