##// END OF EJS Templates
Bugzilla 2.18 and on use contrib/sendbugmail.pl, not processmail....
Jim Hague -
r7618:6c89dd0a default
parent child Browse files
Show More
@@ -1,381 +1,410 b''
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms
6 6 # of the GNU General Public License, incorporated herein by reference.
7 7
8 8 '''Bugzilla integration
9 9
10 10 This hook extension adds comments on bugs in Bugzilla when changesets
11 11 that refer to bugs by Bugzilla ID are seen. The hook does not change bug
12 12 status.
13 13
14 14 The hook updates the Bugzilla database directly. Only Bugzilla installations
15 15 using MySQL are supported.
16 16
17 17 The hook relies on a Bugzilla script to send bug change notification emails.
18 18 That script changes between Bugzilla versions; the 'processmail' script used
19 19 prior to 2.18 is replaced in 2.18 and subsequent versions by
20 20 'config/sendbugmail.pl'. Note that these will be run by Mercurial as the user
21 21 pushing the change; you will need to ensure the Bugzilla install file
22 22 permissions are set appropriately.
23 23
24 24 Configuring the extension:
25 25
26 26 [bugzilla]
27 27 host Hostname of the MySQL server holding the Bugzilla database.
28 28 db Name of the Bugzilla database in MySQL. Default 'bugs'.
29 29 user Username to use to access MySQL server. Default 'bugs'.
30 30 password Password to use to access MySQL server.
31 31 timeout Database connection timeout (seconds). Default 5.
32 version Bugzilla version. Specify '3.0' for Bugzilla versions from
33 3.0 onwards, and '2.16' for versions prior to 3.0.
32 version Bugzilla version. Specify '3.0' for Bugzilla versions 3.0 and
33 later, '2.18' for Bugzilla versions from 2.18 and '2.16' for
34 versions prior to 2.18.
34 35 bzuser Fallback Bugzilla user name to record comments with, if
35 36 changeset committer cannot be found as a Bugzilla user.
37 bzdir Bugzilla install directory. Used by default notify.
38 Default '/var/www/html/bugzilla'.
36 39 notify The command to run to get Bugzilla to send bug change
37 notification emails. Substitutes one string parameter,
38 the bug ID. Default 'cd /var/www/html/bugzilla && '
39 './processmail %s nobody@nowhere.com'.
40 notification emails. Substitutes from a map with 3 keys,
41 'bzdir', 'id' (bug id) and 'user' (committer bugzilla email).
42 Default depends on version; from 2.18 it is
43 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s".
40 44 regexp Regular expression to match bug IDs in changeset commit message.
41 45 Must contain one "()" group. The default expression matches
42 46 'Bug 1234', 'Bug no. 1234', 'Bug number 1234',
43 47 'Bugs 1234,5678', 'Bug 1234 and 5678' and variations thereof.
44 48 Matching is case insensitive.
45 49 style The style file to use when formatting comments.
46 50 template Template to use when formatting comments. Overrides
47 51 style if specified. In addition to the usual Mercurial
48 52 keywords, the extension specifies:
49 53 {bug} The Bugzilla bug ID.
50 54 {root} The full pathname of the Mercurial repository.
51 55 {webroot} Stripped pathname of the Mercurial repository.
52 56 {hgweb} Base URL for browsing Mercurial repositories.
53 57 Default 'changeset {node|short} in repo {root} refers '
54 58 'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'
55 59 strip The number of slashes to strip from the front of {root}
56 60 to produce {webroot}. Default 0.
57 61 usermap Path of file containing Mercurial committer ID to Bugzilla user
58 62 ID mappings. If specified, the file should contain one mapping
59 63 per line, "committer"="Bugzilla user". See also the
60 64 [usermap] section.
61 65
62 66 [usermap]
63 67 Any entries in this section specify mappings of Mercurial committer ID
64 68 to Bugzilla user ID. See also [bugzilla].usermap.
65 69 "committer"="Bugzilla user"
66 70
67 71 [web]
68 72 baseurl Base URL for browsing Mercurial repositories. Reference from
69 73 templates as {hgweb}.
70 74
71 75 Activating the extension:
72 76
73 77 [extensions]
74 78 hgext.bugzilla =
75 79
76 80 [hooks]
77 81 # run bugzilla hook on every change pulled or pushed in here
78 82 incoming.bugzilla = python:hgext.bugzilla.hook
79 83
80 84 Example configuration:
81 85
82 86 This example configuration is for a collection of Mercurial repositories
83 87 in /var/local/hg/repos/ used with a local Bugzilla 3.2 installation in
84 88 /opt/bugzilla-3.2.
85 89
86 90 [bugzilla]
87 91 host=localhost
88 92 password=XYZZY
89 93 version=3.0
90 94 bzuser=unknown@domain.com
91 notify=cd /opt/bugzilla-3.2 && perl -T contrib/sendbugmail.pl %%s bugmail@domain.com
95 bzdir=/opt/bugzilla-3.2
92 96 template=Changeset {node|short} in {root|basename}.\\n{hgweb}/{webroot}/rev/{node|short}\\n\\n{desc}\\n
93 97 strip=5
94 98
95 99 [web]
96 100 baseurl=http://dev.domain.com/hg
97 101
98 102 [usermap]
99 103 user@emaildomain.com=user.name@bugzilladomain.com
100 104
101 105 Commits add a comment to the Bugzilla bug record of the form:
102 106
103 107 Changeset 3b16791d6642 in repository-name.
104 108 http://dev.domain.com/hg/repository-name/rev/3b16791d6642
105 109
106 110 Changeset commit comment. Bug 1234.
107 111 '''
108 112
109 113 from mercurial.i18n import _
110 114 from mercurial.node import short
111 115 from mercurial import cmdutil, templater, util
112 116 import re, time
113 117
114 118 MySQLdb = None
115 119
116 120 def buglist(ids):
117 121 return '(' + ','.join(map(str, ids)) + ')'
118 122
119 123 class bugzilla_2_16(object):
120 124 '''support for bugzilla version 2.16.'''
121 125
122 126 def __init__(self, ui):
123 127 self.ui = ui
124 128 host = self.ui.config('bugzilla', 'host', 'localhost')
125 129 user = self.ui.config('bugzilla', 'user', 'bugs')
126 130 passwd = self.ui.config('bugzilla', 'password')
127 131 db = self.ui.config('bugzilla', 'db', 'bugs')
128 132 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
129 133 usermap = self.ui.config('bugzilla', 'usermap')
130 134 if usermap:
131 135 self.ui.readsections(usermap, 'usermap')
132 136 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
133 137 (host, db, user, '*' * len(passwd)))
134 138 self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
135 139 db=db, connect_timeout=timeout)
136 140 self.cursor = self.conn.cursor()
137 141 self.longdesc_id = self.get_longdesc_id()
138 142 self.user_ids = {}
143 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
139 144
140 145 def run(self, *args, **kwargs):
141 146 '''run a query.'''
142 147 self.ui.note(_('query: %s %s\n') % (args, kwargs))
143 148 try:
144 149 self.cursor.execute(*args, **kwargs)
145 150 except MySQLdb.MySQLError:
146 151 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
147 152 raise
148 153
149 154 def get_longdesc_id(self):
150 155 '''get identity of longdesc field'''
151 156 self.run('select fieldid from fielddefs where name = "longdesc"')
152 157 ids = self.cursor.fetchall()
153 158 if len(ids) != 1:
154 159 raise util.Abort(_('unknown database schema'))
155 160 return ids[0][0]
156 161
157 162 def filter_real_bug_ids(self, ids):
158 163 '''filter not-existing bug ids from list.'''
159 164 self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
160 165 return util.sort([c[0] for c in self.cursor.fetchall()])
161 166
162 167 def filter_unknown_bug_ids(self, node, ids):
163 168 '''filter bug ids from list that already refer to this changeset.'''
164 169
165 170 self.run('''select bug_id from longdescs where
166 171 bug_id in %s and thetext like "%%%s%%"''' %
167 172 (buglist(ids), short(node)))
168 173 unknown = dict.fromkeys(ids)
169 174 for (id,) in self.cursor.fetchall():
170 175 self.ui.status(_('bug %d already knows about changeset %s\n') %
171 176 (id, short(node)))
172 177 unknown.pop(id, None)
173 178 return util.sort(unknown.keys())
174 179
175 def notify(self, ids):
180 def notify(self, ids, committer):
176 181 '''tell bugzilla to send mail.'''
177 182
178 183 self.ui.status(_('telling bugzilla to send mail:\n'))
184 (user, userid) = self.get_bugzilla_user(committer)
179 185 for id in ids:
180 186 self.ui.status(_(' bug %s\n') % id)
181 cmd = self.ui.config('bugzilla', 'notify',
182 'cd /var/www/html/bugzilla && '
183 './processmail %s nobody@nowhere.com') % id
187 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
188 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
189 try:
190 # Backwards-compatible with old notify string, which
191 # took one string. This will throw with a new format
192 # string.
193 cmd = cmdfmt % id
194 except TypeError:
195 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
196 self.ui.note(_('running notify command %s\n') % cmd)
184 197 fp = util.popen('(%s) 2>&1' % cmd)
185 198 out = fp.read()
186 199 ret = fp.close()
187 200 if ret:
188 201 self.ui.warn(out)
189 202 raise util.Abort(_('bugzilla notify command %s') %
190 203 util.explain_exit(ret)[0])
191 204 self.ui.status(_('done\n'))
192 205
193 206 def get_user_id(self, user):
194 207 '''look up numeric bugzilla user id.'''
195 208 try:
196 209 return self.user_ids[user]
197 210 except KeyError:
198 211 try:
199 212 userid = int(user)
200 213 except ValueError:
201 214 self.ui.note(_('looking up user %s\n') % user)
202 215 self.run('''select userid from profiles
203 216 where login_name like %s''', user)
204 217 all = self.cursor.fetchall()
205 218 if len(all) != 1:
206 219 raise KeyError(user)
207 220 userid = int(all[0][0])
208 221 self.user_ids[user] = userid
209 222 return userid
210 223
211 224 def map_committer(self, user):
212 225 '''map name of committer to bugzilla user name.'''
213 226 for committer, bzuser in self.ui.configitems('usermap'):
214 227 if committer.lower() == user.lower():
215 228 return bzuser
216 229 return user
217 230
218 def add_comment(self, bugid, text, committer):
219 '''add comment to bug. try adding comment as committer of
220 changeset, otherwise as default bugzilla user.'''
231 def get_bugzilla_user(self, committer):
232 '''see if committer is a registered bugzilla user. Return
233 bugzilla username and userid if so. If not, return default
234 bugzilla username and userid.'''
221 235 user = self.map_committer(committer)
222 236 try:
223 237 userid = self.get_user_id(user)
224 238 except KeyError:
225 239 try:
226 240 defaultuser = self.ui.config('bugzilla', 'bzuser')
227 241 if not defaultuser:
228 242 raise util.Abort(_('cannot find bugzilla user id for %s') %
229 243 user)
230 244 userid = self.get_user_id(defaultuser)
245 user = defaultuser
231 246 except KeyError:
232 247 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
233 248 (user, defaultuser))
249 return (user, userid)
250
251 def add_comment(self, bugid, text, committer):
252 '''add comment to bug. try adding comment as committer of
253 changeset, otherwise as default bugzilla user.'''
254 (user, userid) = self.get_bugzilla_user(committer)
234 255 now = time.strftime('%Y-%m-%d %H:%M:%S')
235 256 self.run('''insert into longdescs
236 257 (bug_id, who, bug_when, thetext)
237 258 values (%s, %s, %s, %s)''',
238 259 (bugid, userid, now, text))
239 260 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
240 261 values (%s, %s, %s, %s)''',
241 262 (bugid, userid, now, self.longdesc_id))
242 263 self.conn.commit()
243 264
244 class bugzilla_3_0(bugzilla_2_16):
265 class bugzilla_2_18(bugzilla_2_16):
266 '''support for bugzilla 2.18 series.'''
267
268 def __init__(self, ui):
269 bugzilla_2_16.__init__(self, ui)
270 self.default_notify = "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
271
272 class bugzilla_3_0(bugzilla_2_18):
245 273 '''support for bugzilla 3.0 series.'''
246 274
247 275 def __init__(self, ui):
248 bugzilla_2_16.__init__(self, ui)
276 bugzilla_2_18.__init__(self, ui)
249 277
250 278 def get_longdesc_id(self):
251 279 '''get identity of longdesc field'''
252 280 self.run('select id from fielddefs where name = "longdesc"')
253 281 ids = self.cursor.fetchall()
254 282 if len(ids) != 1:
255 283 raise util.Abort(_('unknown database schema'))
256 284 return ids[0][0]
257 285
258 286 class bugzilla(object):
259 287 # supported versions of bugzilla. different versions have
260 288 # different schemas.
261 289 _versions = {
262 290 '2.16': bugzilla_2_16,
291 '2.18': bugzilla_2_18,
263 292 '3.0': bugzilla_3_0
264 293 }
265 294
266 295 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
267 296 r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
268 297
269 298 _bz = None
270 299
271 300 def __init__(self, ui, repo):
272 301 self.ui = ui
273 302 self.repo = repo
274 303
275 304 def bz(self):
276 305 '''return object that knows how to talk to bugzilla version in
277 306 use.'''
278 307
279 308 if bugzilla._bz is None:
280 309 bzversion = self.ui.config('bugzilla', 'version')
281 310 try:
282 311 bzclass = bugzilla._versions[bzversion]
283 312 except KeyError:
284 313 raise util.Abort(_('bugzilla version %s not supported') %
285 314 bzversion)
286 315 bugzilla._bz = bzclass(self.ui)
287 316 return bugzilla._bz
288 317
289 318 def __getattr__(self, key):
290 319 return getattr(self.bz(), key)
291 320
292 321 _bug_re = None
293 322 _split_re = None
294 323
295 324 def find_bug_ids(self, ctx):
296 325 '''find valid bug ids that are referred to in changeset
297 326 comments and that do not already have references to this
298 327 changeset.'''
299 328
300 329 if bugzilla._bug_re is None:
301 330 bugzilla._bug_re = re.compile(
302 331 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
303 332 re.IGNORECASE)
304 333 bugzilla._split_re = re.compile(r'\D+')
305 334 start = 0
306 335 ids = {}
307 336 while True:
308 337 m = bugzilla._bug_re.search(ctx.description(), start)
309 338 if not m:
310 339 break
311 340 start = m.end()
312 341 for id in bugzilla._split_re.split(m.group(1)):
313 342 if not id: continue
314 343 ids[int(id)] = 1
315 344 ids = ids.keys()
316 345 if ids:
317 346 ids = self.filter_real_bug_ids(ids)
318 347 if ids:
319 348 ids = self.filter_unknown_bug_ids(ctx.node(), ids)
320 349 return ids
321 350
322 351 def update(self, bugid, ctx):
323 352 '''update bugzilla bug with reference to changeset.'''
324 353
325 354 def webroot(root):
326 355 '''strip leading prefix of repo root and turn into
327 356 url-safe path.'''
328 357 count = int(self.ui.config('bugzilla', 'strip', 0))
329 358 root = util.pconvert(root)
330 359 while count > 0:
331 360 c = root.find('/')
332 361 if c == -1:
333 362 break
334 363 root = root[c+1:]
335 364 count -= 1
336 365 return root
337 366
338 367 mapfile = self.ui.config('bugzilla', 'style')
339 368 tmpl = self.ui.config('bugzilla', 'template')
340 369 t = cmdutil.changeset_templater(self.ui, self.repo,
341 370 False, mapfile, False)
342 371 if not mapfile and not tmpl:
343 372 tmpl = _('changeset {node|short} in repo {root} refers '
344 373 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
345 374 if tmpl:
346 375 tmpl = templater.parsestring(tmpl, quoted=False)
347 376 t.use_template(tmpl)
348 377 self.ui.pushbuffer()
349 378 t.show(ctx, changes=ctx.changeset(),
350 379 bug=str(bugid),
351 380 hgweb=self.ui.config('web', 'baseurl'),
352 381 root=self.repo.root,
353 382 webroot=webroot(self.repo.root))
354 383 data = self.ui.popbuffer()
355 384 self.add_comment(bugid, data, util.email(ctx.user()))
356 385
357 386 def hook(ui, repo, hooktype, node=None, **kwargs):
358 387 '''add comment to bugzilla for each changeset that refers to a
359 388 bugzilla bug id. only add a comment once per bug, so same change
360 389 seen multiple times does not fill bug with duplicate data.'''
361 390 try:
362 391 import MySQLdb as mysql
363 392 global MySQLdb
364 393 MySQLdb = mysql
365 394 except ImportError, err:
366 395 raise util.Abort(_('python mysql support not available: %s') % err)
367 396
368 397 if node is None:
369 398 raise util.Abort(_('hook type %s does not pass a changeset id') %
370 399 hooktype)
371 400 try:
372 401 bz = bugzilla(ui, repo)
373 402 ctx = repo[node]
374 403 ids = bz.find_bug_ids(ctx)
375 404 if ids:
376 405 for id in ids:
377 406 bz.update(id, ctx)
378 bz.notify(ids)
407 bz.notify(ids, util.email(ctx.user()))
379 408 except MySQLdb.MySQLError, err:
380 409 raise util.Abort(_('database error: %s') % err[1])
381 410
General Comments 0
You need to be logged in to leave comments. Login now