Below is a Mecurial changegroup hook to modify tickets on a remote Trac instance using XML-RPC. To use it, place the following in your hgrc (make necessary changes in [trac-hook] section).

  • File: repo/.hg/hgrc

    [extensions]
    trac_hg_hook = /path/to/trac_hg_hook.py
    
    [hooks]
    changegroup.sync = python:trac_hg_hook.hook
    
    [trac-hook]
    trac_root = /var/lib/trac
    api_url = https://trac_user:trac_pass@trac.example.com/xmlrpc
    repo_name = test
    python_eggs = '/var/www/.python-eggs/'

The hook script is below. Please note that this is for my specific workflow, which includes custom fields in Trac (vcs-repos, vcsrefs, last_author) and my close resolutions. The script should be easy enough to modify, but drop a line in the comments if you would like further clarification/assistance.

  • File: trac_hg_hook.py

    #!/usr/bin/env python
    ##
    #    Trac HG Hook
    #        Adapted from trac-post-commit-hook, (c) 2004 Stephen Hansen, Mads Sulau Joergensen
    #         
    #    Perform ticket actions in Trac based on commit messages. Usage (in commit messages):
    #       [notify] Will send a Trac notification
    #       [me] Will own referenced tickets to yourself
    #       [assign->username] Will assign referenced tickets to username
    #       Look at `SUPPORTED_CMDS` variable for other commands. ex: `close #123, #124`
    #    
    #    @author     David Lasley <dave@dlasley.net>
    #    @package    utils
    #    @version    $Id: trac_hg_hook.py,v 25c192f523e6 2013/10/01 23:04:14 dlasley $
    __version__ = "$Revision: 25c192f523e6 $"
    
    import os
    import re
    import sys
    import time
    import xmlrpclib
    
    from mercurial import commands, demandimport
    demandimport.disable()
    from mercurial.node import short
    
    NOTIFY_TAG = '[notify]'
    OWN_TICKET_TAG = '[me]'
    ASSIGN_TAG = r'\[assign->(\w+)\]'
    
    SUPPORTED_CMDS = {'close':'_cmdTest',
                        'closed':'_cmdTest',
                        'closes':'_cmdTest',
                        'closing':'_cmdTest',
                        
                        'branch':'_cmdInDev',
                        'branching':'_cmdInDev',
                        'beginning':'_cmdInDev',
                        'begin':'_cmdInDev',
                        'start':'_cmdInDev',
                        'starting':'_cmdInDev',
                        'in_dev':'_cmdInDev',
                        
                        'close_complete':'_cmdComplete',
                        'close_pushed':'_cmdPushed',
                        'close_unscheduled':'_cmdUnscheduled',
                        'close_dup':'_cmdDup',
                        'close_invalid':'_cmdInvalid',
                        
                        'fix':'_cmdTest',
                        'fixed':'_cmdTest',
                        'fixes':'_cmdTest',
                        'test':'_cmdTest',
                        'testing':'_cmdTest',
                        
                        'reopen':'_cmdReopen',
                        'reopens':'_cmdReopen',
                        'reopened':'_cmdReopen',
                        
                        'addresses':'_cmdRefs',
                        're':'_cmdRefs',
                        'references':'_cmdRefs',
                        'refs':'_cmdRefs',
                        'see':'_cmdRefs',
                        
                        'pending_push':'_cmdPush',
                        'push':'_cmdPush',
                        'pushing':'_cmdPush',
                        'pushes':'_cmdPush',
                        
                        'pending_others':'_cmdOthers',
                        'pending_info':'_cmdInfo',
                    }
    
    CMD_PATTERN = r'(?P<action>[\w]*).?(?P<ticket>#[0-9]+(?:(?:[, &]*|[ ]?and[ ]?)#[0-9]+)*)'
    TICKET_PATTERN = r'#(\d+)'
    
    CMD_RE = re.compile(CMD_PATTERN)
    TICKET_RE = re.compile(TICKET_PATTERN)
    
    class CommitHook(object):
        def __init__(self, repo, repo_name, url):
            '''
                Init the globals & Trac instance
                @param  localrepository repo        localrepository object
                @param  str             repo_name   Repo name
                @param  str             url         Repo URL
            '''
            self.repo_name = repo_name
            self.repo = repo
            self.ui = repo.ui
            self.trac = xmlrpclib.ServerProxy(url, allow_none=True)
            
        def update(self, ctx, rev):
            '''
                Update a ticket using changset info
                @param  hg.changeContext    ctx HG changeContext (changectx())
                @param  str                 revision ID (short())
            '''
            #   Pull the ui buffer and parse file names
            self.ui.pushbuffer()
            commands.status(self.ui, self.repo, change=rev)
            filenames = self.ui.popbuffer().splitlines()
            chgset_files = ['* %s' % filename for filename in filenames]
            
            tickets = {}
            self.author = ctx.user()
            
            #   Generate the Trac comment
            msg = "[%s/%s] - %s\r\n\r\n'''Files Changed:'''\r\n%s\r\n!" % (
                rev, self.repo_name, ctx.description(), '\r\n'.join(chgset_files))
            
            #   Get the command groups from commit msg
            cmd_groups = CMD_RE.findall(msg.lower())
            self.ui.debug('cmd groups: %s\r\n' % cmd_groups)
            
            #   Loop and assign actions to tickets
            for cmd, tkts in cmd_groups:
                funcname = SUPPORTED_CMDS.get(cmd.lower(), '')
                if funcname:
                    for tkt_id in TICKET_RE.findall(tkts):
                        func = getattr(self, funcname)
                        tickets.setdefault(tkt_id, []).append(func) 
            
            self.ui.debug("Found tkts: %s\r\n" % tickets)
            
            #   Loop tickets, generate fields and run ticket commands
            for tkt_id, vals in tickets.iteritems():
                tkt_id = int(tkt_id)
                try:
                    ticket_attrs = self.trac.ticket.get(tkt_id)[3] #< Current ticket attrs
                    ticket = {'last_author': self.author} #< New ticket attrs
                    
                    #   Tag VCS revs & repos for easy reference, add to ticket attrs and de-dup
                    vcs_ref = '[%s/%s]' % (rev, self.repo_name)
                    try:
                        ticket['vcsref'] = ','.join(set(
                            ticket_attrs['vcsref'].split(',') + [vcs_ref]
                        ))
                    except KeyError:
                        ticket['vcsref'] = vcs_ref
                        
                    try:
                        ticket['vcs_repos'] = ','.join(set(
                            ticket_attrs['vcs_repos'].split(',') + [self.repo_name]
                        ))
                    except KeyError:
                        ticket['vcs_repos'] = self.repo_name
                    
                    #   Run the ticket commands
                    for cmd in vals:
                        cmd(ticket_attrs, ticket)
    
                    #   Assign Tags
                    for assign_to in re.findall(ASSIGN_TAG, msg):
                        ticket['owner'] = assign_to
                    if OWN_TICKET_TAG in msg:
                        ticket['owner'] = self.author
                    
                    self.trac.ticket.update(tkt_id, msg, ticket, NOTIFY_TAG in msg, self.author)
                    
                except BaseException as e:
                    RuntimeError('Unexpected error while processing ticket ID %s: %s' % (tkt_id, e))
            
        def _cmdTest(self, old_ticket, new_ticket):
            '''
                Move ticket to testing status, assign owner to reporter
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            new_ticket['status'] = 'testing'
            new_ticket['owner'] = old_ticket['reporter']
            
        def _cmdOthers(self, old_ticket, new_ticket):
            '''
                Move ticket to pending others
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            new_ticket['status'] = 'pending_others'
    
        def _cmdReopen(self, old_ticket, new_ticket):
            '''
                Reopen ticket, assign to the last author
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            new_ticket['status'] = 'reopened'
            new_ticket['owner'] = old_ticket['last_author']
            #ticket['resol'] = 'fixed'
    
        def _cmdPush(self, old_ticket, new_ticket):
            '''
                Move ticket to pending_push, remove owner
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            new_ticket['status'] = 'pending_push'
            new_ticket['owner'] = ''
    
        def _cmdRefs(self, old_ticket, new_ticket):
            '''
                Reference a ticket
                    If new or reviewing, set owner to author and in_dev status
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            if old_ticket['status'] in ['new', 'reviewing']: 
                new_ticket['status'] = 'in_dev'
                new_ticket['owner'] = self.author
                
        def _cmdInDev(self, old_ticket, new_ticket):
            '''
                Set ticket to in_dev and owner=author
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            new_ticket['status'] = 'in_dev'
            new_ticket['owner'] = self.author
        
        def _cmdFullClose(self, old_ticket, new_ticket, resolution='complete'):
            '''
                Close a ticket w/ resolution
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
                @param  str     resolution  Ticket resolution value (must match Trac)
            '''
            new_ticket['status'] = 'closed'
            new_ticket['owner'] = self.author
            new_ticket['resolution'] = resolution
            
        def _cmdComplete(self, old_ticket, new_ticket):
            '''
                Close - Complete
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            self._cmdFullClose(old_ticket, new_ticket, 'complete')
        def _cmdPushed(self, old_ticket, new_ticket):
            '''
                Close - Pushed to Prod
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            self._cmdFullClose(old_ticket, new_ticket, 'pushed_to_prod')
        def _cmdUnscheduled(self, old_ticket, new_ticket):
            '''
                Close - Unscheduled Production Push/Emergency
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            self._cmdFullClose(old_ticket, new_ticket, 'unscheduled_push')
        def _cmdDup(self, old_ticket, new_ticket):
            '''
                Close ticket as dup
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            self._cmdFullClose(old_ticket, new_ticket, 'duplicate')
        def _cmdInvalid(self, old_ticket, new_ticket):
            '''
                Close ticket as invalid
                @param  dict    old_ticket  Old ticket attrs
                @param  dict    new_ticket  New ticket attrs
            '''
            self._cmdFullClose(old_ticket, new_ticket, 'invalid')
        
    
    def hook(ui, repo, hook_type=None, node=None, **kwargs):
        '''
            Mercurial Commit Hook - http://hgbook.red-bean.com/read/handling-repository-events-with-hooks.html
            @param  ui  ui  UI object
            @param  localrepository repo    localrepository object
            @param  str hook_type Hook type
            @param  str node    Hexadecimal change ID
            @param  **kwargs
        '''
    
        if node is None:
            raise RuntimeError('Hook type %s didn\'t pass a changeset id' % hooktype)
        
        #   Grab vars from hgrc, verify
        url = repo.ui.config('trac-hook', 'api_url', None)
        project = repo.ui.config('trac-hook', 'trac_root', None)
        repo_name = repo.ui.config('trac-hook', 'repo_name', None)
        python_eggs = repo.ui.config('trac-hook', 'python_eggs', None)
        if None in (url, project, repo_name, python_eggs):
            raise RuntimeError('`root` missing from trac-hook in hgrc')
        
        os.environ['PYTHON_EGG_CACHE'] = python_eggs
        
        ctx = repo.changectx(node)
        hook_obj = CommitHook(repo, repo_name, url_)
        
        update_status = []
        for rev in xrange(ctx.rev(), len(repo)):
            rev = short(repo.lookup(rev))
            c = repo.changectx(rev)
            ui.debug('Running update() for changeset %s\r\n' % (rev))
            update_status.append(hook_obj.update(c, rev))
    
        return not False in update_status

0