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
{{{ lang=ini
[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

{{{ lang=python
#!/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
# @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[\w]*).?(?P#[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 }}}


Posted

in

, ,

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *