]> git.lizzy.rs Git - plan9front.git/blob - sys/lib/python/hgext/bugzilla.py
hgwebfs: write headers individually, so they are not limited by webfs iounit (thanks...
[plan9front.git] / sys / lib / python / hgext / bugzilla.py
1 # bugzilla.py - bugzilla integration for mercurial
2 #
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 #
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.
7
8 '''hooks for integrating with the Bugzilla bug tracker
9
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
12 bug status.
13
14 The hook updates the Bugzilla database directly. Only Bugzilla
15 installations using MySQL are supported.
16
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.
23
24 The extension is configured through three different configuration
25 sections. These keys are recognized in the [bugzilla] section:
26
27 host
28   Hostname of the MySQL server holding the Bugzilla database.
29
30 db
31   Name of the Bugzilla database in MySQL. Default 'bugs'.
32
33 user
34   Username to use to access MySQL server. Default 'bugs'.
35
36 password
37   Password to use to access MySQL server.
38
39 timeout
40   Database connection timeout (seconds). Default 5.
41
42 version
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
45   to 2.18.
46
47 bzuser
48   Fallback Bugzilla user name to record comments with, if changeset
49   committer cannot be found as a Bugzilla user.
50
51 bzdir
52    Bugzilla install directory. Used by default notify. Default
53    '/var/www/html/bugzilla'.
54
55 notify
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
60   %(id)s %(user)s".
61
62 regexp
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.
67
68 style
69   The style file to use when formatting comments.
70
71 template
72   Template to use when formatting comments. Overrides style if
73   specified. In addition to the usual Mercurial keywords, the
74   extension specifies::
75
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.
80
81   Default 'changeset {node|short} in repo {root} refers '
82           'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'
83
84 strip
85   The number of slashes to strip from the front of {root} to produce
86   {webroot}. Default 0.
87
88 usermap
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.
92
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"
96
97 Finally, the [web] section supports one entry:
98
99 baseurl
100   Base URL for browsing Mercurial repositories. Reference from
101   templates as {hgweb}.
102
103 Activating the extension::
104
105     [extensions]
106     hgext.bugzilla =
107
108     [hooks]
109     # run bugzilla hook on every change pulled or pushed in here
110     incoming.bugzilla = python:hgext.bugzilla.hook
111
112 Example configuration:
113
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. ::
117
118     [bugzilla]
119     host=localhost
120     password=XYZZY
121     version=3.0
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
126              {desc}\\n
127     strip=5
128
129     [web]
130     baseurl=http://dev.domain.com/hg
131
132     [usermap]
133     user@emaildomain.com=user.name@bugzilladomain.com
134
135 Commits add a comment to the Bugzilla bug record of the form::
136
137     Changeset 3b16791d6642 in repository-name.
138     http://dev.domain.com/hg/repository-name/rev/3b16791d6642
139
140     Changeset commit comment. Bug 1234.
141 '''
142
143 from mercurial.i18n import _
144 from mercurial.node import short
145 from mercurial import cmdutil, templater, util
146 import re, time
147
148 MySQLdb = None
149
150 def buglist(ids):
151     return '(' + ','.join(map(str, ids)) + ')'
152
153 class bugzilla_2_16(object):
154     '''support for bugzilla version 2.16.'''
155
156     def __init__(self, ui):
157         self.ui = 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')
164         if 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()
172         self.user_ids = {}
173         self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
174
175     def run(self, *args, **kwargs):
176         '''run a query.'''
177         self.ui.note(_('query: %s %s\n') % (args, kwargs))
178         try:
179             self.cursor.execute(*args, **kwargs)
180         except MySQLdb.MySQLError:
181             self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
182             raise
183
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()
188         if len(ids) != 1:
189             raise util.Abort(_('unknown database schema'))
190         return ids[0][0]
191
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()])
196
197     def filter_unknown_bug_ids(self, node, ids):
198         '''filter bug ids from list that already refer to this changeset.'''
199
200         self.run('''select bug_id from longdescs where
201                     bug_id in %s and thetext like "%%%s%%"''' %
202                  (buglist(ids), short(node)))
203         unknown = set(ids)
204         for (id,) in self.cursor.fetchall():
205             self.ui.status(_('bug %d already knows about changeset %s\n') %
206                            (id, short(node)))
207             unknown.discard(id)
208         return sorted(unknown)
209
210     def notify(self, ids, committer):
211         '''tell bugzilla to send mail.'''
212
213         self.ui.status(_('telling bugzilla to send mail:\n'))
214         (user, userid) = self.get_bugzilla_user(committer)
215         for id in ids:
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')
219             try:
220                 # Backwards-compatible with old notify string, which
221                 # took one string. This will throw with a new format
222                 # string.
223                 cmd = cmdfmt % id
224             except TypeError:
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)
228             out = fp.read()
229             ret = fp.close()
230             if ret:
231                 self.ui.warn(out)
232                 raise util.Abort(_('bugzilla notify command %s') %
233                                  util.explain_exit(ret)[0])
234         self.ui.status(_('done\n'))
235
236     def get_user_id(self, user):
237         '''look up numeric bugzilla user id.'''
238         try:
239             return self.user_ids[user]
240         except KeyError:
241             try:
242                 userid = int(user)
243             except ValueError:
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()
248                 if len(all) != 1:
249                     raise KeyError(user)
250                 userid = int(all[0][0])
251             self.user_ids[user] = userid
252             return userid
253
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():
258                 return bzuser
259         return user
260
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)
266         try:
267             userid = self.get_user_id(user)
268         except KeyError:
269             try:
270                 defaultuser = self.ui.config('bugzilla', 'bzuser')
271                 if not defaultuser:
272                     raise util.Abort(_('cannot find bugzilla user id for %s') %
273                                      user)
274                 userid = self.get_user_id(defaultuser)
275                 user = defaultuser
276             except KeyError:
277                 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
278                                  (user, defaultuser))
279         return (user, userid)
280
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))
293         self.conn.commit()
294
295 class bugzilla_2_18(bugzilla_2_16):
296     '''support for bugzilla 2.18 series.'''
297
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"
301
302 class bugzilla_3_0(bugzilla_2_18):
303     '''support for bugzilla 3.0 series.'''
304
305     def __init__(self, ui):
306         bugzilla_2_18.__init__(self, ui)
307
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()
312         if len(ids) != 1:
313             raise util.Abort(_('unknown database schema'))
314         return ids[0][0]
315
316 class bugzilla(object):
317     # supported versions of bugzilla. different versions have
318     # different schemas.
319     _versions = {
320         '2.16': bugzilla_2_16,
321         '2.18': bugzilla_2_18,
322         '3.0':  bugzilla_3_0
323         }
324
325     _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
326                        r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
327
328     _bz = None
329
330     def __init__(self, ui, repo):
331         self.ui = ui
332         self.repo = repo
333
334     def bz(self):
335         '''return object that knows how to talk to bugzilla version in
336         use.'''
337
338         if bugzilla._bz is None:
339             bzversion = self.ui.config('bugzilla', 'version')
340             try:
341                 bzclass = bugzilla._versions[bzversion]
342             except KeyError:
343                 raise util.Abort(_('bugzilla version %s not supported') %
344                                  bzversion)
345             bugzilla._bz = bzclass(self.ui)
346         return bugzilla._bz
347
348     def __getattr__(self, key):
349         return getattr(self.bz(), key)
350
351     _bug_re = None
352     _split_re = None
353
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
357         changeset.'''
358
359         if bugzilla._bug_re is None:
360             bugzilla._bug_re = re.compile(
361                 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
362                 re.IGNORECASE)
363             bugzilla._split_re = re.compile(r'\D+')
364         start = 0
365         ids = set()
366         while True:
367             m = bugzilla._bug_re.search(ctx.description(), start)
368             if not m:
369                 break
370             start = m.end()
371             for id in bugzilla._split_re.split(m.group(1)):
372                 if not id: continue
373                 ids.add(int(id))
374         if ids:
375             ids = self.filter_real_bug_ids(ids)
376         if ids:
377             ids = self.filter_unknown_bug_ids(ctx.node(), ids)
378         return ids
379
380     def update(self, bugid, ctx):
381         '''update bugzilla bug with reference to changeset.'''
382
383         def webroot(root):
384             '''strip leading prefix of repo root and turn into
385             url-safe path.'''
386             count = int(self.ui.config('bugzilla', 'strip', 0))
387             root = util.pconvert(root)
388             while count > 0:
389                 c = root.find('/')
390                 if c == -1:
391                     break
392                 root = root[c+1:]
393                 count -= 1
394             return root
395
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}')
403         if tmpl:
404             tmpl = templater.parsestring(tmpl, quoted=False)
405             t.use_template(tmpl)
406         self.ui.pushbuffer()
407         t.show(ctx, changes=ctx.changeset(),
408                bug=str(bugid),
409                hgweb=self.ui.config('web', 'baseurl'),
410                root=self.repo.root,
411                webroot=webroot(self.repo.root))
412         data = self.ui.popbuffer()
413         self.add_comment(bugid, data, util.email(ctx.user()))
414
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.'''
419     try:
420         import MySQLdb as mysql
421         global MySQLdb
422         MySQLdb = mysql
423     except ImportError, err:
424         raise util.Abort(_('python mysql support not available: %s') % err)
425
426     if node is None:
427         raise util.Abort(_('hook type %s does not pass a changeset id') %
428                          hooktype)
429     try:
430         bz = bugzilla(ui, repo)
431         ctx = repo[node]
432         ids = bz.find_bug_ids(ctx)
433         if ids:
434             for id in ids:
435                 bz.update(id, ctx)
436             bz.notify(ids, util.email(ctx.user()))
437     except MySQLdb.MySQLError, err:
438         raise util.Abort(_('database error: %s') % err[1])
439