diff -r 5ff1fc726848 -r c6bca38c1cbf eggs/mercurial-1.7.3-py2.6-linux-x86_64.egg/hgext/bugzilla.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eggs/mercurial-1.7.3-py2.6-linux-x86_64.egg/hgext/bugzilla.py Sat Jan 08 11:20:57 2011 +0530 @@ -0,0 +1,441 @@ +# 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 version 2 or any later version. + +'''hooks for integrating with the Bugzilla bug tracker + +This hook extension adds comments on bugs in Bugzilla when changesets +that refer to bugs by Bugzilla ID are seen. The hook does not change +bug status. + +The hook updates the Bugzilla database directly. Only Bugzilla +installations using MySQL are supported. + +The hook relies on a Bugzilla script to send bug change notification +emails. That script changes between Bugzilla versions; the +'processmail' script used prior to 2.18 is replaced in 2.18 and +subsequent versions by 'config/sendbugmail.pl'. Note that these will +be run by Mercurial as the user pushing the change; you will need to +ensure the Bugzilla install file permissions are set appropriately. + +The extension is configured through three different configuration +sections. These keys are recognized in the [bugzilla] section: + +host + Hostname of the MySQL server holding the Bugzilla database. + +db + Name of the Bugzilla database in MySQL. Default 'bugs'. + +user + Username to use to access MySQL server. Default 'bugs'. + +password + Password to use to access MySQL server. + +timeout + Database connection timeout (seconds). Default 5. + +version + Bugzilla version. Specify '3.0' for Bugzilla versions 3.0 and later, + '2.18' for Bugzilla versions from 2.18 and '2.16' for versions prior + to 2.18. + +bzuser + Fallback Bugzilla user name to record comments with, if changeset + committer cannot be found as a Bugzilla user. + +bzdir + Bugzilla install directory. Used by default notify. Default + '/var/www/html/bugzilla'. + +notify + The command to run to get Bugzilla to send bug change notification + emails. Substitutes from a map with 3 keys, 'bzdir', 'id' (bug id) + and 'user' (committer bugzilla email). Default depends on version; + from 2.18 it is "cd %(bzdir)s && perl -T contrib/sendbugmail.pl + %(id)s %(user)s". + +regexp + Regular expression to match bug IDs in changeset commit message. + Must contain one "()" group. The default expression matches 'Bug + 1234', 'Bug no. 1234', 'Bug number 1234', 'Bugs 1234,5678', 'Bug + 1234 and 5678' and variations thereof. Matching is case insensitive. + +style + The style file to use when formatting comments. + +template + Template to use when formatting comments. Overrides style if + specified. In addition to the usual Mercurial keywords, the + extension specifies:: + + {bug} The Bugzilla bug ID. + {root} The full pathname of the Mercurial repository. + {webroot} Stripped pathname of the Mercurial repository. + {hgweb} Base URL for browsing Mercurial repositories. + + Default 'changeset {node|short} in repo {root} refers ' + 'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}' + +strip + The number of slashes to strip from the front of {root} to produce + {webroot}. Default 0. + +usermap + Path of file containing Mercurial committer ID to Bugzilla user ID + mappings. If specified, the file should contain one mapping per + line, "committer"="Bugzilla user". See also the [usermap] section. + +The [usermap] section is used to specify mappings of Mercurial +committer ID to Bugzilla user ID. See also [bugzilla].usermap. +"committer"="Bugzilla user" + +Finally, the [web] section supports one entry: + +baseurl + Base URL for browsing Mercurial repositories. Reference from + templates as {hgweb}. + +Activating the extension:: + + [extensions] + bugzilla = + + [hooks] + # run bugzilla hook on every change pulled or pushed in here + incoming.bugzilla = python:hgext.bugzilla.hook + +Example configuration: + +This example configuration is for a collection of Mercurial +repositories in /var/local/hg/repos/ used with a local Bugzilla 3.2 +installation in /opt/bugzilla-3.2. :: + + [bugzilla] + host=localhost + password=XYZZY + version=3.0 + bzuser=unknown@domain.com + bzdir=/opt/bugzilla-3.2 + template=Changeset {node|short} in {root|basename}. + {hgweb}/{webroot}/rev/{node|short}\\n + {desc}\\n + strip=5 + + [web] + baseurl=http://dev.domain.com/hg + + [usermap] + user@emaildomain.com=user.name@bugzilladomain.com + +Commits add a comment to the Bugzilla bug record of the form:: + + Changeset 3b16791d6642 in repository-name. + http://dev.domain.com/hg/repository-name/rev/3b16791d6642 + + Changeset commit comment. Bug 1234. +''' + +from mercurial.i18n import _ +from mercurial.node import short +from mercurial import cmdutil, templater, util +import re, time + +MySQLdb = None + +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)) + usermap = self.ui.config('bugzilla', 'usermap') + if usermap: + self.ui.readconfig(usermap, sections=['usermap']) + 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.longdesc_id = self.get_longdesc_id() + self.user_ids = {} + self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s" + + 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: + self.ui.note(_('failed query: %s %s\n') % (args, kwargs)) + raise + + def get_longdesc_id(self): + '''get identity of longdesc field''' + self.run('select fieldid from fielddefs where name = "longdesc"') + ids = self.cursor.fetchall() + if len(ids) != 1: + raise util.Abort(_('unknown database schema')) + return ids[0][0] + + 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)) + return sorted([c[0] for c in self.cursor.fetchall()]) + + 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 = set(ids) + for (id,) in self.cursor.fetchall(): + self.ui.status(_('bug %d already knows about changeset %s\n') % + (id, short(node))) + unknown.discard(id) + return sorted(unknown) + + def notify(self, ids, committer): + '''tell bugzilla to send mail.''' + + self.ui.status(_('telling bugzilla to send mail:\n')) + (user, userid) = self.get_bugzilla_user(committer) + for id in ids: + self.ui.status(_(' bug %s\n') % id) + cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify) + bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla') + try: + # Backwards-compatible with old notify string, which + # took one string. This will throw with a new format + # string. + cmd = cmdfmt % id + except TypeError: + cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user} + self.ui.note(_('running notify command %s\n') % cmd) + fp = util.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 map_committer(self, user): + '''map name of committer to bugzilla user name.''' + for committer, bzuser in self.ui.configitems('usermap'): + if committer.lower() == user.lower(): + return bzuser + return user + + def get_bugzilla_user(self, committer): + '''see if committer is a registered bugzilla user. Return + bugzilla username and userid if so. If not, return default + bugzilla username and userid.''' + user = self.map_committer(committer) + try: + userid = self.get_user_id(user) + except KeyError: + try: + defaultuser = self.ui.config('bugzilla', 'bzuser') + if not defaultuser: + raise util.Abort(_('cannot find bugzilla user id for %s') % + user) + userid = self.get_user_id(defaultuser) + user = defaultuser + except KeyError: + raise util.Abort(_('cannot find bugzilla user id for %s or %s') % + (user, defaultuser)) + return (user, userid) + + def add_comment(self, bugid, text, committer): + '''add comment to bug. try adding comment as committer of + changeset, otherwise as default bugzilla user.''' + (user, userid) = self.get_bugzilla_user(committer) + 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)) + self.conn.commit() + +class bugzilla_2_18(bugzilla_2_16): + '''support for bugzilla 2.18 series.''' + + def __init__(self, ui): + bugzilla_2_16.__init__(self, ui) + self.default_notify = \ + "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s" + +class bugzilla_3_0(bugzilla_2_18): + '''support for bugzilla 3.0 series.''' + + def __init__(self, ui): + bugzilla_2_18.__init__(self, ui) + + def get_longdesc_id(self): + '''get identity of longdesc field''' + self.run('select id from fielddefs where name = "longdesc"') + ids = self.cursor.fetchall() + if len(ids) != 1: + raise util.Abort(_('unknown database schema')) + return ids[0][0] + +class bugzilla(object): + # supported versions of bugzilla. different versions have + # different schemas. + _versions = { + '2.16': bugzilla_2_16, + '2.18': bugzilla_2_18, + '3.0': bugzilla_3_0 + } + + _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, ctx): + '''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 = set() + while True: + m = bugzilla._bug_re.search(ctx.description(), start) + if not m: + break + start = m.end() + for id in bugzilla._split_re.split(m.group(1)): + if not id: + continue + ids.add(int(id)) + if ids: + ids = self.filter_real_bug_ids(ids) + if ids: + ids = self.filter_unknown_bug_ids(ctx.node(), ids) + return ids + + def update(self, bugid, ctx): + '''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 + + mapfile = self.ui.config('bugzilla', 'style') + tmpl = self.ui.config('bugzilla', 'template') + t = cmdutil.changeset_templater(self.ui, self.repo, + False, None, mapfile, False) + 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) + self.ui.pushbuffer() + t.show(ctx, changes=ctx.changeset(), + bug=str(bugid), + hgweb=self.ui.config('web', 'baseurl'), + root=self.repo.root, + webroot=webroot(self.repo.root)) + data = self.ui.popbuffer() + self.add_comment(bugid, data, util.email(ctx.user())) + +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.''' + try: + import MySQLdb as mysql + global MySQLdb + MySQLdb = mysql + except ImportError, err: + raise util.Abort(_('python mysql support not available: %s') % err) + + if node is None: + raise util.Abort(_('hook type %s does not pass a changeset id') % + hooktype) + try: + bz = bugzilla(ui, repo) + ctx = repo[node] + ids = bz.find_bug_ids(ctx) + if ids: + for id in ids: + bz.update(id, ctx) + bz.notify(ids, util.email(ctx.user())) + except MySQLdb.MySQLError, err: + raise util.Abort(_('database error: %s') % err.args[1]) +