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