Since the Plone project switched from CMFCollector to trac I’ve been
jealous of their capability of closing issues by way of commit
messages. Well, not that much people use it. I believe it’s highly
underused these days except by a few brave folks.
Nonetheless, I had this in the back of my mind, and thought that
perhaps abusing our buildbot to perform similar stuff would be an
interesting task for one of those friday evenings.
And indeed, in a friday evening it happened. After reading the
buildbot interface definitions for a little while I realized that
writing a Scheduler that didn’t schedule anything but instead just
made a XML-RPC call to Zope performing the desired action would be
the easiest win.
For those interested in the code, I’m reproducing part of it
below. It’s really simple stuff, and I was surprised by how easy and
how little I had to learn about Twisted to get it to do what I wanted.
Here’s the Scheduler bit, with the nasty regex that parses the
commit message:
import re from buildbot.scheduler import BaseScheduler from buildbot.changes.changes import Change from twisted.web.xmlrpc import Proxy COMMAND = re.compile( r'(?P<action>[A-Za-z]*)(?:[ ]?(?:issue|issues)[ ]?)*(?:[: ]*).?' r'(?P<ticket>#[0-9]+(?:(?:[, &]*|[ ]?and[ ]?)#[0-9]+)*)' r'(?:[ ]?to[ ]?(?P<who>[A-Za-z0-9]*(?:(?:[, &]*|[ ]?and[ ]?)[A-Za-z0-9]+)*))*' r'(?:[ ]?(?:in|on|of|at)+[ ]?(?P<where>internal|public|test)(?:[ ]?(?P<what>desktop|server|proxy)))+') TICKET = re.compile(r'#([0-9]*)') WHO = re.compile(r'([A-Za-z0-9]+)') KNOWN_COMMANDS = {'close' : 'resolve', 'closes' : 'resolve', 'closed' : 'resolve', 'fix' : 'resolve', 'fixed' : 'resolve', 'fixes' : 'resolve', 'resolve' : 'resolve', 'assign' : 'assign', 'assigned': 'assign', 'review' : 'assign', 're' : 'comment', } def parse_action_bugs(msg): ''' >>> from pprint import pprint >>> pprint(parse_action_bugs(""" ... - Fixes #330 and #339 in public server ... - Fixed #331 at internal proxy ... - Fixed issue #339 on internal desktop ... - Fixes issues #330 and #339 of internal server ... - Closes #320, #321 on public proxy ... - Re: #334, #321 of internal proxy ... - Assign #312 to cjohnson on internal desktop ... - Assign #322 to cjohnson, sidnei and markh on public server ... """)) [('resolve', ('330', '339'), (), 'public', 'server'), ('resolve', ('331',), (), 'internal', 'proxy'), ('resolve', ('339',), (), 'internal', 'desktop'), ('resolve', ('330', '339'), (), 'internal', 'server'), ('resolve', ('320', '321'), (), 'public', 'proxy'), ('comment', ('334', '321'), (), 'internal', 'proxy'), ('assign', ('312',), ('cjohnson',), 'internal', 'desktop'), ('assign', ('322',), ('cjohnson', 'sidnei', 'markh'), 'public', 'server')] ''' actions = [] for action, ticket, who, where, what in COMMAND.findall(msg): bugs = tuple(TICKET.findall(ticket)) to = tuple([name for name in WHO.findall(who) if not name == 'and']) cmd = KNOWN_COMMANDS.get(action.lower()) if cmd is None: continue actions.append((cmd, bugs, to, where, what)) return actions class CollectorNotifyScheduler(BaseScheduler): builderNames = () def __init__(self, name, collector_mapping): self.collector_mapping = collector_mapping BaseScheduler.__init__(self, name) def listBuilderNames(self): return self.builderNames def addChange(self, change): print 'Adding change %r' % change actions = parse_action_bugs(change.comments) print actions for action in actions: self.processCommand(change, action) def processCommand(self, change, action): cmd, bugs, to, where, what = action loc = self.collector_mapping.get(where) if loc is None: # Doesn't map to a known collector location return collector_url = loc.get(what) if collector_url is None: # Doesn't map to a known product collector return changed_files = '\n'.join([' - %s' % fname for fname in change.files]) msg = '%s\n\nIn revision: %s.\n\nChanged files:\n\n%s' % ( change.comments.strip('\n'), change.revision, changed_files) print msg if '@' in collector_url: proxy = AuthProxy(collector_url) else: proxy = Proxy(collector_url) defer = proxy.callRemote('collector_issue_notify', change.who, cmd, bugs, to, msg) defer.addCallbacks(self._commandCallback, self._commandErrorCallback) def _commandErrorCallback(self, error): print error def _commandCallback(self, value): print value
I’ve used the AuthProxy and URI recipes from ASPN to allow usage of
authentication with XML-RPC.
Hooking this into the buildbot is very simple, here are the relevant bits:
from collector_notify import CollectorNotifyScheduler collector_mapping = {'internal': { 'server': 'http://intranet.enfoldsystems.local:8080/Plone/products/server/collector'}} c = BuildmasterConfig = {} c['schedulers'].append( CollectorNotifyScheduler('collector_notify', collector_mapping) )
And finally, on the Zope side, a not that simple yet not too terribly
complex method, that is attached to the Collector object itself
and called via XML-RPC. An interesting note about this method is
that it ‘impersonates’ the user that made the checkin by switching the
SecurityManager in Zope for a short period. Think of the sudo
command, if you like:
from StringIO import StringIO from AccessControl import Unauthorized from AccessControl.SecurityManagement import getSecurityManager from AccessControl.SecurityManagement import setSecurityManager from AccessControl.SecurityManagement import newSecurityManager class Auth: def __init__(self, user_id): self.user_id = user_id def acquire(self, context): self.manager = getSecurityManager() user = context.acl_users.getUserById(self.user_id) if user is None: return wrapped = user.__of__(context.acl_users) newSecurityManager(None, wrapped) return wrapped def validate(self, object, name): try: return getSecurityManager().validate( None, object, name, getattr(object, name)) except Unauthorized: return False def release(self): setSecurityManager(self.manager) def collector_issue_notify(self, who, cmd, bugs, to, msg): """Handle a command for a contained issue. """ out = StringIO() auth = Auth(who) user = auth.acquire(self) try: url = self.absolute_url() if user is None: print >> out, 'Could not find user %s at %s' % (who, url) else: for bug in bugs: try: issue = self[bug] except (KeyError, AttributeError): print >> out, 'Could not find bug %s at %s' % (bug, url) continue if not auth.validate(issue, 'do_action'): print >> out, ('User %s is not allowed to perform action ' 'on bug %s at %s' % (who, bug, url)) continue issue.do_action(cmd, msg, assignees=to) print >> out, ('Executed action %s on issue %s ' 'at %s' % (cmd, bug, url)) finally: auth.release() return out.getvalue()
I hope that is of use to anyone. I know there are several people out
there using the buildbot yet very few still using
CMFCollector. One way or another, it shows the ease of extending
the buildbot to perform other tasks than it was intended for, and also
the ease of extending Zope to be called externally via XML-RPC.
All in all, the hardest part of this was writing the regex to parse
the commit message. :)
If this example is of any use to you and if you use it and make any
improvement please post a comment here, thank you!