Index: mergebot/trunk/COPYING
===================================================================
--- mergebot/trunk/COPYING	(revision 16)
+++ mergebot/trunk/COPYING	(revision 17)
@@ -2,4 +2,5 @@
 
 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
Index: mergebot/trunk/README.txt
===================================================================
--- mergebot/trunk/README.txt	(revision 16)
+++ mergebot/trunk/README.txt	(revision 17)
@@ -1,29 +1,30 @@
 = Installing =
 # Checkout the source.
-$ svn co <url> mergebot-0.10
-$ cd mergebot-0.10
-$ python setup.py bdist_egg
-$ cp dist/TracMergeBot*.egg <mytracenv>/plugins/
+$ 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
 
-# Add to trac.ini:
-[mergebot]
-work_dir = /var/www/trac/mytracenv/mergebot
-repository_url = http://servername/repos
+Enable mergebot in the [components] section:
+mergebot.web_ui.* = enabled
 
-[ticket-custom]
-mergebotstate = select
-mergebotstate.label = MergeBotState
-mergebotstate.options = | tomerge | merged | tobranch | branched | conflicts
-mergebotstate.value = 
+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.
 
-# Give MERGEBOT_* permissions to your users.
-# Make sure you have a version for 'trunk'
-# Make sure you have your Subversion directory structure setup as
-# <component>/trunk
-# <component>/tags
-# <component>/branches
+# 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.
 
-# Restart your webserver
+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/trunk/bdist_rpm_installscript
===================================================================
--- mergebot/trunk/bdist_rpm_installscript	(revision 16)
+++ 	(revision )
@@ -1,1 +1,0 @@
-python setup.py install --optimize=1 --single-version-externally-managed --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES
Index: mergebot/trunk/makerpm
===================================================================
--- mergebot/trunk/makerpm	(revision 16)
+++ 	(revision )
@@ -1,2 +1,0 @@
-#!/bin/bash
-python setup.py bdist_rpm --install-script=bdist_rpm_installscript
Index: mergebot/trunk/mergebot/Actor.py
===================================================================
--- mergebot/trunk/mergebot/Actor.py	(revision 17)
+++ mergebot/trunk/mergebot/Actor.py	(revision 17)
@@ -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/trunk/mergebot/BranchActor.py
===================================================================
--- mergebot/trunk/mergebot/BranchActor.py	(revision 16)
+++ mergebot/trunk/mergebot/BranchActor.py	(revision 17)
@@ -1,86 +1,76 @@
 #!/usr/bin/env python
 """Module for creating new branches for tickets"""
-# Syntax: BranchActor.py ticketnum component version requestor
 
 import os
-import sys
 import time
-import trac.env
 
-import SvnOps
-from WorkQueue import MergeBotActor, VersionToDir
-from TrackerTools import GetRepositoryPublicUrl, GetRepositoryLocalUrl, Task, \
-    GetLogFile
+from mergebot import SvnOps
+from mergebot.Actor import Actor
 
-def branch_action(trac_env, ticketnum, component, version, requestor):
-    "Create the branch"
-    task_obj = Task(trac_env, ticketnum)
+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))
 
-    # Setup logging
-    logfile = GetLogFile(trac_env, ticketnum)
-    open(logfile, "a").write("%s: branching ticket %s\n" % (time.asctime(),
-        ticketnum))
+        # 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
 
-    # Determine the URLS to copy from and to
-    branchdir = "branches/ticket-%s" % (ticketnum)
-    copyfrom = os.path.join(GetRepositoryLocalUrl(trac_env), component,
-        VersionToDir(version))
-    copyto = os.path.join(GetRepositoryLocalUrl(trac_env), component, branchdir)
+        commit_header = 'Ticket #%s: %s' % (self.ticket, self.summary)
 
-    commit_header = "Ticket #%s: %s" % (ticketnum, task_obj.GetSummary())
+        # 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
 
-    # 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(copyto, logfile):
-        # This branch already exists.
+        # Do the branch creationg
         commit_message = "\n".join([commit_header,
-            "    Delete old branch",
+            "    Create branch from %s for %s." % (self.version,
+                                                   self.requestor),
         ])
-        new_rev = SvnOps.delete_branch(copyto, commit_message, logfile)
-        if new_rev == -1:
-            status = "branchfailed"
-            return status, task_obj
-
-    # Do the branch creationg
-    commit_message = "\n".join([commit_header,
-        "    Create branch from %s for %s." % (version, requestor),
-    ])
-    retval = SvnOps.create_branch(copyfrom, copyto, commit_message, logfile)
-    if retval:
-        # Failed for some reason.
-        status = "branchfailed"
-    else:
-        publiccopyto = os.path.join(GetRepositoryPublicUrl(trac_env), component,
-            branchdir)
-        comment = "\n".join([
-            "Created branch from %s for %s." % (version, requestor),
-            "",
-            "Browse branch [source:%s source code] and [log:%s commit log]." % 
-                (os.path.join(component, branchdir),
-                os.path.join(component, branchdir)),
-            "",
-            "To checkout, run:",
-            "{{{",
-            "svn checkout %s %s-%s" % (publiccopyto, component, ticketnum),
-            "}}}",
+        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),
+            '}}}',
         ])
-        task_obj.AddComment(comment)
-        status = "branched"
-    return status, task_obj
-
-class BranchActor(MergeBotActor):
-    "Actor for creating a new branch."
-    def __init__(self, trac_env):
-        MergeBotActor.__init__(self, trac_env, "branch", branch_action)
-
-def main():
-    tracdir = sys.argv[1]
-    trac_env = trac.env.open_environment(tracdir)
-    branchingActor = BranchActor(trac_env)
-    branchingActor.AddTask(sys.argv[2:])
-    branchingActor.Run()
-
-if __name__ == "__main__":
-    main()
+        return results, comment, True
 
 # vim:foldcolumn=4 foldmethod=indent
