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