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