Index: mergebot/trunk/mergebot/CheckMergeActor.py
===================================================================
--- mergebot/trunk/mergebot/CheckMergeActor.py	(revision 16)
+++ mergebot/trunk/mergebot/CheckMergeActor.py	(revision 17)
@@ -1,6 +1,4 @@
 #!/usr/bin/env python
 """
-Syntax: MergeActor.py ticketnum component version requestor
-
 Verify that a branch can be merged to its trunk without conflicts, but don't
 commit the merge.
@@ -8,74 +6,73 @@
 
 import os
-import sys
-import trac.env
+import shutil
 
-import SvnOps
-from WorkQueue import MergeBotActor, VersionToDir
-from TrackerTools import GetWorkDir, GetRepositoryLocalUrl, Task, GetLogFile
+from mergebot import SvnOps
+from mergebot.Actor import Actor
 
-def check_merge_action(trac_env, ticketnum, component, version, requestor):
+class CheckMergeActor(Actor):
+    """Checks that this ticket can be merged to its baseline, but don't modify
+    the repository.
     """
-    Verify that a branch can be merged to its trunk without conflicts, but
-    don't commit the merge.
-    """
-    task_obj = Task(trac_env, ticketnum)
-    endstatus = "???????"
-    workdir = GetWorkDir(trac_env, ticketnum, __name__)
-    logfile = GetLogFile(trac_env, ticketnum)
-    # FIXME: Should we just bail out instead?
-    if os.path.exists(workdir):
-        os.system("rm -rf \"%s\"" % (workdir))
+    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()
 
-    sourceurl = os.path.join(GetRepositoryLocalUrl(trac_env), component,
-        VersionToDir(version))
-    ticketurl = os.path.join(GetRepositoryLocalUrl(trac_env), component, "branches",
-        "ticket-%s" % ticketnum)
+        if os.path.exists(workdir):
+            shutil.rmtree(workdir)
 
-    branch_info = SvnOps.get_branch_info(ticketurl, GetLogFile(trac_env, ticketnum))
-    # FIXME: if not branch_info: # Error case
-    startrev, endrev = branch_info
+        # 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
 
-    SvnOps.checkout(sourceurl, workdir, logfile)
-    # TODO: check return code of the above
-    results = SvnOps.merge(ticketurl, workdir, (startrev, endrev), logfile)
-    conflicts = SvnOps.conflicts_from_merge_results(results)
-    if conflicts:
-        message = "\n".join([
-            "Found %s conflicts while checking merge of %s:%s to %s for %s." % \
-                (len(conflicts), startrev, endrev, version, requestor),
-            "Files in conflict:",
-            "{{{",
-            "\n".join(conflicts),
-            "}}}",
-            "A rebranch will be needed before this can be merged.",
-        ])
-        endstatus = "conflicts"
-    else:
-        message = \
-            "Found no conflicts while checking merge of %s:%s to %s for %s." \
-            % (startrev, endrev, version, requestor)
-        endstatus = "branched"
+        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
 
-    # Clean up the work area
-    os.system("rm -rf \"%s\"" % (workdir, ))
+        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
 
-    task_obj.AddComment(message)
-    return endstatus, task_obj
+        # Clean up the work area
+        if os.path.exists(workdir):
+            shutil.rmtree(workdir)
 
-class CheckMergeActor(MergeBotActor):
-    "Actor wrapper for the check_merge_action"
-    def __init__(self, trac_env):
-        MergeBotActor.__init__(self, trac_env, "checkmerge", check_merge_action)
-
-def main():
-    tracdir = sys.argv[1]
-    trac_env = trac.env.open_environment(tracdir)
-    mergingActor = CheckMergeActor(trac_env)
-    mergingActor.AddTask(sys.argv[1:])
-    mergingActor.Run()
-
-if __name__ == "__main__":
-    main()
+        return results, message, success
 
 # vim:foldcolumn=4 foldmethod=indent
Index: mergebot/trunk/mergebot/MergeActor.py
===================================================================
--- mergebot/trunk/mergebot/MergeActor.py	(revision 16)
+++ mergebot/trunk/mergebot/MergeActor.py	(revision 17)
@@ -7,97 +7,99 @@
 
 import os
-import sys
-import trac.env
+import shutil
 
-import SvnOps
-from WorkQueue import MergeBotActor, VersionToDir
-from TrackerTools import GetWorkDir, GetRepositoryLocalUrl, Task, GetLogFile
+from mergebot import SvnOps
+from mergebot.Actor import Actor
 
-def merge_action(trac_env, ticketnum, component, version, requestor):
-    "Merge a branch to its trunk"
-    task_obj = Task(trac_env, ticketnum)
-    logfile = GetLogFile(trac_env, ticketnum)
+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)
 
-    checkoutdir = GetWorkDir(trac_env, ticketnum, __name__)
-    # FIXME: Should we just bail out instead?
-    if os.path.exists(checkoutdir):
-        os.system("rm -rf \"%s\" >>%s 2>&1" % (checkoutdir, logfile))
+        # 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
 
-    sourceurl = os.path.join(GetRepositoryLocalUrl(trac_env), component,
-        VersionToDir(version))
-    ticketurl = os.path.join(GetRepositoryLocalUrl(trac_env), component, "branches",
-        "ticket-%s" % ticketnum)
+        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
 
-    # FIXME: needs error checking
-    startrev, endrev = SvnOps.get_branch_info(ticketurl, logfile)
+        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
 
