Mercurial/Trac Changeset Hook
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