Index: /mergebot/trunk/COPYING
===================================================================
--- /mergebot/trunk/COPYING	(revision 16)
+++ /mergebot/trunk/COPYING	(revision 16)
@@ -0,0 +1,19 @@
+The MIT License
+
+Copyright (c) 2007 CommProve, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
Index: /mergebot/trunk/README.txt
===================================================================
--- /mergebot/trunk/README.txt	(revision 16)
+++ /mergebot/trunk/README.txt	(revision 16)
@@ -0,0 +1,29 @@
+= 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/
+# 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
+
+[ticket-custom]
+mergebotstate = select
+mergebotstate.label = MergeBotState
+mergebotstate.options = | tomerge | merged | tobranch | branched | conflicts
+mergebotstate.value = 
+
+# 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
+
+# Restart your webserver
Index: /mergebot/trunk/bdist_rpm_installscript
===================================================================
--- /mergebot/trunk/bdist_rpm_installscript	(revision 16)
+++ /mergebot/trunk/bdist_rpm_installscript	(revision 16)
@@ -0,0 +1,1 @@
+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)
+++ /mergebot/trunk/makerpm	(revision 16)
@@ -0,0 +1,2 @@
+#!/bin/bash
+python setup.py bdist_rpm --install-script=bdist_rpm_installscript
Index: /mergebot/trunk/mergebot/BranchActor.py
===================================================================
--- /mergebot/trunk/mergebot/BranchActor.py	(revision 16)
+++ /mergebot/trunk/mergebot/BranchActor.py	(revision 16)
@@ -0,0 +1,87 @@
+#!/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
+
+def branch_action(trac_env, ticketnum, component, version, requestor):
+    "Create the branch"
+    task_obj = Task(trac_env, ticketnum)
+
+    # Setup logging
+    logfile = GetLogFile(trac_env, ticketnum)
+    open(logfile, "a").write("%s: branching ticket %s\n" % (time.asctime(),
+        ticketnum))
+
+    # 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" % (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(copyto, logfile):
+        # This branch already exists.
+        commit_message = "\n".join([commit_header,
+            "    Delete old branch",
+        ])
+        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),
+            "}}}",
+        ])
+        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()
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: /mergebot/trunk/mergebot/CheckMergeActor.py
===================================================================
--- /mergebot/trunk/mergebot/CheckMergeActor.py	(revision 16)
+++ /mergebot/trunk/mergebot/CheckMergeActor.py	(revision 16)
@@ -0,0 +1,82 @@
+#!/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.
+"""
+
+import os
+import sys
+import trac.env
+
+import SvnOps
+from WorkQueue import MergeBotActor, VersionToDir
+from TrackerTools import GetWorkDir, GetRepositoryLocalUrl, Task, GetLogFile
+
+def check_merge_action(trac_env, ticketnum, component, version, requestor):
+    """
+    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))
+
+    sourceurl = os.path.join(GetRepositoryLocalUrl(trac_env), component,
+        VersionToDir(version))
+    ticketurl = os.path.join(GetRepositoryLocalUrl(trac_env), component, "branches",
+        "ticket-%s" % ticketnum)
+
+    branch_info = SvnOps.get_branch_info(ticketurl, GetLogFile(trac_env, ticketnum))
+    # FIXME: if not branch_info: # Error case
+    startrev, endrev = branch_info
+
+    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"
+
+    # Clean up the work area
+    os.system("rm -rf \"%s\"" % (workdir, ))
+
+    task_obj.AddComment(message)
+    return endstatus, task_obj
+
+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()
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: /mergebot/trunk/mergebot/MergeActor.py
===================================================================
--- /mergebot/trunk/mergebot/MergeActor.py	(revision 16)
+++ /mergebot/trunk/mergebot/MergeActor.py	(revision 16)
@@ -0,0 +1,104 @@
+#!/usr/bin/env python
+"""
+Syntax: MergeActor.py ticketnum component version requestor
+
+Merge a branch to its trunk.
+"""
+
+import os
+import sys
+import trac.env
+
+import SvnOps
+from WorkQueue import MergeBotActor, VersionToDir
+from TrackerTools import GetWorkDir, GetRepositoryLocalUrl, Task, GetLogFile
+
+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)
+
+    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))
+
+    sourceurl = os.path.join(GetRepositoryLocalUrl(trac_env), component,
+        VersionToDir(version))
+    ticketurl = os.path.join(GetRepositoryLocalUrl(trac_env), component, "branches",
+        "ticket-%s" % ticketnum)
+
+    # FIXME: needs error checking
+    startrev, endrev = SvnOps.get_branch_info(ticketurl, logfile)
+
+    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
+    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()
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: /mergebot/trunk/mergebot/RebranchActor.py
===================================================================
--- /mergebot/trunk/mergebot/RebranchActor.py	(revision 16)
+++ /mergebot/trunk/mergebot/RebranchActor.py	(revision 16)
@@ -0,0 +1,178 @@
+#!/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 os
+import sys
+import time
+import trac.env
+
+import SvnOps
+from WorkQueue import MergeBotActor, VersionToDir
+from TrackerTools import GetRepositoryPublicUrl, GetRepositoryLocalUrl, Task, \
+    GetWorkDir, GetLogFile
+
+def rebranch_action(trac_env, ticketnum, component, version, requestor):
+    """
+    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()
+
+    # 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))
+
+    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)
+
+    # 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
+
+    workingcopy = GetWorkDir(trac_env, ticketnum, __name__)
+
+    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
+
+    # 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:",
+                "{{{",
+                publicsvnmergecommand,
+                "}}}",
+            ])
+            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.",
+            ])
+
+    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()
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: /mergebot/trunk/mergebot/SvnOps.py
===================================================================
--- /mergebot/trunk/mergebot/SvnOps.py	(revision 16)
+++ /mergebot/trunk/mergebot/SvnOps.py	(revision 16)
@@ -0,0 +1,121 @@
+#!/usr/bin/python
+"""
+Encapsulate logical Subversion operations so the various MergeBot actors can
+operate at a higher level of abstraction.
+"""
+
+import os
+import time
+import re
+
+def shell_quote(string):
+    """Given a string, escape the characters interpretted by the shell."""
+    for char in ["\\", "\"", "$"]:
+        string = string.replace(char, "\\%s" % (char, ))
+    return '"%s"' % (string, )
+
+def logcmd(cmd, logfile):
+    """Log the cmd string, then execute it, appending its stdout and stderr to
+    logfile."""
+    open(logfile, "a").write("%s: %s\n" % (time.asctime(), cmd))
+    return os.system("(%s) >>%s 2>&1" % (cmd, logfile))
+
+def get_rev_from_log(logentry):
+    """Given a log entry split out of svn log, return its revision number"""
+    return int(logentry.split()[0][1:])
+
+def get_branch_info(url, logfile):
+    """Given a subversion url and a logfile, return (start_revision,
+    end_revision) or None if it does not exist."""
+    svncmd = os.popen("svn log --stop-on-copy --non-interactive %s 2>>%s" % \
+        (url, logfile), "r")
+    branchlog = svncmd.read()
+    returnval = svncmd.close()
+    if returnval:
+        # This branch apparently doesn't exist
+        return None
+    logs = branchlog.split("-"*72 + "\n")
+    # If there have been no commits on the branch since it was created, there
+    # will only be one revision listed.... but the log will split into 3 parts.
+    endrev = get_rev_from_log(logs[1])
+    startrev = get_rev_from_log(logs[-2])
+    return (startrev, endrev)
+
+def create_branch(from_url, to_url, commit_message, logfile):
+    """Create a branch copying from_url to to_url.  Commit as mergebot, and use
+    the provided commit message."""
+    svncmd = \
+        "svn copy --username=mergebot --password=mergebot -m %s %s %s" \
+        % (shell_quote(commit_message), from_url, to_url)
+    return logcmd(svncmd, logfile)
+
+def delete_branch(url, commit_message, logfile):
+    """This will generate a new revision.  Return the revision number, or -1 on
+    failure.
+    Assumes that the url exists.  You should call get_branch_info() to
+    determine that first"""
+    svncmd = "svn rm --no-auth-cache --username=mergebot --password=mergebot " \
+        "-m %s %s 2>>%s" % (shell_quote(commit_message), url, logfile)
+    return _svn_new_rev_command(svncmd)
+
+def checkout(from_url, workingdir, logfile):
+    """Checkout from the given url into workingdir"""
+    return os.system("svn checkout %s %s >>%s 2>&1" % (from_url, workingdir,
+        logfile))
+
+def merge(from_url, workingdir, revision_range, logfile):
+    """Returns a list (status, filename) tuples"""
+    # There are a couple of different 'Skipped' messages.
+    skipped_regex = re.compile("Skipped.* '(.*)'", re.M)
+    start_rev, end_rev = revision_range
+    pipe = os.popen("cd %s && svn merge --revision %s:%s %s . 2>>%s" % \
+        (workingdir, start_rev, end_rev, from_url, logfile))
+    output = pipe.readlines()
+    # FIXME: check pipe.close for errors
+    results = []
+    for line in output:
+        if line.startswith("Skipped"):
+            # This kind of conflict requires special handling.
+            filename = skipped_regex.findall(line)[0]
+            status = "C"
+        else:
+            assert line[4] == ' ', "Unexpected output from svn merge " \
+                "operation; the 5th character should always be a space." \
+                "  Output was %r." % line
+            filename = line[5:-1] # (strip trailing newline)
+            status = line[:4].rstrip()
+        results.append((status, filename))
+    return results
+
+def conflicts_from_merge_results(results):
+    "Given the output from merge, return a list of files that had conflicts."
+    conflicts = [filename for status, filename in results if 'C' in status]
+    return conflicts
+
+def commit(workingdir, commit_message, logfile):
+    """Returns newly committed revision number, or None if there was nothing to
+    commit.  -1 on error."""
+    svncmd = "cd %s && svn commit --no-auth-cache --username=mergebot " \
+        "--password=mergebot -m %s 2>>%s" % (workingdir,
+            shell_quote(commit_message), logfile)
+    return _svn_new_rev_command(svncmd)
+
+def _svn_new_rev_command(svncmd):
+    """Given an svn command that results in a new revision, return the revision
+    number, or -1 on error."""
+    pipe = os.popen(svncmd)
+    output = pipe.read()
+    retval = pipe.close()
+    if retval:
+        new_revision = -1
+    else:
+        new_revisions = re.compile("Committed revision ([0-9]+)\\.",
+            re.M).findall(output)
+        if new_revisions:
+            new_revision = new_revisions[0]
+        else:
+            new_revision = None
+    return new_revision
+
+# vim:foldcolumn=4 foldmethod=indent
+# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Index: /mergebot/trunk/mergebot/TrackerTools.py
===================================================================
--- /mergebot/trunk/mergebot/TrackerTools.py	(revision 16)
+++ /mergebot/trunk/mergebot/TrackerTools.py	(revision 16)
@@ -0,0 +1,72 @@
+#!/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)
+++ /mergebot/trunk/mergebot/WorkQueue.py	(revision 16)
@@ -0,0 +1,190 @@
+#!/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/__init__.py
===================================================================
--- /mergebot/trunk/mergebot/__init__.py	(revision 16)
+++ /mergebot/trunk/mergebot/__init__.py	(revision 16)
@@ -0,0 +1,1 @@
+from mergebot.web_ui import *
Index: /mergebot/trunk/mergebot/templates/mergebot.cs
===================================================================
--- /mergebot/trunk/mergebot/templates/mergebot.cs	(revision 16)
+++ /mergebot/trunk/mergebot/templates/mergebot.cs	(revision 16)
@@ -0,0 +1,121 @@
+<?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/web_ui.py
===================================================================
--- /mergebot/trunk/mergebot/web_ui.py	(revision 16)
+++ /mergebot/trunk/mergebot/web_ui.py	(revision 16)
@@ -0,0 +1,260 @@
+#!/usr/bin/env python
+
+import random
+
+from trac.core import *
+from trac import util
+from trac.ticket.query import Query
+from trac.ticket.model import Ticket
+from trac.config import Option
+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
+
+class MergeBotModule(Component):
+    implements(INavigationContributor, IPermissionRequestor, IRequestHandler, ITemplateProvider)
+
+    # INavigationContributor
+    # so it shows up in the main nav bar
+    def get_active_navigation_item(self, req):
+        return 'mergebot'
+    def get_navigation_items(self, req):
+        """Generator that yields the MergeBot tab, but only if the user has
+        MERGEBOT_VIEW privs."""
+        if req.perm.has_permission("MERGEBOT_VIEW"):
+            label = util.Markup('<a href="%s">MergeBot</a>' % \
+                req.href.mergebot())
+            yield ('mainnav', 'mergebot', label)
+
+    # IPermissionRequestor methods
+    # So we can control access to this functionality
+    def get_permission_actions(self):
+        """Returns a permission structure."""
+        actions = ["MERGEBOT_VIEW", "MERGEBOT_BRANCH", "MERGEBOT_MERGE_TICKET",
+            "MERGEBOT_MERGE_RELEASE"]
+        # MERGEBOT_ADMIN implies all of the above permissions
+        allactions = actions + [
+            ("MERGEBOT_ADMIN", actions),
+            ("MERGEBOT_BRANCH", ["MERGEBOT_VIEW"]),
+            ("MERGEBOT_MERGE_TICKET", ["MERGEBOT_VIEW"]),
+            ("MERGEBOT_MERGE_RELEASE", ["MERGEBOT_VIEW"])
+            ]
+        return allactions
+
+    # IRequestHandler
+    def match_request(self, req):
+        """Returns true, if the given request path is handled by this module"""
+        # For now, we don't recognize any arguments...
+        return req.path_info == "/mergebot" or req.path_info.startswith("/mergebot/")
+
+    def _get_ticket_info(self, ticketid):
+        # grab the ticket info we care about
+        fields = ['summary', 'component', 'version', 'status']
+        info = {}
+        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 process_request(self, req):
+        """This is the function called when a user requests a mergebot page."""
+        req.perm.assert_permission("MERGEBOT_VIEW")
+
+        # 2nd redirect back to the real mergebot page To address POST
+        if req.path_info == "/mergebot/redir":
+            req.redirect(req.href.mergebot())
+
+        debugs = []
+        req.hdf["title"] = "MergeBot"
+
+        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)
+            # First half of a double-redirect to make a refresh not re-send the
+            # POST data.
+            req.redirect(req.href.mergebot("redir"))
+
+        # We want to fill out the information for the page unconditionally.
+
+        # 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 += "&owner=%s" % (req.authname,)
+        query = Query.from_string(self.env, querystring, order="id")
+        columns = query.get_columns()
+        for name in ("component", "version", "mergebotstate"):
+            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 = {}
+        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.
+                continue
+            req.hdf["mergebot.notqueued.%d" % (ticketnum)] = str(ticketnum)
+            for field, value in ticket.items():
+                req.hdf["mergebot.notqueued.%d.%s" % (ticketnum, field)] = value
+            # 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)
+            # CHECK-MERGE
+            if req.perm.has_permission("MERGEBOT_VIEW"):
+                req.hdf["mergebot.notqueued.%d.actions.checkmerge" % (ticketnum)] = self._is_checkmergeable(ticket)
+            else:
+                req.hdf["mergebot.notqueued.%d.actions.checkmerge" % (ticketnum)] = 0
+            # 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)
+            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"
+
+    # ITemplateProvider
+    def get_htdocs_dirs(self):
+        return []
+    def get_templates_dirs(self):
+        # It appears that everyone does this import here instead of at the top
+        # level... I'm not sure I understand why...
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
+# vim:foldmethod=indent foldcolumn=8
+# vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab
Index: /mergebot/trunk/setup.cfg
===================================================================
--- /mergebot/trunk/setup.cfg	(revision 16)
+++ /mergebot/trunk/setup.cfg	(revision 16)
@@ -0,0 +1,4 @@
+[egg_info]
+;tag_build = dev
+;tag_svn_revision = true
+
Index: /mergebot/trunk/setup.py
===================================================================
--- /mergebot/trunk/setup.py	(revision 16)
+++ /mergebot/trunk/setup.py	(revision 16)
@@ -0,0 +1,26 @@
+#!/usr/bin/env python
+
+from setuptools import setup
+
+setup(
+    name = "TracMergeBot",
+    version = "0.9",
+    author = "Eli Carter",
+    author_email = "eli.carter@commprove.com",
+    url = "http://www.commprove.com",
+    description = "Branch and merge management plugin",
+    license = """MIT""",
+    zip_safe=False,
+    packages=["mergebot"],
+    package_data={"mergebot": ["templates/*.cs"]},
+
+    install_requires = [],
+
+    entry_points = {
+        "trac.plugins": [
+            "mergebot.web_ui = mergebot.web_ui"
+        ]
+    },
+)
+# vim:foldmethod=indent foldcolumn=8
+# vim:softtabstop=4 shiftwidth=4 tabstop=4 expandtab
Index: /mergebot/trunk/test.py
===================================================================
--- /mergebot/trunk/test.py	(revision 16)
+++ /mergebot/trunk/test.py	(revision 16)
@@ -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, 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')