-    SvnOps.checkout(sourceurl, checkoutdir, logfile)
-    # FIXME: check return code
-    results = SvnOps.merge(ticketurl, checkoutdir, (startrev, endrev), logfile)
-    conflicts = SvnOps.conflicts_from_merge_results(results)
-    if conflicts:
-        message = "\n".join([
-            "Found %s conflicts in attempt to merge %s:%s to %s for %s." % \
-                (len(conflicts), startrev, endrev, version, requestor),
-            "Files in conflict:",
-            "{{{",
-            "\n".join(conflicts),
-            "}}}",
-            "A rebranch will be needed before this can be merged.",
-        ])
-        endstatus = "conflicts"
-    else:
-        # The merge worked, so commit the changes.
-        summary = task_obj.GetSummary()
-        commitmessage = "\n".join([
-            "Ticket #%s: %s" % (ticketnum, summary),
-            "    Merge of %s:%s to %s for %s." % (startrev, endrev, version,
-                requestor),
-        ])
-        committedrev = SvnOps.commit(checkoutdir, commitmessage, logfile)
-        # Sed message and endstatus
-        if committedrev == None:
-            # Apparently nothing to commit.
-            message = "\n".join([
-                "Merged %s:%s to %s for %s." % (startrev, endrev, version,
-                    requestor),
-                "No changes to commit.",
-            ])
-            endstatus = "merged"
-        elif committedrev >= 0:
-            # The commit succeeded.
-            message = "\n".join([
-                "Merged %s:%s to %s for %s." % (startrev, endrev, version,
-                    requestor),
-                "Changeset [%s]. [source:%s/%s@%s]" % (committedrev,
-                    component, VersionToDir(version), committedrev),
-            ])
-            endstatus = "merged"
-        else:
-            # The commit for the merge failed.
-            message = "Commit failed in attempt to merge %s:%s to %s for %s." \
-                % (startrev, endrev, version, requestor)
-            endstatus = "mergefailed"
+        # Clean up the work area
+        if os.path.exists(checkoutdir):
+            shutil.rmtree(checkoutdir)
 
-    # Clean up the work area
-    os.system("rm -rf \"%s\" >>%s 2>&1" % (checkoutdir, logfile))
-
-    task_obj.AddComment(message)
-    return endstatus, task_obj
-
-class MergeActor(MergeBotActor):
-    "Actor wrapper for merge_action"
-    def __init__(self, trac_env):
-        MergeBotActor.__init__(self, trac_env, "merge", merge_action)
-
-def main():
-    tracdir = sys.argv[1]
-    trac_env = trac.env.open_environment(tracdir)
-    mergingActor = MergeActor(trac_env)
-    mergingActor.AddTask(sys.argv[2:])
-    mergingActor.Run()
-
-if __name__ == "__main__":
-    main()
+        return results, comment, success
 
 # vim:foldcolumn=4 foldmethod=indent
Index: mergebot/trunk/mergebot/RebranchActor.py
===================================================================
--- mergebot/trunk/mergebot/RebranchActor.py	(revision 16)
+++ mergebot/trunk/mergebot/RebranchActor.py	(revision 17)
@@ -1,177 +1,176 @@
 #!/usr/bin/env python
 """
-Syntax: RebranchActor.py ticketnum component version requestor
-
 Rebranch a branch from its trunk, pulling in the changes made on the branch if
 possible.
 """
 
+import shutil
+import time
 import os
-import sys
-import time
-import trac.env
 
-import SvnOps
-from WorkQueue import MergeBotActor, VersionToDir
-from TrackerTools import GetRepositoryPublicUrl, GetRepositoryLocalUrl, Task, \
-    GetWorkDir, GetLogFile
+from mergebot import SvnOps
+from mergebot.Actor import Actor
 
-def rebranch_action(trac_env, ticketnum, component, version, requestor):
+class RebranchActor(Actor):
+    """Rebranches a ticket from its baseline.
     """
-    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.
-    task_obj = Task(trac_env, ticketnum)
-    summary = task_obj.GetSummary()
+    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.
 
-    # We need to improve the logging of the rebranch stuff.
-    logfile = GetLogFile(trac_env, ticketnum)
-    open(logfile, "a").write("%s rebranching ticket %s\n" % (time.asctime(),
-        ticketnum))
+        results = {}
 
-    baserepositoryurl = str(GetRepositoryLocalUrl(trac_env))
-    branchurl = os.path.join(baserepositoryurl, component, "branches",
-        "ticket-%s" % ticketnum)
-    baselineurl = os.path.join(baserepositoryurl, component,
-        VersionToDir(version))
-    publicbranchurl = os.path.join(GetRepositoryPublicUrl(trac_env), component,
-        "branches", "ticket-%s" % ticketnum)
+        # 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))
 
-    # Determine the revision range for the branch: startrev:endrev
-    branchinfo = SvnOps.get_branch_info(branchurl, logfile)
-    if not branchinfo:
-        open(logfile, "a").write(
-            "Unable to get %s branch revision information.\n" % (branchurl))
-        return "rebranchfailed", task_obj
-    startrev, endrev = branchinfo
+        # 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
 
-    workingcopy = GetWorkDir(trac_env, ticketnum, __name__)
+        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
 
