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
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
}}}
Leave a Reply