##// END OF EJS Templates
configitems: register the 'bugzilla.regexp' config...
Boris Feld -
r33462:c8417122 default
parent child Browse files
Show More
@@ -1,1107 +1,1107 b''
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 # Copyright 2011-4 Jim Hague <jim.hague@acm.org>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 '''hooks for integrating with the Bugzilla bug tracker
10 10
11 11 This hook extension adds comments on bugs in Bugzilla when changesets
12 12 that refer to bugs by Bugzilla ID are seen. The comment is formatted using
13 13 the Mercurial template mechanism.
14 14
15 15 The bug references can optionally include an update for Bugzilla of the
16 16 hours spent working on the bug. Bugs can also be marked fixed.
17 17
18 18 Four basic modes of access to Bugzilla are provided:
19 19
20 20 1. Access via the Bugzilla REST-API. Requires bugzilla 5.0 or later.
21 21
22 22 2. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
23 23
24 24 3. Check data via the Bugzilla XMLRPC interface and submit bug change
25 25 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
26 26
27 27 4. Writing directly to the Bugzilla database. Only Bugzilla installations
28 28 using MySQL are supported. Requires Python MySQLdb.
29 29
30 30 Writing directly to the database is susceptible to schema changes, and
31 31 relies on a Bugzilla contrib script to send out bug change
32 32 notification emails. This script runs as the user running Mercurial,
33 33 must be run on the host with the Bugzilla install, and requires
34 34 permission to read Bugzilla configuration details and the necessary
35 35 MySQL user and password to have full access rights to the Bugzilla
36 36 database. For these reasons this access mode is now considered
37 37 deprecated, and will not be updated for new Bugzilla versions going
38 38 forward. Only adding comments is supported in this access mode.
39 39
40 40 Access via XMLRPC needs a Bugzilla username and password to be specified
41 41 in the configuration. Comments are added under that username. Since the
42 42 configuration must be readable by all Mercurial users, it is recommended
43 43 that the rights of that user are restricted in Bugzilla to the minimum
44 44 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
45 45
46 46 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
47 47 email to the Bugzilla email interface to submit comments to bugs.
48 48 The From: address in the email is set to the email address of the Mercurial
49 49 user, so the comment appears to come from the Mercurial user. In the event
50 50 that the Mercurial user email is not recognized by Bugzilla as a Bugzilla
51 51 user, the email associated with the Bugzilla username used to log into
52 52 Bugzilla is used instead as the source of the comment. Marking bugs fixed
53 53 works on all supported Bugzilla versions.
54 54
55 55 Access via the REST-API needs either a Bugzilla username and password
56 56 or an apikey specified in the configuration. Comments are made under
57 57 the given username or the user associated with the apikey in Bugzilla.
58 58
59 59 Configuration items common to all access modes:
60 60
61 61 bugzilla.version
62 62 The access type to use. Values recognized are:
63 63
64 64 :``restapi``: Bugzilla REST-API, Bugzilla 5.0 and later.
65 65 :``xmlrpc``: Bugzilla XMLRPC interface.
66 66 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
67 67 :``3.0``: MySQL access, Bugzilla 3.0 and later.
68 68 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
69 69 including 3.0.
70 70 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
71 71 including 2.18.
72 72
73 73 bugzilla.regexp
74 74 Regular expression to match bug IDs for update in changeset commit message.
75 75 It must contain one "()" named group ``<ids>`` containing the bug
76 76 IDs separated by non-digit characters. It may also contain
77 77 a named group ``<hours>`` with a floating-point number giving the
78 78 hours worked on the bug. If no named groups are present, the first
79 79 "()" group is assumed to contain the bug IDs, and work time is not
80 80 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
81 81 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
82 82 variations thereof, followed by an hours number prefixed by ``h`` or
83 83 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
84 84
85 85 bugzilla.fixregexp
86 86 Regular expression to match bug IDs for marking fixed in changeset
87 87 commit message. This must contain a "()" named group ``<ids>` containing
88 88 the bug IDs separated by non-digit characters. It may also contain
89 89 a named group ``<hours>`` with a floating-point number giving the
90 90 hours worked on the bug. If no named groups are present, the first
91 91 "()" group is assumed to contain the bug IDs, and work time is not
92 92 updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``,
93 93 ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and
94 94 variations thereof, followed by an hours number prefixed by ``h`` or
95 95 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
96 96
97 97 bugzilla.fixstatus
98 98 The status to set a bug to when marking fixed. Default ``RESOLVED``.
99 99
100 100 bugzilla.fixresolution
101 101 The resolution to set a bug to when marking fixed. Default ``FIXED``.
102 102
103 103 bugzilla.style
104 104 The style file to use when formatting comments.
105 105
106 106 bugzilla.template
107 107 Template to use when formatting comments. Overrides style if
108 108 specified. In addition to the usual Mercurial keywords, the
109 109 extension specifies:
110 110
111 111 :``{bug}``: The Bugzilla bug ID.
112 112 :``{root}``: The full pathname of the Mercurial repository.
113 113 :``{webroot}``: Stripped pathname of the Mercurial repository.
114 114 :``{hgweb}``: Base URL for browsing Mercurial repositories.
115 115
116 116 Default ``changeset {node|short} in repo {root} refers to bug
117 117 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
118 118
119 119 bugzilla.strip
120 120 The number of path separator characters to strip from the front of
121 121 the Mercurial repository path (``{root}`` in templates) to produce
122 122 ``{webroot}``. For example, a repository with ``{root}``
123 123 ``/var/local/my-project`` with a strip of 2 gives a value for
124 124 ``{webroot}`` of ``my-project``. Default 0.
125 125
126 126 web.baseurl
127 127 Base URL for browsing Mercurial repositories. Referenced from
128 128 templates as ``{hgweb}``.
129 129
130 130 Configuration items common to XMLRPC+email and MySQL access modes:
131 131
132 132 bugzilla.usermap
133 133 Path of file containing Mercurial committer email to Bugzilla user email
134 134 mappings. If specified, the file should contain one mapping per
135 135 line::
136 136
137 137 committer = Bugzilla user
138 138
139 139 See also the ``[usermap]`` section.
140 140
141 141 The ``[usermap]`` section is used to specify mappings of Mercurial
142 142 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
143 143 Contains entries of the form ``committer = Bugzilla user``.
144 144
145 145 XMLRPC and REST-API access mode configuration:
146 146
147 147 bugzilla.bzurl
148 148 The base URL for the Bugzilla installation.
149 149 Default ``http://localhost/bugzilla``.
150 150
151 151 bugzilla.user
152 152 The username to use to log into Bugzilla via XMLRPC. Default
153 153 ``bugs``.
154 154
155 155 bugzilla.password
156 156 The password for Bugzilla login.
157 157
158 158 REST-API access mode uses the options listed above as well as:
159 159
160 160 bugzilla.apikey
161 161 An apikey generated on the Bugzilla instance for api access.
162 162 Using an apikey removes the need to store the user and password
163 163 options.
164 164
165 165 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
166 166 and also:
167 167
168 168 bugzilla.bzemail
169 169 The Bugzilla email address.
170 170
171 171 In addition, the Mercurial email settings must be configured. See the
172 172 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
173 173
174 174 MySQL access mode configuration:
175 175
176 176 bugzilla.host
177 177 Hostname of the MySQL server holding the Bugzilla database.
178 178 Default ``localhost``.
179 179
180 180 bugzilla.db
181 181 Name of the Bugzilla database in MySQL. Default ``bugs``.
182 182
183 183 bugzilla.user
184 184 Username to use to access MySQL server. Default ``bugs``.
185 185
186 186 bugzilla.password
187 187 Password to use to access MySQL server.
188 188
189 189 bugzilla.timeout
190 190 Database connection timeout (seconds). Default 5.
191 191
192 192 bugzilla.bzuser
193 193 Fallback Bugzilla user name to record comments with, if changeset
194 194 committer cannot be found as a Bugzilla user.
195 195
196 196 bugzilla.bzdir
197 197 Bugzilla install directory. Used by default notify. Default
198 198 ``/var/www/html/bugzilla``.
199 199
200 200 bugzilla.notify
201 201 The command to run to get Bugzilla to send bug change notification
202 202 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
203 203 id) and ``user`` (committer bugzilla email). Default depends on
204 204 version; from 2.18 it is "cd %(bzdir)s && perl -T
205 205 contrib/sendbugmail.pl %(id)s %(user)s".
206 206
207 207 Activating the extension::
208 208
209 209 [extensions]
210 210 bugzilla =
211 211
212 212 [hooks]
213 213 # run bugzilla hook on every change pulled or pushed in here
214 214 incoming.bugzilla = python:hgext.bugzilla.hook
215 215
216 216 Example configurations:
217 217
218 218 XMLRPC example configuration. This uses the Bugzilla at
219 219 ``http://my-project.org/bugzilla``, logging in as user
220 220 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
221 221 collection of Mercurial repositories in ``/var/local/hg/repos/``,
222 222 with a web interface at ``http://my-project.org/hg``. ::
223 223
224 224 [bugzilla]
225 225 bzurl=http://my-project.org/bugzilla
226 226 user=bugmail@my-project.org
227 227 password=plugh
228 228 version=xmlrpc
229 229 template=Changeset {node|short} in {root|basename}.
230 230 {hgweb}/{webroot}/rev/{node|short}\\n
231 231 {desc}\\n
232 232 strip=5
233 233
234 234 [web]
235 235 baseurl=http://my-project.org/hg
236 236
237 237 XMLRPC+email example configuration. This uses the Bugzilla at
238 238 ``http://my-project.org/bugzilla``, logging in as user
239 239 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
240 240 collection of Mercurial repositories in ``/var/local/hg/repos/``,
241 241 with a web interface at ``http://my-project.org/hg``. Bug comments
242 242 are sent to the Bugzilla email address
243 243 ``bugzilla@my-project.org``. ::
244 244
245 245 [bugzilla]
246 246 bzurl=http://my-project.org/bugzilla
247 247 user=bugmail@my-project.org
248 248 password=plugh
249 249 version=xmlrpc+email
250 250 bzemail=bugzilla@my-project.org
251 251 template=Changeset {node|short} in {root|basename}.
252 252 {hgweb}/{webroot}/rev/{node|short}\\n
253 253 {desc}\\n
254 254 strip=5
255 255
256 256 [web]
257 257 baseurl=http://my-project.org/hg
258 258
259 259 [usermap]
260 260 user@emaildomain.com=user.name@bugzilladomain.com
261 261
262 262 MySQL example configuration. This has a local Bugzilla 3.2 installation
263 263 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
264 264 the Bugzilla database name is ``bugs`` and MySQL is
265 265 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
266 266 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
267 267 with a web interface at ``http://my-project.org/hg``. ::
268 268
269 269 [bugzilla]
270 270 host=localhost
271 271 password=XYZZY
272 272 version=3.0
273 273 bzuser=unknown@domain.com
274 274 bzdir=/opt/bugzilla-3.2
275 275 template=Changeset {node|short} in {root|basename}.
276 276 {hgweb}/{webroot}/rev/{node|short}\\n
277 277 {desc}\\n
278 278 strip=5
279 279
280 280 [web]
281 281 baseurl=http://my-project.org/hg
282 282
283 283 [usermap]
284 284 user@emaildomain.com=user.name@bugzilladomain.com
285 285
286 286 All the above add a comment to the Bugzilla bug record of the form::
287 287
288 288 Changeset 3b16791d6642 in repository-name.
289 289 http://my-project.org/hg/repository-name/rev/3b16791d6642
290 290
291 291 Changeset commit comment. Bug 1234.
292 292 '''
293 293
294 294 from __future__ import absolute_import
295 295
296 296 import json
297 297 import re
298 298 import time
299 299
300 300 from mercurial.i18n import _
301 301 from mercurial.node import short
302 302 from mercurial import (
303 303 cmdutil,
304 304 error,
305 305 mail,
306 306 registrar,
307 307 url,
308 308 util,
309 309 )
310 310
311 311 xmlrpclib = util.xmlrpclib
312 312
313 313 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
314 314 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
315 315 # be specifying the version(s) of Mercurial they are tested with, or
316 316 # leave the attribute unspecified.
317 317 testedwith = 'ships-with-hg-core'
318 318
319 319 configtable = {}
320 320 configitem = registrar.configitem(configtable)
321 321
322 322 configitem('bugzilla', 'apikey',
323 323 default='',
324 324 )
325 325 configitem('bugzilla', 'bzdir',
326 326 default='/var/www/html/bugzilla',
327 327 )
328 328 configitem('bugzilla', 'bzemail',
329 329 default=None,
330 330 )
331 331 configitem('bugzilla', 'bzurl',
332 332 default='http://localhost/bugzilla/',
333 333 )
334 334 configitem('bugzilla', 'bzuser',
335 335 default=None,
336 336 )
337 337 configitem('bugzilla', 'db',
338 338 default='bugs',
339 339 )
340 340 configitem('bugzilla', 'fixregexp',
341 341 default=lambda: bugzilla._default_fix_re,
342 342 )
343 343 configitem('bugzilla', 'fixresolution',
344 344 default='FIXED',
345 345 )
346 346 configitem('bugzilla', 'fixstatus',
347 347 default='RESOLVED',
348 348 )
349 349 configitem('bugzilla', 'host',
350 350 default='localhost',
351 351 )
352 352 configitem('bugzilla', 'password',
353 353 default=None,
354 354 )
355 configitem('bugzilla', 'regexp',
356 default=(r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
357 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
358 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
359 )
355 360
356 361 class bzaccess(object):
357 362 '''Base class for access to Bugzilla.'''
358 363
359 364 def __init__(self, ui):
360 365 self.ui = ui
361 366 usermap = self.ui.config('bugzilla', 'usermap')
362 367 if usermap:
363 368 self.ui.readconfig(usermap, sections=['usermap'])
364 369
365 370 def map_committer(self, user):
366 371 '''map name of committer to Bugzilla user name.'''
367 372 for committer, bzuser in self.ui.configitems('usermap'):
368 373 if committer.lower() == user.lower():
369 374 return bzuser
370 375 return user
371 376
372 377 # Methods to be implemented by access classes.
373 378 #
374 379 # 'bugs' is a dict keyed on bug id, where values are a dict holding
375 380 # updates to bug state. Recognized dict keys are:
376 381 #
377 382 # 'hours': Value, float containing work hours to be updated.
378 383 # 'fix': If key present, bug is to be marked fixed. Value ignored.
379 384
380 385 def filter_real_bug_ids(self, bugs):
381 386 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
382 387 pass
383 388
384 389 def filter_cset_known_bug_ids(self, node, bugs):
385 390 '''remove bug IDs where node occurs in comment text from bugs.'''
386 391 pass
387 392
388 393 def updatebug(self, bugid, newstate, text, committer):
389 394 '''update the specified bug. Add comment text and set new states.
390 395
391 396 If possible add the comment as being from the committer of
392 397 the changeset. Otherwise use the default Bugzilla user.
393 398 '''
394 399 pass
395 400
396 401 def notify(self, bugs, committer):
397 402 '''Force sending of Bugzilla notification emails.
398 403
399 404 Only required if the access method does not trigger notification
400 405 emails automatically.
401 406 '''
402 407 pass
403 408
404 409 # Bugzilla via direct access to MySQL database.
405 410 class bzmysql(bzaccess):
406 411 '''Support for direct MySQL access to Bugzilla.
407 412
408 413 The earliest Bugzilla version this is tested with is version 2.16.
409 414
410 415 If your Bugzilla is version 3.4 or above, you are strongly
411 416 recommended to use the XMLRPC access method instead.
412 417 '''
413 418
414 419 @staticmethod
415 420 def sql_buglist(ids):
416 421 '''return SQL-friendly list of bug ids'''
417 422 return '(' + ','.join(map(str, ids)) + ')'
418 423
419 424 _MySQLdb = None
420 425
421 426 def __init__(self, ui):
422 427 try:
423 428 import MySQLdb as mysql
424 429 bzmysql._MySQLdb = mysql
425 430 except ImportError as err:
426 431 raise error.Abort(_('python mysql support not available: %s') % err)
427 432
428 433 bzaccess.__init__(self, ui)
429 434
430 435 host = self.ui.config('bugzilla', 'host')
431 436 user = self.ui.config('bugzilla', 'user', 'bugs')
432 437 passwd = self.ui.config('bugzilla', 'password')
433 438 db = self.ui.config('bugzilla', 'db')
434 439 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
435 440 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
436 441 (host, db, user, '*' * len(passwd)))
437 442 self.conn = bzmysql._MySQLdb.connect(host=host,
438 443 user=user, passwd=passwd,
439 444 db=db,
440 445 connect_timeout=timeout)
441 446 self.cursor = self.conn.cursor()
442 447 self.longdesc_id = self.get_longdesc_id()
443 448 self.user_ids = {}
444 449 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
445 450
446 451 def run(self, *args, **kwargs):
447 452 '''run a query.'''
448 453 self.ui.note(_('query: %s %s\n') % (args, kwargs))
449 454 try:
450 455 self.cursor.execute(*args, **kwargs)
451 456 except bzmysql._MySQLdb.MySQLError:
452 457 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
453 458 raise
454 459
455 460 def get_longdesc_id(self):
456 461 '''get identity of longdesc field'''
457 462 self.run('select fieldid from fielddefs where name = "longdesc"')
458 463 ids = self.cursor.fetchall()
459 464 if len(ids) != 1:
460 465 raise error.Abort(_('unknown database schema'))
461 466 return ids[0][0]
462 467
463 468 def filter_real_bug_ids(self, bugs):
464 469 '''filter not-existing bugs from set.'''
465 470 self.run('select bug_id from bugs where bug_id in %s' %
466 471 bzmysql.sql_buglist(bugs.keys()))
467 472 existing = [id for (id,) in self.cursor.fetchall()]
468 473 for id in bugs.keys():
469 474 if id not in existing:
470 475 self.ui.status(_('bug %d does not exist\n') % id)
471 476 del bugs[id]
472 477
473 478 def filter_cset_known_bug_ids(self, node, bugs):
474 479 '''filter bug ids that already refer to this changeset from set.'''
475 480 self.run('''select bug_id from longdescs where
476 481 bug_id in %s and thetext like "%%%s%%"''' %
477 482 (bzmysql.sql_buglist(bugs.keys()), short(node)))
478 483 for (id,) in self.cursor.fetchall():
479 484 self.ui.status(_('bug %d already knows about changeset %s\n') %
480 485 (id, short(node)))
481 486 del bugs[id]
482 487
483 488 def notify(self, bugs, committer):
484 489 '''tell bugzilla to send mail.'''
485 490 self.ui.status(_('telling bugzilla to send mail:\n'))
486 491 (user, userid) = self.get_bugzilla_user(committer)
487 492 for id in bugs.keys():
488 493 self.ui.status(_(' bug %s\n') % id)
489 494 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
490 495 bzdir = self.ui.config('bugzilla', 'bzdir')
491 496 try:
492 497 # Backwards-compatible with old notify string, which
493 498 # took one string. This will throw with a new format
494 499 # string.
495 500 cmd = cmdfmt % id
496 501 except TypeError:
497 502 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
498 503 self.ui.note(_('running notify command %s\n') % cmd)
499 504 fp = util.popen('(%s) 2>&1' % cmd)
500 505 out = fp.read()
501 506 ret = fp.close()
502 507 if ret:
503 508 self.ui.warn(out)
504 509 raise error.Abort(_('bugzilla notify command %s') %
505 510 util.explainexit(ret)[0])
506 511 self.ui.status(_('done\n'))
507 512
508 513 def get_user_id(self, user):
509 514 '''look up numeric bugzilla user id.'''
510 515 try:
511 516 return self.user_ids[user]
512 517 except KeyError:
513 518 try:
514 519 userid = int(user)
515 520 except ValueError:
516 521 self.ui.note(_('looking up user %s\n') % user)
517 522 self.run('''select userid from profiles
518 523 where login_name like %s''', user)
519 524 all = self.cursor.fetchall()
520 525 if len(all) != 1:
521 526 raise KeyError(user)
522 527 userid = int(all[0][0])
523 528 self.user_ids[user] = userid
524 529 return userid
525 530
526 531 def get_bugzilla_user(self, committer):
527 532 '''See if committer is a registered bugzilla user. Return
528 533 bugzilla username and userid if so. If not, return default
529 534 bugzilla username and userid.'''
530 535 user = self.map_committer(committer)
531 536 try:
532 537 userid = self.get_user_id(user)
533 538 except KeyError:
534 539 try:
535 540 defaultuser = self.ui.config('bugzilla', 'bzuser')
536 541 if not defaultuser:
537 542 raise error.Abort(_('cannot find bugzilla user id for %s') %
538 543 user)
539 544 userid = self.get_user_id(defaultuser)
540 545 user = defaultuser
541 546 except KeyError:
542 547 raise error.Abort(_('cannot find bugzilla user id for %s or %s')
543 548 % (user, defaultuser))
544 549 return (user, userid)
545 550
546 551 def updatebug(self, bugid, newstate, text, committer):
547 552 '''update bug state with comment text.
548 553
549 554 Try adding comment as committer of changeset, otherwise as
550 555 default bugzilla user.'''
551 556 if len(newstate) > 0:
552 557 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
553 558
554 559 (user, userid) = self.get_bugzilla_user(committer)
555 560 now = time.strftime('%Y-%m-%d %H:%M:%S')
556 561 self.run('''insert into longdescs
557 562 (bug_id, who, bug_when, thetext)
558 563 values (%s, %s, %s, %s)''',
559 564 (bugid, userid, now, text))
560 565 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
561 566 values (%s, %s, %s, %s)''',
562 567 (bugid, userid, now, self.longdesc_id))
563 568 self.conn.commit()
564 569
565 570 class bzmysql_2_18(bzmysql):
566 571 '''support for bugzilla 2.18 series.'''
567 572
568 573 def __init__(self, ui):
569 574 bzmysql.__init__(self, ui)
570 575 self.default_notify = \
571 576 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
572 577
573 578 class bzmysql_3_0(bzmysql_2_18):
574 579 '''support for bugzilla 3.0 series.'''
575 580
576 581 def __init__(self, ui):
577 582 bzmysql_2_18.__init__(self, ui)
578 583
579 584 def get_longdesc_id(self):
580 585 '''get identity of longdesc field'''
581 586 self.run('select id from fielddefs where name = "longdesc"')
582 587 ids = self.cursor.fetchall()
583 588 if len(ids) != 1:
584 589 raise error.Abort(_('unknown database schema'))
585 590 return ids[0][0]
586 591
587 592 # Bugzilla via XMLRPC interface.
588 593
589 594 class cookietransportrequest(object):
590 595 """A Transport request method that retains cookies over its lifetime.
591 596
592 597 The regular xmlrpclib transports ignore cookies. Which causes
593 598 a bit of a problem when you need a cookie-based login, as with
594 599 the Bugzilla XMLRPC interface prior to 4.4.3.
595 600
596 601 So this is a helper for defining a Transport which looks for
597 602 cookies being set in responses and saves them to add to all future
598 603 requests.
599 604 """
600 605
601 606 # Inspiration drawn from
602 607 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
603 608 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
604 609
605 610 cookies = []
606 611 def send_cookies(self, connection):
607 612 if self.cookies:
608 613 for cookie in self.cookies:
609 614 connection.putheader("Cookie", cookie)
610 615
611 616 def request(self, host, handler, request_body, verbose=0):
612 617 self.verbose = verbose
613 618 self.accept_gzip_encoding = False
614 619
615 620 # issue XML-RPC request
616 621 h = self.make_connection(host)
617 622 if verbose:
618 623 h.set_debuglevel(1)
619 624
620 625 self.send_request(h, handler, request_body)
621 626 self.send_host(h, host)
622 627 self.send_cookies(h)
623 628 self.send_user_agent(h)
624 629 self.send_content(h, request_body)
625 630
626 631 # Deal with differences between Python 2.6 and 2.7.
627 632 # In the former h is a HTTP(S). In the latter it's a
628 633 # HTTP(S)Connection. Luckily, the 2.6 implementation of
629 634 # HTTP(S) has an underlying HTTP(S)Connection, so extract
630 635 # that and use it.
631 636 try:
632 637 response = h.getresponse()
633 638 except AttributeError:
634 639 response = h._conn.getresponse()
635 640
636 641 # Add any cookie definitions to our list.
637 642 for header in response.msg.getallmatchingheaders("Set-Cookie"):
638 643 val = header.split(": ", 1)[1]
639 644 cookie = val.split(";", 1)[0]
640 645 self.cookies.append(cookie)
641 646
642 647 if response.status != 200:
643 648 raise xmlrpclib.ProtocolError(host + handler, response.status,
644 649 response.reason, response.msg.headers)
645 650
646 651 payload = response.read()
647 652 parser, unmarshaller = self.getparser()
648 653 parser.feed(payload)
649 654 parser.close()
650 655
651 656 return unmarshaller.close()
652 657
653 658 # The explicit calls to the underlying xmlrpclib __init__() methods are
654 659 # necessary. The xmlrpclib.Transport classes are old-style classes, and
655 660 # it turns out their __init__() doesn't get called when doing multiple
656 661 # inheritance with a new-style class.
657 662 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
658 663 def __init__(self, use_datetime=0):
659 664 if util.safehasattr(xmlrpclib.Transport, "__init__"):
660 665 xmlrpclib.Transport.__init__(self, use_datetime)
661 666
662 667 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
663 668 def __init__(self, use_datetime=0):
664 669 if util.safehasattr(xmlrpclib.Transport, "__init__"):
665 670 xmlrpclib.SafeTransport.__init__(self, use_datetime)
666 671
667 672 class bzxmlrpc(bzaccess):
668 673 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
669 674
670 675 Requires a minimum Bugzilla version 3.4.
671 676 """
672 677
673 678 def __init__(self, ui):
674 679 bzaccess.__init__(self, ui)
675 680
676 681 bzweb = self.ui.config('bugzilla', 'bzurl')
677 682 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
678 683
679 684 user = self.ui.config('bugzilla', 'user', 'bugs')
680 685 passwd = self.ui.config('bugzilla', 'password')
681 686
682 687 self.fixstatus = self.ui.config('bugzilla', 'fixstatus')
683 688 self.fixresolution = self.ui.config('bugzilla', 'fixresolution')
684 689
685 690 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
686 691 ver = self.bzproxy.Bugzilla.version()['version'].split('.')
687 692 self.bzvermajor = int(ver[0])
688 693 self.bzverminor = int(ver[1])
689 694 login = self.bzproxy.User.login({'login': user, 'password': passwd,
690 695 'restrict_login': True})
691 696 self.bztoken = login.get('token', '')
692 697
693 698 def transport(self, uri):
694 699 if util.urlreq.urlparse(uri, "http")[0] == "https":
695 700 return cookiesafetransport()
696 701 else:
697 702 return cookietransport()
698 703
699 704 def get_bug_comments(self, id):
700 705 """Return a string with all comment text for a bug."""
701 706 c = self.bzproxy.Bug.comments({'ids': [id],
702 707 'include_fields': ['text'],
703 708 'token': self.bztoken})
704 709 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
705 710
706 711 def filter_real_bug_ids(self, bugs):
707 712 probe = self.bzproxy.Bug.get({'ids': sorted(bugs.keys()),
708 713 'include_fields': [],
709 714 'permissive': True,
710 715 'token': self.bztoken,
711 716 })
712 717 for badbug in probe['faults']:
713 718 id = badbug['id']
714 719 self.ui.status(_('bug %d does not exist\n') % id)
715 720 del bugs[id]
716 721
717 722 def filter_cset_known_bug_ids(self, node, bugs):
718 723 for id in sorted(bugs.keys()):
719 724 if self.get_bug_comments(id).find(short(node)) != -1:
720 725 self.ui.status(_('bug %d already knows about changeset %s\n') %
721 726 (id, short(node)))
722 727 del bugs[id]
723 728
724 729 def updatebug(self, bugid, newstate, text, committer):
725 730 args = {}
726 731 if 'hours' in newstate:
727 732 args['work_time'] = newstate['hours']
728 733
729 734 if self.bzvermajor >= 4:
730 735 args['ids'] = [bugid]
731 736 args['comment'] = {'body' : text}
732 737 if 'fix' in newstate:
733 738 args['status'] = self.fixstatus
734 739 args['resolution'] = self.fixresolution
735 740 args['token'] = self.bztoken
736 741 self.bzproxy.Bug.update(args)
737 742 else:
738 743 if 'fix' in newstate:
739 744 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
740 745 "to mark bugs fixed\n"))
741 746 args['id'] = bugid
742 747 args['comment'] = text
743 748 self.bzproxy.Bug.add_comment(args)
744 749
745 750 class bzxmlrpcemail(bzxmlrpc):
746 751 """Read data from Bugzilla via XMLRPC, send updates via email.
747 752
748 753 Advantages of sending updates via email:
749 754 1. Comments can be added as any user, not just logged in user.
750 755 2. Bug statuses or other fields not accessible via XMLRPC can
751 756 potentially be updated.
752 757
753 758 There is no XMLRPC function to change bug status before Bugzilla
754 759 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
755 760 But bugs can be marked fixed via email from 3.4 onwards.
756 761 """
757 762
758 763 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
759 764 # in-email fields are specified as '@<fieldname> = <value>'. In
760 765 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
761 766 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
762 767 # compatibility, but rather than rely on this use the new format for
763 768 # 4.0 onwards.
764 769
765 770 def __init__(self, ui):
766 771 bzxmlrpc.__init__(self, ui)
767 772
768 773 self.bzemail = self.ui.config('bugzilla', 'bzemail')
769 774 if not self.bzemail:
770 775 raise error.Abort(_("configuration 'bzemail' missing"))
771 776 mail.validateconfig(self.ui)
772 777
773 778 def makecommandline(self, fieldname, value):
774 779 if self.bzvermajor >= 4:
775 780 return "@%s %s" % (fieldname, str(value))
776 781 else:
777 782 if fieldname == "id":
778 783 fieldname = "bug_id"
779 784 return "@%s = %s" % (fieldname, str(value))
780 785
781 786 def send_bug_modify_email(self, bugid, commands, comment, committer):
782 787 '''send modification message to Bugzilla bug via email.
783 788
784 789 The message format is documented in the Bugzilla email_in.pl
785 790 specification. commands is a list of command lines, comment is the
786 791 comment text.
787 792
788 793 To stop users from crafting commit comments with
789 794 Bugzilla commands, specify the bug ID via the message body, rather
790 795 than the subject line, and leave a blank line after it.
791 796 '''
792 797 user = self.map_committer(committer)
793 798 matches = self.bzproxy.User.get({'match': [user],
794 799 'token': self.bztoken})
795 800 if not matches['users']:
796 801 user = self.ui.config('bugzilla', 'user', 'bugs')
797 802 matches = self.bzproxy.User.get({'match': [user],
798 803 'token': self.bztoken})
799 804 if not matches['users']:
800 805 raise error.Abort(_("default bugzilla user %s email not found")
801 806 % user)
802 807 user = matches['users'][0]['email']
803 808 commands.append(self.makecommandline("id", bugid))
804 809
805 810 text = "\n".join(commands) + "\n\n" + comment
806 811
807 812 _charsets = mail._charsets(self.ui)
808 813 user = mail.addressencode(self.ui, user, _charsets)
809 814 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
810 815 msg = mail.mimeencode(self.ui, text, _charsets)
811 816 msg['From'] = user
812 817 msg['To'] = bzemail
813 818 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
814 819 sendmail = mail.connect(self.ui)
815 820 sendmail(user, bzemail, msg.as_string())
816 821
817 822 def updatebug(self, bugid, newstate, text, committer):
818 823 cmds = []
819 824 if 'hours' in newstate:
820 825 cmds.append(self.makecommandline("work_time", newstate['hours']))
821 826 if 'fix' in newstate:
822 827 cmds.append(self.makecommandline("bug_status", self.fixstatus))
823 828 cmds.append(self.makecommandline("resolution", self.fixresolution))
824 829 self.send_bug_modify_email(bugid, cmds, text, committer)
825 830
826 831 class NotFound(LookupError):
827 832 pass
828 833
829 834 class bzrestapi(bzaccess):
830 835 """Read and write bugzilla data using the REST API available since
831 836 Bugzilla 5.0.
832 837 """
833 838 def __init__(self, ui):
834 839 bzaccess.__init__(self, ui)
835 840 bz = self.ui.config('bugzilla', 'bzurl')
836 841 self.bzroot = '/'.join([bz, 'rest'])
837 842 self.apikey = self.ui.config('bugzilla', 'apikey')
838 843 self.user = self.ui.config('bugzilla', 'user', 'bugs')
839 844 self.passwd = self.ui.config('bugzilla', 'password')
840 845 self.fixstatus = self.ui.config('bugzilla', 'fixstatus')
841 846 self.fixresolution = self.ui.config('bugzilla', 'fixresolution')
842 847
843 848 def apiurl(self, targets, include_fields=None):
844 849 url = '/'.join([self.bzroot] + [str(t) for t in targets])
845 850 qv = {}
846 851 if self.apikey:
847 852 qv['api_key'] = self.apikey
848 853 elif self.user and self.passwd:
849 854 qv['login'] = self.user
850 855 qv['password'] = self.passwd
851 856 if include_fields:
852 857 qv['include_fields'] = include_fields
853 858 if qv:
854 859 url = '%s?%s' % (url, util.urlreq.urlencode(qv))
855 860 return url
856 861
857 862 def _fetch(self, burl):
858 863 try:
859 864 resp = url.open(self.ui, burl)
860 865 return json.loads(resp.read())
861 866 except util.urlerr.httperror as inst:
862 867 if inst.code == 401:
863 868 raise error.Abort(_('authorization failed'))
864 869 if inst.code == 404:
865 870 raise NotFound()
866 871 else:
867 872 raise
868 873
869 874 def _submit(self, burl, data, method='POST'):
870 875 data = json.dumps(data)
871 876 if method == 'PUT':
872 877 class putrequest(util.urlreq.request):
873 878 def get_method(self):
874 879 return 'PUT'
875 880 request_type = putrequest
876 881 else:
877 882 request_type = util.urlreq.request
878 883 req = request_type(burl, data,
879 884 {'Content-Type': 'application/json'})
880 885 try:
881 886 resp = url.opener(self.ui).open(req)
882 887 return json.loads(resp.read())
883 888 except util.urlerr.httperror as inst:
884 889 if inst.code == 401:
885 890 raise error.Abort(_('authorization failed'))
886 891 if inst.code == 404:
887 892 raise NotFound()
888 893 else:
889 894 raise
890 895
891 896 def filter_real_bug_ids(self, bugs):
892 897 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
893 898 badbugs = set()
894 899 for bugid in bugs:
895 900 burl = self.apiurl(('bug', bugid), include_fields='status')
896 901 try:
897 902 self._fetch(burl)
898 903 except NotFound:
899 904 badbugs.add(bugid)
900 905 for bugid in badbugs:
901 906 del bugs[bugid]
902 907
903 908 def filter_cset_known_bug_ids(self, node, bugs):
904 909 '''remove bug IDs where node occurs in comment text from bugs.'''
905 910 sn = short(node)
906 911 for bugid in bugs.keys():
907 912 burl = self.apiurl(('bug', bugid, 'comment'), include_fields='text')
908 913 result = self._fetch(burl)
909 914 comments = result['bugs'][str(bugid)]['comments']
910 915 if any(sn in c['text'] for c in comments):
911 916 self.ui.status(_('bug %d already knows about changeset %s\n') %
912 917 (bugid, sn))
913 918 del bugs[bugid]
914 919
915 920 def updatebug(self, bugid, newstate, text, committer):
916 921 '''update the specified bug. Add comment text and set new states.
917 922
918 923 If possible add the comment as being from the committer of
919 924 the changeset. Otherwise use the default Bugzilla user.
920 925 '''
921 926 bugmod = {}
922 927 if 'hours' in newstate:
923 928 bugmod['work_time'] = newstate['hours']
924 929 if 'fix' in newstate:
925 930 bugmod['status'] = self.fixstatus
926 931 bugmod['resolution'] = self.fixresolution
927 932 if bugmod:
928 933 # if we have to change the bugs state do it here
929 934 bugmod['comment'] = {
930 935 'comment': text,
931 936 'is_private': False,
932 937 'is_markdown': False,
933 938 }
934 939 burl = self.apiurl(('bug', bugid))
935 940 self._submit(burl, bugmod, method='PUT')
936 941 self.ui.debug('updated bug %s\n' % bugid)
937 942 else:
938 943 burl = self.apiurl(('bug', bugid, 'comment'))
939 944 self._submit(burl, {
940 945 'comment': text,
941 946 'is_private': False,
942 947 'is_markdown': False,
943 948 })
944 949 self.ui.debug('added comment to bug %s\n' % bugid)
945 950
946 951 def notify(self, bugs, committer):
947 952 '''Force sending of Bugzilla notification emails.
948 953
949 954 Only required if the access method does not trigger notification
950 955 emails automatically.
951 956 '''
952 957 pass
953 958
954 959 class bugzilla(object):
955 960 # supported versions of bugzilla. different versions have
956 961 # different schemas.
957 962 _versions = {
958 963 '2.16': bzmysql,
959 964 '2.18': bzmysql_2_18,
960 965 '3.0': bzmysql_3_0,
961 966 'xmlrpc': bzxmlrpc,
962 967 'xmlrpc+email': bzxmlrpcemail,
963 968 'restapi': bzrestapi,
964 969 }
965 970
966 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
967 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
968 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
969
970 971 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
971 972 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
972 973 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
973 974 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
974 975
975 976 def __init__(self, ui, repo):
976 977 self.ui = ui
977 978 self.repo = repo
978 979
979 980 bzversion = self.ui.config('bugzilla', 'version')
980 981 try:
981 982 bzclass = bugzilla._versions[bzversion]
982 983 except KeyError:
983 984 raise error.Abort(_('bugzilla version %s not supported') %
984 985 bzversion)
985 986 self.bzdriver = bzclass(self.ui)
986 987
987 988 self.bug_re = re.compile(
988 self.ui.config('bugzilla', 'regexp',
989 bugzilla._default_bug_re), re.IGNORECASE)
989 self.ui.config('bugzilla', 'regexp'), re.IGNORECASE)
990 990 self.fix_re = re.compile(
991 991 self.ui.config('bugzilla', 'fixregexp'), re.IGNORECASE)
992 992 self.split_re = re.compile(r'\D+')
993 993
994 994 def find_bugs(self, ctx):
995 995 '''return bugs dictionary created from commit comment.
996 996
997 997 Extract bug info from changeset comments. Filter out any that are
998 998 not known to Bugzilla, and any that already have a reference to
999 999 the given changeset in their comments.
1000 1000 '''
1001 1001 start = 0
1002 1002 hours = 0.0
1003 1003 bugs = {}
1004 1004 bugmatch = self.bug_re.search(ctx.description(), start)
1005 1005 fixmatch = self.fix_re.search(ctx.description(), start)
1006 1006 while True:
1007 1007 bugattribs = {}
1008 1008 if not bugmatch and not fixmatch:
1009 1009 break
1010 1010 if not bugmatch:
1011 1011 m = fixmatch
1012 1012 elif not fixmatch:
1013 1013 m = bugmatch
1014 1014 else:
1015 1015 if bugmatch.start() < fixmatch.start():
1016 1016 m = bugmatch
1017 1017 else:
1018 1018 m = fixmatch
1019 1019 start = m.end()
1020 1020 if m is bugmatch:
1021 1021 bugmatch = self.bug_re.search(ctx.description(), start)
1022 1022 if 'fix' in bugattribs:
1023 1023 del bugattribs['fix']
1024 1024 else:
1025 1025 fixmatch = self.fix_re.search(ctx.description(), start)
1026 1026 bugattribs['fix'] = None
1027 1027
1028 1028 try:
1029 1029 ids = m.group('ids')
1030 1030 except IndexError:
1031 1031 ids = m.group(1)
1032 1032 try:
1033 1033 hours = float(m.group('hours'))
1034 1034 bugattribs['hours'] = hours
1035 1035 except IndexError:
1036 1036 pass
1037 1037 except TypeError:
1038 1038 pass
1039 1039 except ValueError:
1040 1040 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
1041 1041
1042 1042 for id in self.split_re.split(ids):
1043 1043 if not id:
1044 1044 continue
1045 1045 bugs[int(id)] = bugattribs
1046 1046 if bugs:
1047 1047 self.bzdriver.filter_real_bug_ids(bugs)
1048 1048 if bugs:
1049 1049 self.bzdriver.filter_cset_known_bug_ids(ctx.node(), bugs)
1050 1050 return bugs
1051 1051
1052 1052 def update(self, bugid, newstate, ctx):
1053 1053 '''update bugzilla bug with reference to changeset.'''
1054 1054
1055 1055 def webroot(root):
1056 1056 '''strip leading prefix of repo root and turn into
1057 1057 url-safe path.'''
1058 1058 count = int(self.ui.config('bugzilla', 'strip', 0))
1059 1059 root = util.pconvert(root)
1060 1060 while count > 0:
1061 1061 c = root.find('/')
1062 1062 if c == -1:
1063 1063 break
1064 1064 root = root[c + 1:]
1065 1065 count -= 1
1066 1066 return root
1067 1067
1068 1068 mapfile = None
1069 1069 tmpl = self.ui.config('bugzilla', 'template')
1070 1070 if not tmpl:
1071 1071 mapfile = self.ui.config('bugzilla', 'style')
1072 1072 if not mapfile and not tmpl:
1073 1073 tmpl = _('changeset {node|short} in repo {root} refers '
1074 1074 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
1075 1075 spec = cmdutil.logtemplatespec(tmpl, mapfile)
1076 1076 t = cmdutil.changeset_templater(self.ui, self.repo, spec,
1077 1077 False, None, False)
1078 1078 self.ui.pushbuffer()
1079 1079 t.show(ctx, changes=ctx.changeset(),
1080 1080 bug=str(bugid),
1081 1081 hgweb=self.ui.config('web', 'baseurl'),
1082 1082 root=self.repo.root,
1083 1083 webroot=webroot(self.repo.root))
1084 1084 data = self.ui.popbuffer()
1085 1085 self.bzdriver.updatebug(bugid, newstate, data, util.email(ctx.user()))
1086 1086
1087 1087 def notify(self, bugs, committer):
1088 1088 '''ensure Bugzilla users are notified of bug change.'''
1089 1089 self.bzdriver.notify(bugs, committer)
1090 1090
1091 1091 def hook(ui, repo, hooktype, node=None, **kwargs):
1092 1092 '''add comment to bugzilla for each changeset that refers to a
1093 1093 bugzilla bug id. only add a comment once per bug, so same change
1094 1094 seen multiple times does not fill bug with duplicate data.'''
1095 1095 if node is None:
1096 1096 raise error.Abort(_('hook type %s does not pass a changeset id') %
1097 1097 hooktype)
1098 1098 try:
1099 1099 bz = bugzilla(ui, repo)
1100 1100 ctx = repo[node]
1101 1101 bugs = bz.find_bugs(ctx)
1102 1102 if bugs:
1103 1103 for bug in bugs:
1104 1104 bz.update(bug, bugs[bug], ctx)
1105 1105 bz.notify(bugs, util.email(ctx.user()))
1106 1106 except Exception as e:
1107 1107 raise error.Abort(_('Bugzilla error: %s') % e)
General Comments 0
You need to be logged in to leave comments. Login now