-    svnmergecommand = "svn merge -r %s:%s %s@%s" % (startrev, endrev,
-        branchurl, startrev)
-    publicsvnmergecommand = "svn merge -r %s:%s %s@%s" % (startrev, endrev,
-        publicbranchurl, startrev)
-    # This is used in the ticket comments.
-    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" % (ticketnum, summary),
-        "    Remove the branch to rebranch from %s for %s." % \
-            (version, requestor),
-    ])
-    copymessage = "\n".join([
-        "Ticket #%s: %s" % (ticketnum, summary),
-        "    Recreate branch from %s for %s." % (version, requestor),
-        "[log:%s/branches/ticket-%s@%s Previous log]." % \
-            (component, ticketnum, 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.logcmd("rm -rf \"%s\"" % (workingcopy), x),
-            "Rebranch internal error: Unable to cleanup work area."),
-        (lambda x: SvnOps.delete_branch(branchurl, rmmessage, x) == -1,
-            "Rebranch internal error: Unable to delete the old branch."),
-        (lambda x: SvnOps.create_branch(baselineurl, branchurl, copymessage, x),
-            "Rebranch internal error: Unable to recreate the branch.  %s" % \
-                (instructioncomment, )),
-        (lambda x: SvnOps.checkout(branchurl, workingcopy, x),
-            "Rebranch internal error: Unable to get working copy.  %s" % \
-                (instructioncomment, )),
-    )
-    for cmd, errorcomment in commanderrors:
-        retval = cmd(logfile)
-        if retval:
-            task_obj.AddComment(errorcomment)
-            os.system("rm -rf \"%s\"" % (workingcopy, ))
-            return "rebranchfailed", task_obj
+        workingcopy = self.work_dir
+        if os.path.exists(workingcopy):
+            shutil.rmtree(workingcopy)
 
-    # On success, we're in the same state as if we had just branched.
-    status = "branched"
-    # 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.
-    results = SvnOps.merge("%s@%s" % (branchurl, startrev), workingcopy,
-        (startrev, endrev), logfile)
-    conflicts = SvnOps.conflicts_from_merge_results(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" % (ticketnum, summary),
-            "    Merge in changes from old branch for %s." % (requestor),
-            "    %s" % (svnmergecommand),
-        ])
-        newrev = SvnOps.commit(workingcopy, mergemessage, logfile)
-        if newrev == None:
-            ticketmessage = "\n".join([
-                "Rebranched from %s for %s." % (version, 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:",
+        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,
             ])
-            status = "rebranchfailed"
-        else:
-            ticketmessage = "\n".join([
-                "Rebranched from %s for %s." % (version, requestor),
-                "There were no conflicts, so the changes were automatically "
-                    "merged and committed to the branch.",
-                "You will need to update your working copy.",
-            ])
+            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.",
+                ])
 
-    task_obj.AddComment( ticketmessage )
-    os.system("rm -rf \"%s\"" % (workingcopy, ))
-    return status, task_obj
-
-class RebranchActor(MergeBotActor):
-    "Actor wrapper for rebranch_action"
-    def __init__(self, trac_env):
-        MergeBotActor.__init__(self, trac_env, "rebranch", rebranch_action)
-
-def main():
-    tracdir = sys.argv[1]
-    trac_env = trac.env.open_environment(tracdir)
-    rebranchingActor = RebranchActor(trac_env)
-    rebranchingActor.AddTask(sys.argv[2:])
-    rebranchingActor.Run()
-
-if __name__ == "__main__":
-    main()
+        if os.path.exists(workingcopy):
+            shutil.rmtree(workingcopy)
+        return results, ticketmessage, success
 
 # vim:foldcolumn=4 foldmethod=indent
Index: mergebot/trunk/mergebot/TrackerTools.py
===================================================================
--- mergebot/trunk/mergebot/TrackerTools.py	(revision 16)
+++ 	(revision )
@@ -1,72 +1,0 @@
-#!/usr/bin/env python
-"""
-The purpose of this file is to provide some high-functionality to the MergeBot
-while hiding the details of the task tracking software.
-
-This implementation uses Trac as the task tracker.
-"""
-
-import os
-import time
-
-import trac.env
-import trac.ticket
-
-class Task(object):
-    "A convenience wrapper for Trac's ticket objects."
-    def __init__(self, tracEnv, ticketnum):
-        self.tracEnv = tracEnv
-        self.tracTicket = trac.ticket.Ticket(self.tracEnv, ticketnum)
-        self.comments = []
-    def AddComment(self, comment):
-        "Add a comment to the list of comments to add to the ticket."
-        self.comments.append(comment)
-    def SetMergeBotStatus(self, state):
-        self.tracTicket['mergebotstate'] = state
-    def CloseAsFixed(self):
-        self.tracTicket['status'] = 'closed'
-        self.tracTicket['resolution'] = 'fixed'
-    def Save(self):
-        comment = "\n\n".join(self.comments)
-        try:
-            self.tracTicket.save_changes("mergebot", comment)
-        except: #IntegrityError:
-            # Two changes too close together?  Try again, but just once, so we
-            # don't get an infinite loop.
-            time.sleep(1)
-            self.tracTicket.save_changes("mergebot", comment)
-    def GetSummary(self):
-        return self.tracTicket['summary']
-
-def GetWorkDir(tracEnv, ticket=None, module=None):
-    """Generate a work directory name based on the ticket and/or module"""
-    workdir = tracEnv.config.get("mergebot", "work_dir")
-    if not os.path.isabs(workdir):
-        workdir = os.path.join(os.path.abspath(tracEnv.path), workdir)
-    nameparts = []
-    if module:
-        nameparts.append(module)
-    if ticket:
-        nameparts.append("ticket-%s" % (ticket, ))
-    if nameparts:
-        workdir = os.path.join(workdir, ".".join(nameparts))
-    return workdir
-
-def GetLogFile(tracEnv, ticket=None):
-    if ticket:
-        filename = "ticket-%s.log" % (ticket, )
-    else:
-        filename = "mergebot.log"
-    return os.path.join(GetWorkDir(tracEnv), filename)
-
-def GetRepositoryPublicUrl(tracEnv):
-    return tracEnv.config.get("mergebot", "repository_url")
-
-def GetRepositoryLocalUrl(tracEnv):
-    dirname = tracEnv.config.get("trac", "repository_dir")
-    if not os.path.isabs(dirname):
-        dirname = os.path.join(os.path.abspath(tracEnv.path), dirname)
-    return "file://%s" % (dirname, )
-    
-# vim:foldmethod=indent foldcolumn=8
-# vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab
Index: mergebot/trunk/mergebot/WorkQueue.py
===================================================================
--- mergebot/trunk/mergebot/WorkQueue.py	(revision 16)
+++ 	(revision )
@@ -1,190 +1,0 @@
-#!/usr/bin/env python
-import os
-import sys
-import time
-import traceback
-import trac.env
-
-from TrackerTools import Task, GetWorkDir
-from CreateDaemon import createDaemon
-
-class WorkQueue(object):
-    # TODO: add locking to file accesses.
-    delim = " "
-    def __init__(self, workdir, name):
-        self.name = name
-        if not os.path.exists(workdir):
-            os.makedirs(workdir)
-        self.queuefile = os.path.join(workdir, "queue.%s" % name)
-        self.inprocessfile = os.path.join(workdir, "queue.%s.current" % name)
-        self.pidfile = os.path.join(workdir, "queue.%s.pid" % name)
-    def drivequeue(self):
-        "return true if this actor is already running"
-        if os.path.exists(self.pidfile):
-            # Check that it's running...
-            pid = open(self.pidfile).read()
-            process = os.popen("ps h --pid %s" % pid).read()
-            if process:
-                return 1
-            # otherwise, just overwrite the existing one.
-        open(self.pidfile, "w").write("%s\n" % os.getpid())
-        return 0
-    def releasequeue(self):
-        os.remove(self.pidfile)
-    def readqueue(self):
-        if os.path.exists(self.queuefile):
-            lines = open( self.queuefile ).readlines()
-        else:
-            lines = []
-        # The -1 is to strip the trailing newline.
-        queued = map(lambda x: x[:-1].split(self.delim), lines)
-        return queued
-    def writequeue(self, tasks):
-        if tasks:
-            lines = map( lambda task:"%s\n" % (self.delim.join(task)), tasks )
-        else:
-            lines = []
-        open(self.queuefile, "w").writelines(lines)
-    def readcurrent(self):
-        if os.path.exists(self.inprocessfile):
-            current = open( self.inprocessfile ).readlines()
-        else:
-            current = []
-        if current:
-            task = current[0].split(self.delim)
-        else:
-            task = None
-        return task
-    def writecurrent(self, task):
-        if task:
-            line = "%s\n" % (self.delim.join(task))
-        else:
-            line = ""
-        open(self.inprocessfile, "w").writelines([line])
-    def enqueue(self, task):
-        queued = self.readqueue()
-        if task in queued:
-            return 1
-        queued.append(task)
-        self.writequeue(queued)
-        return 0
-    def dequeue(self):
-        # Check that we don't have something half-way dequeued.
-        # The problem is that if we have something half-way dequeued, there was
-        # a problem completing that task.
-        # TODO: So instead of returning it, we need to ring alarm bells, but
-        # dequeue the next task.
-        queued = self.readqueue()
-        if not queued:
-            return None
-        self.writequeue(queued[1:])
-        self.writecurrent(queued[0])
-        return queued[0]
-    def completetask(self):
-        self.writecurrent(None)
-
-class MergeBotActor(object):
-    def __init__(self, tracEnv, name, action):
-        self.tracEnv = tracEnv
-        self.name = name
-        self.action = action
-        workdir = GetWorkDir(tracEnv)
-        self.queue = WorkQueue(workdir, name)
-    def AddTask(self, task):
-        if self.queue.enqueue(task):
-            # must be a dup
-            return
-        task_obj = Task(self.tracEnv, task[0])
-        task_obj.SetMergeBotStatus("to%s"%self.name)
-        task_obj.Save()
-    def Run(self):
-        self.tracEnv.log.debug("Starting %s action" % (self.name))
-        # The child will need the absolute path to the environment.
-        trac_env_path = os.path.abspath(self.tracEnv.path)
-        pid = os.fork()
-        if pid:
-            # Parent
-            self.tracEnv.log.debug("Running %s action in child %s" % (self.name, pid))
-        else:
-            # Child.  We daemonize and do the work.
-            createDaemon()  # This closes all open files, and sets the working directory to /
-            # And because of that, we must open our own copy of the Trac environment.
-            tracEnv = trac.env.open_environment(trac_env_path)
-            if self.queue.drivequeue():
-                # something is already working
-                tracEnv.log.debug("The %s queue is already running." % (self.name, ))
-                sys.exit(0)
-            try:
-                tracEnv.log.debug("Child running %s action" % (self.name, ))
-                while 1:
-                    task = self.queue.dequeue()
-                    if not task:
-                        tracEnv.log.debug("Queue %s emptied." % (self.name, ))
-                        # we're done.
-                        break
-                    tracEnv.log.debug("%s working on %s..." % (self.name, task))
-                    # FIXME: Need to rethink the status update logic.
-                    args = [tracEnv] + task
-                    tracEnv.log.debug("Running %s action with arguments %s" % (self.name, repr(args)))
-                    try:
-                        result, task_obj = self.action(*args)
-                        if result:
-                            tracEnv.log.debug("Action %s completed with result %s" % (self.name, result))
-                            task_obj.SetMergeBotStatus(result)
-                        task_obj.Save()
-                    except Exception, e:
-                        tracEnv.log.error("BUG!!!  Task %r failed while running"
-                            " the %s queue.  Continuing." % (args, self.name))
-                        tracEnv.log.exception(e)
-                        
-                    self.queue.completetask()
-                self.queue.releasequeue()
-            except Exception, e:
-                tracEnv.log.error("BUG!!!  Failed while running the %s queue" % (self.name, ))
-                tracEnv.log.exception(e)
-                sys.exit(1)
-            
-            tracEnv.log.debug("Child completed action %s, exiting" % (self.name, ))
-            sys.exit(0)
-    def GetStatus(self):
-        """Returns a tuple (current, (queue)), where each element is a list of
-        arguments"""
-        return (self.queue.readcurrent(), self.queue.readqueue() )
-
-def VersionToDir(version):
-    """Given the version from the Trac version list, determine what the path
-    should be under that component.  trunk -> trunk, but the rest will be
-    branches/something."""
-    if version == "trunk":
-        versiondir = "trunk"
-    elif version.startswith("#"):
-        ticketnum = version[1:] # Strip the "#"
-        versiondir = "branches/ticket-%s" % (ticketnum)
-    else:
-        versiondir = "branches/release-%s" % (version)
-    return versiondir
-
-def testcase():
-    wc = WorkQueue(os.path.join(os.getcwd(), "test"), "noop")
-    print "Initial state."
-    print wc.readcurrent()
-    print wc.readqueue()
-
-    while wc.dequeue():
-        wc.completetask()
-
-    print wc.enqueue(["task1"])
-    print wc.enqueue(["task2"])
-
-    task = wc.dequeue()
-    wc.completetask()
-
-    print "Final state."
-    print wc.readcurrent()
-    print wc.readqueue()
-
-if __name__ == "__main__":
-    testcase()
-
-# vim:foldcolumn=4 foldmethod=indent
-# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: mergebot/trunk/mergebot/action_logic.py
===================================================================
--- mergebot/trunk/mergebot/action_logic.py	(revision 17)
+++ mergebot/trunk/mergebot/action_logic.py	(revision 17)
@@ -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/trunk/mergebot/daemonize.py
===================================================================
--- mergebot/trunk/mergebot/daemonize.py	(revision 17)
+++ mergebot/trunk/mergebot/daemonize.py	(revision 17)
@@ -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/trunk/mergebot/mergebotdaemon.py
===================================================================
--- mergebot/trunk/mergebot/mergebotdaemon.py	(revision 17)
+++ mergebot/trunk/mergebot/mergebotdaemon.py	(revision 17)
@@ -0,0 +1,381 @@
+#!/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
+                # 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/trunk/mergebot/templates/mergebot.cs
===================================================================
--- mergebot/trunk/mergebot/templates/mergebot.cs	(revision 16)
+++ 	(revision )
@@ -1,121 +1,0 @@
-<?cs include "header.cs"?>
-<?cs include "macros.cs"?>
-
-<div id="ctxtnav"></div>
-
-<div id="content" class="mergebot">
-
-<h1>MergeBot</h1>
-
-<!-- DEBUG SECTION -->
-<?cs if:mergebot.debug ?>
-<h2>Debug info:</h2>
-<?cs each:debug = mergebot.debug ?>
-	<li><?cs var:debug ?></li>
-<?cs /each ?>
-<hr>
-<?cs /if ?>
-<!-- END DEBUG SECTION -->
-
-<!-- Show all of our queues -->
-<?cs each:queue = mergebot.queue ?>
-	<div id="content" class="query">
-	<h1><?cs var:queue ?> Queue</h1>
-
-	<table class="listing tickets">
-	<!-- Hardcode the header for now: -->
-	<thead><tr>
-		<th>Ticket</th>
-		<th>Summary</th>
-		<th>Component</th>
-		<th>Version</th>
-		<th>Status</th>
-		<th>Requestor</th>
-		<th>Activity</th> <!-- for lack of a better name -->
-	</tr></thead>
-
-	<?cs if:queue.current ?>
-		<tr>
-		<th><a href=<?cs var:queue.current.href ?>>#<?cs var:queue.current ?></a></th> <!-- #ticket -->
-		<th><a href=<?cs var:queue.current.href ?>><?cs var:queue.current.summary ?></a></th>
-		<th><?cs var:queue.current.component ?></th>
-		<th><?cs var:queue.current.version ?></th>
-		<th><?cs var:queue.current.status ?></th>
-		<th><?cs var:queue.current.requestor ?></th>
-		<th>Doing <?cs name:queue ?></th> <!-- activity -->
-		</tr>
-	<?cs /if ?>
-	<?cs each:task = queue.queue ?> <!-- was queue.queue -->
-		<tr>
-		<th><a href=<?cs var:task.href ?>>#<?cs var:task ?></a></th> <!-- #ticket -->
-		<th><a href=<?cs var:task.href ?>><?cs var:task.summary ?></a></th>
-		<th><?cs var:task.component ?></th>
-		<th><?cs var:task.version ?></th>
-		<th><?cs var:task.status ?></th>
-		<th><?cs var:task.requestor ?></th>
-		<th><?cs var:task.mergebotstate ?></th> <!-- activity -->
-		</tr>
-	<?cs /each ?>
-
-	</table>
-	</div>
-<?cs /each ?>
-
-<!-- Tickets that are not in an activity queue: -->
-<div id="content" class="query">
-<h1>Unqueued</h1>
-
-<table class="listing tickets">
-<!-- Hardcode the header for now: -->
-<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>
-
-<?cs each:task = mergebot.notqueued ?>
-	<tr>
-	<th><a href=<?cs var:task.href ?>>#<?cs var:task ?></a></th> <!-- ticket -->
-	<th><a href=<?cs var:task.href ?>><?cs var:task.summary ?></a></th> <!-- summary -->
-	<th><?cs var:task.component ?></th>
-	<th><?cs var:task.version ?></th>
-	<th><?cs var:task.status ?></th>
-	<th><?cs var:task.mergebotstate ?></th>
-
-	<td>
-	<!-- For each of the buttons, MergeBot's web_ui tells us if it's a
-	valid operation for the ticket.  Use that to determine which buttons to
-	show.  -->
-	<form id="ops" method="post" name="ops-<?cs var:task ?>" action="">
-	<input type="hidden" name="ticket" value="<?cs var:task ?>" />
-	<input type="hidden" name="component" value="<?cs var:task.component ?>" />
-	<input type="hidden" name="version" value="<?cs var:task.version ?>" />
-	<?cs if:task.actions.branch ?>
-		<input type="submit" name="action" value="Branch" />
-	<?cs /if ?>
-	<?cs if:task.actions.rebranch ?>
-		<input type="submit" name="action" value="Rebranch" />
-	<?cs /if ?>
-	<?cs if:task.actions.merge ?>
-		<input type="submit" name="action" value="Merge" />
-	<?cs /if ?>
-	<?cs if:task.actions.checkmerge ?>
-		<input type="submit" name="action" value="CheckMerge" />
-	<?cs /if ?>
-	</form>
-	</td>
-
-	</tr>
-<?cs /each ?>
-
-</table>
-</div>
-
-
-</div>
-
-<?cs include:"footer.cs"?>
Index: mergebot/trunk/mergebot/templates/mergebot.html
===================================================================
--- mergebot/trunk/mergebot/templates/mergebot.html	(revision 17)
+++ mergebot/trunk/mergebot/templates/mergebot.html	(revision 17)
@@ -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/trunk/mergebot/ticket_actions.py
===================================================================
--- mergebot/trunk/mergebot/ticket_actions.py	(revision 17)
+++ mergebot/trunk/mergebot/ticket_actions.py	(revision 17)
@@ -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/trunk/mergebot/web_ui.py
===================================================================
--- mergebot/trunk/mergebot/web_ui.py	(revision 16)
+++ mergebot/trunk/mergebot/web_ui.py	(revision 17)
@@ -1,5 +1,9 @@
 #!/usr/bin/env python
 
