diff --git a/hgext/bugzilla.py b/hgext/bugzilla.py new file mode 100644 --- /dev/null +++ b/hgext/bugzilla.py @@ -0,0 +1,293 @@ +# bugzilla.py - bugzilla integration for mercurial +# +# Copyright 2006 Vadim Gelfer +# +# This software may be used and distributed according to the terms +# of the GNU General Public License, incorporated herein by reference. +# +# hook extension to update comments of bugzilla bugs when changesets +# that refer to bugs by id are seen. this hook does not change bug +# status, only comments. +# +# to configure, add items to '[bugzilla]' section of hgrc. +# +# to use, configure bugzilla extension and enable like this: +# +# [extensions] +# hgext.bugzilla = +# +# [hooks] +# # run bugzilla hook on every change pulled or pushed in here +# incoming.bugzilla = python:hgext.bugzilla.hook +# +# config items: +# +# REQUIRED: +# host = bugzilla # mysql server where bugzilla database lives +# password = ** # user's password +# version = 2.16 # version of bugzilla installed +# +# OPTIONAL: +# bzuser = ... # bugzilla user id to record comments with +# db = bugs # database to connect to +# hgweb = http:// # root of hg web site for browsing commits +# notify = ... # command to run to get bugzilla to send mail +# regexp = ... # regexp to match bug ids (must contain one "()" group) +# strip = 0 # number of slashes to strip for url paths +# style = ... # style file to use when formatting comments +# template = ... # template to use when formatting comments +# timeout = 5 # database connection timeout (seconds) +# user = bugs # user to connect to database as + +from mercurial.demandload import * +from mercurial.i18n import gettext as _ +from mercurial.node import * +demandload(globals(), 'cStringIO mercurial:templater,util os re time') + +try: + import MySQLdb +except ImportError: + raise util.Abort(_('python mysql support not available')) + +def buglist(ids): + return '(' + ','.join(map(str, ids)) + ')' + +class bugzilla_2_16(object): + '''support for bugzilla version 2.16.''' + + def __init__(self, ui): + self.ui = ui + host = self.ui.config('bugzilla', 'host', 'localhost') + user = self.ui.config('bugzilla', 'user', 'bugs') + passwd = self.ui.config('bugzilla', 'password') + db = self.ui.config('bugzilla', 'db', 'bugs') + timeout = int(self.ui.config('bugzilla', 'timeout', 5)) + self.ui.note(_('connecting to %s:%s as %s, password %s\n') % + (host, db, user, '*' * len(passwd))) + self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd, + db=db, connect_timeout=timeout) + self.cursor = self.conn.cursor() + self.run('select fieldid from fielddefs where name = "longdesc"') + ids = self.cursor.fetchall() + if len(ids) != 1: + raise util.Abort(_('unknown database schema')) + self.longdesc_id = ids[0][0] + self.user_ids = {} + + def run(self, *args, **kwargs): + '''run a query.''' + self.ui.note(_('query: %s %s\n') % (args, kwargs)) + try: + self.cursor.execute(*args, **kwargs) + except MySQLdb.MySQLError, err: + self.ui.note(_('failed query: %s %s\n') % (args, kwargs)) + raise + + def filter_real_bug_ids(self, ids): + '''filter not-existing bug ids from list.''' + self.run('select bug_id from bugs where bug_id in %s' % buglist(ids)) + ids = [c[0] for c in self.cursor.fetchall()] + ids.sort() + return ids + + def filter_unknown_bug_ids(self, node, ids): + '''filter bug ids from list that already refer to this changeset.''' + + self.run('''select bug_id from longdescs where + bug_id in %s and thetext like "%%%s%%"''' % + (buglist(ids), short(node))) + unknown = dict.fromkeys(ids) + for (id,) in self.cursor.fetchall(): + self.ui.status(_('bug %d already knows about changeset %s\n') % + (id, short(node))) + unknown.pop(id, None) + ids = unknown.keys() + ids.sort() + return ids + + def notify(self, ids): + '''tell bugzilla to send mail.''' + + self.ui.status(_('telling bugzilla to send mail:\n')) + for id in ids: + self.ui.status(_(' bug %s\n') % id) + cmd = self.ui.config('bugzilla', 'notify', + 'cd /var/www/html/bugzilla && ' + './processmail %s nobody@nowhere.com') % id + fp = os.popen('(%s) 2>&1' % cmd) + out = fp.read() + ret = fp.close() + if ret: + self.ui.warn(out) + raise util.Abort(_('bugzilla notify command %s') % + util.explain_exit(ret)[0]) + self.ui.status(_('done\n')) + + def get_user_id(self, user): + '''look up numeric bugzilla user id.''' + try: + return self.user_ids[user] + except KeyError: + try: + userid = int(user) + except ValueError: + self.ui.note(_('looking up user %s\n') % user) + self.run('''select userid from profiles + where login_name like %s''', user) + all = self.cursor.fetchall() + if len(all) != 1: + raise KeyError(user) + userid = int(all[0][0]) + self.user_ids[user] = userid + return userid + + def add_comment(self, bugid, text, prefuser): + '''add comment to bug. try adding comment as committer of + changeset, otherwise as default bugzilla user.''' + try: + userid = self.get_user_id(prefuser) + except KeyError: + try: + defaultuser = self.ui.config('bugzilla', 'bzuser') + userid = self.get_user_id(defaultuser) + except KeyError: + raise util.Abort(_('cannot find user id for %s or %s') % + (prefuser, defaultuser)) + now = time.strftime('%Y-%m-%d %H:%M:%S') + self.run('''insert into longdescs + (bug_id, who, bug_when, thetext) + values (%s, %s, %s, %s)''', + (bugid, userid, now, text)) + self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid) + values (%s, %s, %s, %s)''', + (bugid, userid, now, self.longdesc_id)) + +class bugzilla(object): + # supported versions of bugzilla. different versions have + # different schemas. + _versions = { + '2.16': bugzilla_2_16, + } + + _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*' + r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)') + + _bz = None + + def __init__(self, ui, repo): + self.ui = ui + self.repo = repo + + def bz(self): + '''return object that knows how to talk to bugzilla version in + use.''' + + if bugzilla._bz is None: + bzversion = self.ui.config('bugzilla', 'version') + try: + bzclass = bugzilla._versions[bzversion] + except KeyError: + raise util.Abort(_('bugzilla version %s not supported') % + bzversion) + bugzilla._bz = bzclass(self.ui) + return bugzilla._bz + + def __getattr__(self, key): + return getattr(self.bz(), key) + + _bug_re = None + _split_re = None + + def find_bug_ids(self, node, desc): + '''find valid bug ids that are referred to in changeset + comments and that do not already have references to this + changeset.''' + + if bugzilla._bug_re is None: + bugzilla._bug_re = re.compile( + self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re), + re.IGNORECASE) + bugzilla._split_re = re.compile(r'\D+') + start = 0 + ids = {} + while True: + m = bugzilla._bug_re.search(desc, start) + if not m: + break + start = m.end() + for id in bugzilla._split_re.split(m.group(1)): + ids[int(id)] = 1 + ids = ids.keys() + if ids: + ids = self.filter_real_bug_ids(ids) + if ids: + ids = self.filter_unknown_bug_ids(node, ids) + return ids + + def update(self, bugid, node, changes): + '''update bugzilla bug with reference to changeset.''' + + def webroot(root): + '''strip leading prefix of repo root and turn into + url-safe path.''' + count = int(self.ui.config('bugzilla', 'strip', 0)) + root = util.pconvert(root) + while count > 0: + c = root.find('/') + if c == -1: + break + root = root[c+1:] + count -= 1 + return root + + class stringio(object): + '''wrap cStringIO.''' + def __init__(self): + self.fp = cStringIO.StringIO() + + def write(self, *args): + for a in args: + self.fp.write(a) + + write_header = write + + def getvalue(self): + return self.fp.getvalue() + + mapfile = self.ui.config('bugzilla', 'style') + tmpl = self.ui.config('bugzilla', 'template') + sio = stringio() + t = templater.changeset_templater(self.ui, self.repo, mapfile, sio) + if not mapfile and not tmpl: + tmpl = _('changeset {node|short} in repo {root} refers ' + 'to bug {bug}.\ndetails:\n\t{desc|tabindent}') + if tmpl: + tmpl = templater.parsestring(tmpl, quoted=False) + t.use_template(tmpl) + t.show(changenode=node, changes=changes, + bug=str(bugid), + hgweb=self.ui.config('bugzilla', 'hgweb'), + root=self.repo.root, + webroot=webroot(self.repo.root)) + self.add_comment(bugid, sio.getvalue(), templater.email(changes[1])) + +def hook(ui, repo, hooktype, node=None, **kwargs): + '''add comment to bugzilla for each changeset that refers to a + bugzilla bug id. only add a comment once per bug, so same change + seen multiple times does not fill bug with duplicate data.''' + if node is None: + raise util.Abort(_('hook type %s does not pass a changeset id') % + hooktype) + try: + bz = bugzilla(ui, repo) + bin_node = bin(node) + changes = repo.changelog.read(bin_node) + ids = bz.find_bug_ids(bin_node, changes[4]) + if ids: + for id in ids: + bz.update(id, bin_node, changes) + bz.notify(ids) + return True + except MySQLdb.MySQLError, err: + raise util.Abort(_('database error: %s') % err[1]) +