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