-import random
+import socket
+import time
+import os
+import base64
+from subprocess import check_call
 
 from trac.core import *
@@ -7,16 +11,36 @@
 from trac.ticket.query import Query
 from trac.ticket.model import Ticket
-from trac.config import Option
+from trac.env import IEnvironmentSetupParticipant
 from trac.perm import IPermissionRequestor
 from trac.web.main import IRequestHandler
-from trac.web.chrome import INavigationContributor, ITemplateProvider
-
-from MergeActor import MergeActor
-from BranchActor import BranchActor
-from RebranchActor import RebranchActor
-from CheckMergeActor import CheckMergeActor
+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)
+    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
@@ -24,4 +48,5 @@
     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
@@ -57,10 +82,40 @@
         fields = ['summary', 'component', 'version', 'status']
         info = {}
-        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))
+        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):
@@ -68,50 +123,50 @@
         req.perm.assert_permission("MERGEBOT_VIEW")
 
-        # 2nd redirect back to the real mergebot page To address POST
+        # 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())
 
-        debugs = []
-        req.hdf["title"] = "MergeBot"
+        data = {}
 
         if req.method == "POST": # The user hit a button
-            #debugs += [
-            #    "POST",
-            #    "Branching ticket %s" % req.args,
-            #]
-
-            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.
-            actor = None
-            if req.args['action'] == "Branch":
-                req.perm.assert_permission("MERGEBOT_BRANCH")
-                if self._is_branchable(ticket):
-                    actor = BranchActor(self.env)
-            elif req.args['action'] == "Rebranch":
-                req.perm.assert_permission("MERGEBOT_BRANCH")
-                if self._is_rebranchable(ticket):
-                    actor = RebranchActor(self.env)
-            elif req.args['action'] == "CheckMerge":
-                req.perm.assert_permission("MERGEBOT_VIEW")
-                if self._is_checkmergeable(ticket):
-                    actor = CheckMergeActor(self.env)
-            elif req.args['action'] == "Merge":
-                if version.startswith("#"):
-                    req.perm.assert_permission("MERGEBOT_MERGE_TICKET")
-                else:
-                    req.perm.assert_permission("MERGEBOT_MERGE_RELEASE")
-                if self._is_mergeable(ticket):
-                    actor = MergeActor(self.env)
-            if actor:
-                actor.AddTask([ticketnum, component, version, requestor])
-                try:
-                    actor.Run() # Starts processing deamon.
-                except Exception, e:
-                    self.log.exception(e)
+            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.
@@ -120,135 +175,92 @@
         # 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=new|assigned|reopened&version!="
