1 # bugzilla.py - bugzilla integration for mercurial
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2, incorporated herein by reference.
8 '''hooks for integrating with the Bugzilla bug tracker
10 This hook extension adds comments on bugs in Bugzilla when changesets
11 that refer to bugs by Bugzilla ID are seen. The hook does not change
14 The hook updates the Bugzilla database directly. Only Bugzilla
15 installations using MySQL are supported.
17 The hook relies on a Bugzilla script to send bug change notification
18 emails. That script changes between Bugzilla versions; the
19 'processmail' script used prior to 2.18 is replaced in 2.18 and
20 subsequent versions by 'config/sendbugmail.pl'. Note that these will
21 be run by Mercurial as the user pushing the change; you will need to
22 ensure the Bugzilla install file permissions are set appropriately.
24 The extension is configured through three different configuration
25 sections. These keys are recognized in the [bugzilla] section:
28 Hostname of the MySQL server holding the Bugzilla database.
31 Name of the Bugzilla database in MySQL. Default 'bugs'.
34 Username to use to access MySQL server. Default 'bugs'.
37 Password to use to access MySQL server.
40 Database connection timeout (seconds). Default 5.
43 Bugzilla version. Specify '3.0' for Bugzilla versions 3.0 and later,
44 '2.18' for Bugzilla versions from 2.18 and '2.16' for versions prior
48 Fallback Bugzilla user name to record comments with, if changeset
49 committer cannot be found as a Bugzilla user.
52 Bugzilla install directory. Used by default notify. Default
53 '/var/www/html/bugzilla'.
56 The command to run to get Bugzilla to send bug change notification
57 emails. Substitutes from a map with 3 keys, 'bzdir', 'id' (bug id)
58 and 'user' (committer bugzilla email). Default depends on version;
59 from 2.18 it is "cd %(bzdir)s && perl -T contrib/sendbugmail.pl
63 Regular expression to match bug IDs in changeset commit message.
64 Must contain one "()" group. The default expression matches 'Bug
65 1234', 'Bug no. 1234', 'Bug number 1234', 'Bugs 1234,5678', 'Bug
66 1234 and 5678' and variations thereof. Matching is case insensitive.
69 The style file to use when formatting comments.
72 Template to use when formatting comments. Overrides style if
73 specified. In addition to the usual Mercurial keywords, the
76 {bug} The Bugzilla bug ID.
77 {root} The full pathname of the Mercurial repository.
78 {webroot} Stripped pathname of the Mercurial repository.
79 {hgweb} Base URL for browsing Mercurial repositories.
81 Default 'changeset {node|short} in repo {root} refers '
82 'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'
85 The number of slashes to strip from the front of {root} to produce
89 Path of file containing Mercurial committer ID to Bugzilla user ID
90 mappings. If specified, the file should contain one mapping per
91 line, "committer"="Bugzilla user". See also the [usermap] section.
93 The [usermap] section is used to specify mappings of Mercurial
94 committer ID to Bugzilla user ID. See also [bugzilla].usermap.
95 "committer"="Bugzilla user"
97 Finally, the [web] section supports one entry:
100 Base URL for browsing Mercurial repositories. Reference from
101 templates as {hgweb}.
103 Activating the extension::
109 # run bugzilla hook on every change pulled or pushed in here
110 incoming.bugzilla = python:hgext.bugzilla.hook
112 Example configuration:
114 This example configuration is for a collection of Mercurial
115 repositories in /var/local/hg/repos/ used with a local Bugzilla 3.2
116 installation in /opt/bugzilla-3.2. ::
122 bzuser=unknown@domain.com
123 bzdir=/opt/bugzilla-3.2
124 template=Changeset {node|short} in {root|basename}.
125 {hgweb}/{webroot}/rev/{node|short}\\n
130 baseurl=http://dev.domain.com/hg
133 user@emaildomain.com=user.name@bugzilladomain.com
135 Commits add a comment to the Bugzilla bug record of the form::
137 Changeset 3b16791d6642 in repository-name.
138 http://dev.domain.com/hg/repository-name/rev/3b16791d6642
140 Changeset commit comment. Bug 1234.
143 from mercurial.i18n import _
144 from mercurial.node import short
145 from mercurial import cmdutil, templater, util
151 return '(' + ','.join(map(str, ids)) + ')'
153 class bugzilla_2_16(object):
154 '''support for bugzilla version 2.16.'''
156 def __init__(self, ui):
158 host = self.ui.config('bugzilla', 'host', 'localhost')
159 user = self.ui.config('bugzilla', 'user', 'bugs')
160 passwd = self.ui.config('bugzilla', 'password')
161 db = self.ui.config('bugzilla', 'db', 'bugs')
162 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
163 usermap = self.ui.config('bugzilla', 'usermap')
165 self.ui.readconfig(usermap, sections=['usermap'])
166 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
167 (host, db, user, '*' * len(passwd)))
168 self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
169 db=db, connect_timeout=timeout)
170 self.cursor = self.conn.cursor()
171 self.longdesc_id = self.get_longdesc_id()
173 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
175 def run(self, *args, **kwargs):
177 self.ui.note(_('query: %s %s\n') % (args, kwargs))
179 self.cursor.execute(*args, **kwargs)
180 except MySQLdb.MySQLError:
181 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
184 def get_longdesc_id(self):
185 '''get identity of longdesc field'''
186 self.run('select fieldid from fielddefs where name = "longdesc"')
187 ids = self.cursor.fetchall()
189 raise util.Abort(_('unknown database schema'))
192 def filter_real_bug_ids(self, ids):
193 '''filter not-existing bug ids from list.'''
194 self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
195 return sorted([c[0] for c in self.cursor.fetchall()])
197 def filter_unknown_bug_ids(self, node, ids):
198 '''filter bug ids from list that already refer to this changeset.'''
200 self.run('''select bug_id from longdescs where
201 bug_id in %s and thetext like "%%%s%%"''' %
202 (buglist(ids), short(node)))
204 for (id,) in self.cursor.fetchall():
205 self.ui.status(_('bug %d already knows about changeset %s\n') %
208 return sorted(unknown)
210 def notify(self, ids, committer):
211 '''tell bugzilla to send mail.'''
213 self.ui.status(_('telling bugzilla to send mail:\n'))
214 (user, userid) = self.get_bugzilla_user(committer)
216 self.ui.status(_(' bug %s\n') % id)
217 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
218 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
220 # Backwards-compatible with old notify string, which
221 # took one string. This will throw with a new format
225 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
226 self.ui.note(_('running notify command %s\n') % cmd)
227 fp = util.popen('(%s) 2>&1' % cmd)
232 raise util.Abort(_('bugzilla notify command %s') %
233 util.explain_exit(ret)[0])
234 self.ui.status(_('done\n'))
236 def get_user_id(self, user):
237 '''look up numeric bugzilla user id.'''
239 return self.user_ids[user]
244 self.ui.note(_('looking up user %s\n') % user)
245 self.run('''select userid from profiles
246 where login_name like %s''', user)
247 all = self.cursor.fetchall()
250 userid = int(all[0][0])
251 self.user_ids[user] = userid
254 def map_committer(self, user):
255 '''map name of committer to bugzilla user name.'''
256 for committer, bzuser in self.ui.configitems('usermap'):
257 if committer.lower() == user.lower():
261 def get_bugzilla_user(self, committer):
262 '''see if committer is a registered bugzilla user. Return
263 bugzilla username and userid if so. If not, return default
264 bugzilla username and userid.'''
265 user = self.map_committer(committer)
267 userid = self.get_user_id(user)
270 defaultuser = self.ui.config('bugzilla', 'bzuser')
272 raise util.Abort(_('cannot find bugzilla user id for %s') %
274 userid = self.get_user_id(defaultuser)
277 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
279 return (user, userid)
281 def add_comment(self, bugid, text, committer):
282 '''add comment to bug. try adding comment as committer of
283 changeset, otherwise as default bugzilla user.'''
284 (user, userid) = self.get_bugzilla_user(committer)
285 now = time.strftime('%Y-%m-%d %H:%M:%S')
286 self.run('''insert into longdescs
287 (bug_id, who, bug_when, thetext)
288 values (%s, %s, %s, %s)''',
289 (bugid, userid, now, text))
290 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
291 values (%s, %s, %s, %s)''',
292 (bugid, userid, now, self.longdesc_id))
295 class bugzilla_2_18(bugzilla_2_16):
296 '''support for bugzilla 2.18 series.'''
298 def __init__(self, ui):
299 bugzilla_2_16.__init__(self, ui)
300 self.default_notify = "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
302 class bugzilla_3_0(bugzilla_2_18):
303 '''support for bugzilla 3.0 series.'''
305 def __init__(self, ui):
306 bugzilla_2_18.__init__(self, ui)
308 def get_longdesc_id(self):
309 '''get identity of longdesc field'''
310 self.run('select id from fielddefs where name = "longdesc"')
311 ids = self.cursor.fetchall()
313 raise util.Abort(_('unknown database schema'))
316 class bugzilla(object):
317 # supported versions of bugzilla. different versions have
320 '2.16': bugzilla_2_16,
321 '2.18': bugzilla_2_18,
325 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
326 r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
330 def __init__(self, ui, repo):
335 '''return object that knows how to talk to bugzilla version in
338 if bugzilla._bz is None:
339 bzversion = self.ui.config('bugzilla', 'version')
341 bzclass = bugzilla._versions[bzversion]
343 raise util.Abort(_('bugzilla version %s not supported') %
345 bugzilla._bz = bzclass(self.ui)
348 def __getattr__(self, key):
349 return getattr(self.bz(), key)
354 def find_bug_ids(self, ctx):
355 '''find valid bug ids that are referred to in changeset
356 comments and that do not already have references to this
359 if bugzilla._bug_re is None:
360 bugzilla._bug_re = re.compile(
361 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
363 bugzilla._split_re = re.compile(r'\D+')
367 m = bugzilla._bug_re.search(ctx.description(), start)
371 for id in bugzilla._split_re.split(m.group(1)):
375 ids = self.filter_real_bug_ids(ids)
377 ids = self.filter_unknown_bug_ids(ctx.node(), ids)
380 def update(self, bugid, ctx):
381 '''update bugzilla bug with reference to changeset.'''
384 '''strip leading prefix of repo root and turn into
386 count = int(self.ui.config('bugzilla', 'strip', 0))
387 root = util.pconvert(root)
396 mapfile = self.ui.config('bugzilla', 'style')
397 tmpl = self.ui.config('bugzilla', 'template')
398 t = cmdutil.changeset_templater(self.ui, self.repo,
399 False, None, mapfile, False)
400 if not mapfile and not tmpl:
401 tmpl = _('changeset {node|short} in repo {root} refers '
402 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
404 tmpl = templater.parsestring(tmpl, quoted=False)
407 t.show(ctx, changes=ctx.changeset(),
409 hgweb=self.ui.config('web', 'baseurl'),
411 webroot=webroot(self.repo.root))
412 data = self.ui.popbuffer()
413 self.add_comment(bugid, data, util.email(ctx.user()))
415 def hook(ui, repo, hooktype, node=None, **kwargs):
416 '''add comment to bugzilla for each changeset that refers to a
417 bugzilla bug id. only add a comment once per bug, so same change
418 seen multiple times does not fill bug with duplicate data.'''
420 import MySQLdb as mysql
423 except ImportError, err:
424 raise util.Abort(_('python mysql support not available: %s') % err)
427 raise util.Abort(_('hook type %s does not pass a changeset id') %
430 bz = bugzilla(ui, repo)
432 ids = bz.find_bug_ids(ctx)
436 bz.notify(ids, util.email(ctx.user()))
437 except MySQLdb.MySQLError, err:
438 raise util.Abort(_('database error: %s') % err[1])