-        if not req.perm.has_permission("MERGEBOT_ADMIN"):
+        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 ("component", "version", "mergebotstate"):
+        for name in required_columns:
             if name not in columns:
                 columns.append(name)
-        #debugs.append("query.fields = %s" % str(query.fields))
         db = self.env.get_db_cnx()
         tickets = query.execute(req, db)
-        #debugs += map(str, tickets)
-        req.hdf['mergebot.ticketcount'] = str(len(tickets))
-
-        # Make the tickets indexable by ticket id number:
-        ticketinfo = {}
+        data['unqueued'] = []
         for ticket in tickets:
-            ticketinfo[ticket['id']] = ticket
-        #debugs.append(str(ticketinfo))
-
-        availableTickets = tickets[:]
-        queued_tickets = []
-        # We currently have 4 queues, "branch", "rebranch", "checkmerge", and
-        # "merge"
-        queues = [
-            ("branch", BranchActor),
-            ("rebranch", RebranchActor),
-            ("checkmerge", CheckMergeActor),
-            ("merge", MergeActor)
-        ]
-        for queuename, actor in queues:
-            status, queue = actor(self.env).GetStatus()
-            req.hdf["mergebot.queue.%s" % (queuename, )] = queuename
-            if status:
-                # status[0] is ticketnum
-                req.hdf["mergebot.queue.%s.current" % (queuename)] = status[0]
-                req.hdf["mergebot.queue.%s.current.requestor" % (queuename)] = status[3]
-                ticketnum = int(status[0])
-                queued_tickets.append(ticketnum)
-                ticket = self._get_ticket_info(ticketnum)
-                for field, value in ticket.items():
-                    req.hdf["mergebot.queue.%s.current.%s" % (queuename, field)] = value
-            else:
-                req.hdf["mergebot.queue.%s.current" % (queuename)] = ""
-                req.hdf["mergebot.queue.%s.current.requestor" % (queuename)] = ""
-
-            for i in range(len(queue)):
-                ticketnum = int(queue[i][0])
-                queued_tickets.append(ticketnum)
-                req.hdf["mergebot.queue.%s.queue.%d" % (queuename, i)] = str(ticketnum)
-                req.hdf["mergebot.queue.%s.queue.%d.requestor" % (queuename, i)] = queue[i][3]
-                ticket = self._get_ticket_info(ticketnum)
-                for field, value in ticket.items():
-                    req.hdf["mergebot.queue.%s.queue.%d.%s" % (queuename, i, field)] = value
-                    #debugs.append("%s queue %d, ticket #%d, %s = %s" % (queuename, i, ticketnum, field, value))
-
-        # Provide the list of tickets at the bottom of the page, along with
-        # flags for which buttons should be enabled for each ticket.
-        for ticket in availableTickets:
             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.
+                # to support a 'Cancel' button, though.  Or present actions
+                # based upon the expected state of the ticket
                 continue
-            req.hdf["mergebot.notqueued.%d" % (ticketnum)] = str(ticketnum)
-            for field, value in ticket.items():
-                req.hdf["mergebot.notqueued.%d.%s" % (ticketnum, field)] = value
+
+            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
-            req.hdf["mergebot.notqueued.%d.actions.merge" % (ticketnum)] = 0
-            if req.perm.has_permission("MERGEBOT_MERGE_RELEASE") and \
-                not ticket['version'].startswith("#"):
-                req.hdf["mergebot.notqueued.%d.actions.merge" % (ticketnum)] = self._is_mergeable(ticket)
-            if req.perm.has_permission("MERGEBOT_MERGE_TICKET") and \
-                ticket['version'].startswith("#"):
-                req.hdf["mergebot.notqueued.%d.actions.merge" % (ticketnum)] = self._is_mergeable(ticket)
+            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"):
-                req.hdf["mergebot.notqueued.%d.actions.checkmerge" % (ticketnum)] = self._is_checkmergeable(ticket)
+                ticket_info['checkmerge'] = is_checkmergeable(ticket)
             else:
-                req.hdf["mergebot.notqueued.%d.actions.checkmerge" % (ticketnum)] = 0
+                ticket_info['checkmerge'] = False
             # BRANCH, REBRANCH
             if req.perm.has_permission("MERGEBOT_BRANCH"):
-                req.hdf["mergebot.notqueued.%d.actions.branch" % (ticketnum)] = self._is_branchable(ticket)
-                req.hdf["mergebot.notqueued.%d.actions.rebranch" % (ticketnum)] = self._is_rebranchable(ticket)
+                ticket_info['branch'] = is_branchable(ticket)
+                ticket_info['rebranch'] = is_rebranchable(ticket)
             else:
-                req.hdf["mergebot.notqueued.%d.actions.branch" % (ticketnum)] = 0
-                req.hdf["mergebot.notqueued.%d.actions.rebranch" % (ticketnum)] = 0
-
-        # Add debugs:
-        req.hdf["mergebot.debug"] = len(debugs)
-        for i in range(len(debugs)):
-            req.hdf["mergebot.debug.%d" % (i)] = debugs[i]
-
-        return "mergebot.cs", None
-
-    def _is_branchable(self, ticket):
-        try:
-            state = ticket['mergebotstate']
-        except KeyError:
-            state = ""
-        return state == "" or state == "merged"
-    def _is_rebranchable(self, 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.
-        try:
-            state = ticket['mergebotstate']
-        except KeyError:
-            state = ""
-        return state in ["branched", "conflicts"]
-    def _is_mergeable(self, ticket):
-        try:
-            state = ticket['mergebotstate']
-        except KeyError:
-            state = ""
-        return state == "branched"
-    def _is_checkmergeable(self, ticket):
-        try:
-            state = ticket['mergebotstate']
-        except KeyError:
-            state = ""
-        return state == "branched" or state == "conflicts"
+                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
Index: mergebot/trunk/setup.py
===================================================================
--- mergebot/trunk/setup.py	(revision 16)
+++ mergebot/trunk/setup.py	(revision 17)
@@ -5,19 +5,23 @@
 setup(
     name = "TracMergeBot",
-    version = "0.9",
+    version = "0.11",
     author = "Eli Carter",
-    author_email = "eli.carter@commprove.com",
-    url = "http://www.commprove.com",
+    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/*.cs"]},
+    package_data={"mergebot": ["templates/*.html"]},
 
     install_requires = [],
 
     entry_points = {
+        "console_scripts": [
+            "mergebotdaemon = mergebot.mergebotdaemon:run",
+        ],
         "trac.plugins": [
-            "mergebot.web_ui = mergebot.web_ui"
+            "mergebot.web_ui = mergebot.web_ui",
+            "mergebot.ticket_actions = mergebot.ticket_actions"
         ]
     },
Index: mergebot/trunk/test.py
===================================================================
--- mergebot/trunk/test.py	(revision 16)
+++ 	(revision )
@@ -1,295 +1,0 @@
-#!/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, TwillTest, 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)
-
-        # 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(TwillTest):
-    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(TwillTest):
-    def runTest(self):
-        tc.follow('MergeBot')
-        for queue in 'branch', 'rebranch', 'checkmerge', 'merge':
-            tc.find('%s Queue' % queue)
-
-
-class MergeBotTestNoVersion(TwillTest):
-    """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(TwillTest):
-    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(TwillTest):
-    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(TwillTest):
-    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(TwillTest):
-    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(TwillTest):
-    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')
Index: mergebot/trunk/utils/bdist_rpm_installscript
===================================================================
--- mergebot/trunk/utils/bdist_rpm_installscript	(revision 17)
+++ mergebot/trunk/utils/bdist_rpm_installscript	(revision 17)
@@ -0,0 +1,1 @@
+python setup.py install --optimize=1 --single-version-externally-managed --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES
Index: mergebot/trunk/utils/makerpm
===================================================================
--- mergebot/trunk/utils/makerpm	(revision 17)
+++ mergebot/trunk/utils/makerpm	(revision 17)
@@ -0,0 +1,2 @@
+#!/bin/bash
+python setup.py bdist_rpm --install-script=utils/bdist_rpm_installscript
Index: mergebot/trunk/utils/run_tests
===================================================================
--- mergebot/trunk/utils/run_tests	(revision 17)
+++ mergebot/trunk/utils/run_tests	(revision 17)
@@ -0,0 +1,2 @@
+#!/bin/bash
+PYTHONPATH=../../twill-0.9:../../genshi-0.5.0:../../trac-0.11 utils/test.py
Index: mergebot/trunk/utils/test.py
===================================================================
--- mergebot/trunk/utils/test.py	(revision 17)
+++ mergebot/trunk/utils/test.py	(revision 17)
@@ -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')
