##// END OF EJS Templates
configitems: register the 'bugzilla.fixregexp' config
Boris Feld -
r33399:bab1f324 default
parent child Browse files
Show More
@@ -1,1095 +1,1097 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 configitem('bugzilla', 'fixregexp',
341 default=lambda: bugzilla._default_fix_re,
342 )
340 343
341 344 class bzaccess(object):
342 345 '''Base class for access to Bugzilla.'''
343 346
344 347 def __init__(self, ui):
345 348 self.ui = ui
346 349 usermap = self.ui.config('bugzilla', 'usermap')
347 350 if usermap:
348 351 self.ui.readconfig(usermap, sections=['usermap'])
349 352
350 353 def map_committer(self, user):
351 354 '''map name of committer to Bugzilla user name.'''
352 355 for committer, bzuser in self.ui.configitems('usermap'):
353 356 if committer.lower() == user.lower():
354 357 return bzuser
355 358 return user
356 359
357 360 # Methods to be implemented by access classes.
358 361 #
359 362 # 'bugs' is a dict keyed on bug id, where values are a dict holding
360 363 # updates to bug state. Recognized dict keys are:
361 364 #
362 365 # 'hours': Value, float containing work hours to be updated.
363 366 # 'fix': If key present, bug is to be marked fixed. Value ignored.
364 367
365 368 def filter_real_bug_ids(self, bugs):
366 369 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
367 370 pass
368 371
369 372 def filter_cset_known_bug_ids(self, node, bugs):
370 373 '''remove bug IDs where node occurs in comment text from bugs.'''
371 374 pass
372 375
373 376 def updatebug(self, bugid, newstate, text, committer):
374 377 '''update the specified bug. Add comment text and set new states.
375 378
376 379 If possible add the comment as being from the committer of
377 380 the changeset. Otherwise use the default Bugzilla user.
378 381 '''
379 382 pass
380 383
381 384 def notify(self, bugs, committer):
382 385 '''Force sending of Bugzilla notification emails.
383 386
384 387 Only required if the access method does not trigger notification
385 388 emails automatically.
386 389 '''
387 390 pass
388 391
389 392 # Bugzilla via direct access to MySQL database.
390 393 class bzmysql(bzaccess):
391 394 '''Support for direct MySQL access to Bugzilla.
392 395
393 396 The earliest Bugzilla version this is tested with is version 2.16.
394 397
395 398 If your Bugzilla is version 3.4 or above, you are strongly
396 399 recommended to use the XMLRPC access method instead.
397 400 '''
398 401
399 402 @staticmethod
400 403 def sql_buglist(ids):
401 404 '''return SQL-friendly list of bug ids'''
402 405 return '(' + ','.join(map(str, ids)) + ')'
403 406
404 407 _MySQLdb = None
405 408
406 409 def __init__(self, ui):
407 410 try:
408 411 import MySQLdb as mysql
409 412 bzmysql._MySQLdb = mysql
410 413 except ImportError as err:
411 414 raise error.Abort(_('python mysql support not available: %s') % err)
412 415
413 416 bzaccess.__init__(self, ui)
414 417
415 418 host = self.ui.config('bugzilla', 'host', 'localhost')
416 419 user = self.ui.config('bugzilla', 'user', 'bugs')
417 420 passwd = self.ui.config('bugzilla', 'password')
418 421 db = self.ui.config('bugzilla', 'db')
419 422 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
420 423 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
421 424 (host, db, user, '*' * len(passwd)))
422 425 self.conn = bzmysql._MySQLdb.connect(host=host,
423 426 user=user, passwd=passwd,
424 427 db=db,
425 428 connect_timeout=timeout)
426 429 self.cursor = self.conn.cursor()
427 430 self.longdesc_id = self.get_longdesc_id()
428 431 self.user_ids = {}
429 432 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
430 433
431 434 def run(self, *args, **kwargs):
432 435 '''run a query.'''
433 436 self.ui.note(_('query: %s %s\n') % (args, kwargs))
434 437 try:
435 438 self.cursor.execute(*args, **kwargs)
436 439 except bzmysql._MySQLdb.MySQLError:
437 440 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
438 441 raise
439 442
440 443 def get_longdesc_id(self):
441 444 '''get identity of longdesc field'''
442 445 self.run('select fieldid from fielddefs where name = "longdesc"')
443 446 ids = self.cursor.fetchall()
444 447 if len(ids) != 1:
445 448 raise error.Abort(_('unknown database schema'))
446 449 return ids[0][0]
447 450
448 451 def filter_real_bug_ids(self, bugs):
449 452 '''filter not-existing bugs from set.'''
450 453 self.run('select bug_id from bugs where bug_id in %s' %
451 454 bzmysql.sql_buglist(bugs.keys()))
452 455 existing = [id for (id,) in self.cursor.fetchall()]
453 456 for id in bugs.keys():
454 457 if id not in existing:
455 458 self.ui.status(_('bug %d does not exist\n') % id)
456 459 del bugs[id]
457 460
458 461 def filter_cset_known_bug_ids(self, node, bugs):
459 462 '''filter bug ids that already refer to this changeset from set.'''
460 463 self.run('''select bug_id from longdescs where
461 464 bug_id in %s and thetext like "%%%s%%"''' %
462 465 (bzmysql.sql_buglist(bugs.keys()), short(node)))
463 466 for (id,) in self.cursor.fetchall():
464 467 self.ui.status(_('bug %d already knows about changeset %s\n') %
465 468 (id, short(node)))
466 469 del bugs[id]
467 470
468 471 def notify(self, bugs, committer):
469 472 '''tell bugzilla to send mail.'''
470 473 self.ui.status(_('telling bugzilla to send mail:\n'))
471 474 (user, userid) = self.get_bugzilla_user(committer)
472 475 for id in bugs.keys():
473 476 self.ui.status(_(' bug %s\n') % id)
474 477 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
475 478 bzdir = self.ui.config('bugzilla', 'bzdir')
476 479 try:
477 480 # Backwards-compatible with old notify string, which
478 481 # took one string. This will throw with a new format
479 482 # string.
480 483 cmd = cmdfmt % id
481 484 except TypeError:
482 485 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
483 486 self.ui.note(_('running notify command %s\n') % cmd)
484 487 fp = util.popen('(%s) 2>&1' % cmd)
485 488 out = fp.read()
486 489 ret = fp.close()
487 490 if ret:
488 491 self.ui.warn(out)
489 492 raise error.Abort(_('bugzilla notify command %s') %
490 493 util.explainexit(ret)[0])
491 494 self.ui.status(_('done\n'))
492 495
493 496 def get_user_id(self, user):
494 497 '''look up numeric bugzilla user id.'''
495 498 try:
496 499 return self.user_ids[user]
497 500 except KeyError:
498 501 try:
499 502 userid = int(user)
500 503 except ValueError:
501 504 self.ui.note(_('looking up user %s\n') % user)
502 505 self.run('''select userid from profiles
503 506 where login_name like %s''', user)
504 507 all = self.cursor.fetchall()
505 508 if len(all) != 1:
506 509 raise KeyError(user)
507 510 userid = int(all[0][0])
508 511 self.user_ids[user] = userid
509 512 return userid
510 513
511 514 def get_bugzilla_user(self, committer):
512 515 '''See if committer is a registered bugzilla user. Return
513 516 bugzilla username and userid if so. If not, return default
514 517 bugzilla username and userid.'''
515 518 user = self.map_committer(committer)
516 519 try:
517 520 userid = self.get_user_id(user)
518 521 except KeyError:
519 522 try:
520 523 defaultuser = self.ui.config('bugzilla', 'bzuser')
521 524 if not defaultuser:
522 525 raise error.Abort(_('cannot find bugzilla user id for %s') %
523 526 user)
524 527 userid = self.get_user_id(defaultuser)
525 528 user = defaultuser
526 529 except KeyError:
527 530 raise error.Abort(_('cannot find bugzilla user id for %s or %s')
528 531 % (user, defaultuser))
529 532 return (user, userid)
530 533
531 534 def updatebug(self, bugid, newstate, text, committer):
532 535 '''update bug state with comment text.
533 536
534 537 Try adding comment as committer of changeset, otherwise as
535 538 default bugzilla user.'''
536 539 if len(newstate) > 0:
537 540 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
538 541
539 542 (user, userid) = self.get_bugzilla_user(committer)
540 543 now = time.strftime('%Y-%m-%d %H:%M:%S')
541 544 self.run('''insert into longdescs
542 545 (bug_id, who, bug_when, thetext)
543 546 values (%s, %s, %s, %s)''',
544 547 (bugid, userid, now, text))
545 548 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
546 549 values (%s, %s, %s, %s)''',
547 550 (bugid, userid, now, self.longdesc_id))
548 551 self.conn.commit()
549 552
550 553 class bzmysql_2_18(bzmysql):
551 554 '''support for bugzilla 2.18 series.'''
552 555
553 556 def __init__(self, ui):
554 557 bzmysql.__init__(self, ui)
555 558 self.default_notify = \
556 559 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
557 560
558 561 class bzmysql_3_0(bzmysql_2_18):
559 562 '''support for bugzilla 3.0 series.'''
560 563
561 564 def __init__(self, ui):
562 565 bzmysql_2_18.__init__(self, ui)
563 566
564 567 def get_longdesc_id(self):
565 568 '''get identity of longdesc field'''
566 569 self.run('select id from fielddefs where name = "longdesc"')
567 570 ids = self.cursor.fetchall()
568 571 if len(ids) != 1:
569 572 raise error.Abort(_('unknown database schema'))
570 573 return ids[0][0]
571 574
572 575 # Bugzilla via XMLRPC interface.
573 576
574 577 class cookietransportrequest(object):
575 578 """A Transport request method that retains cookies over its lifetime.
576 579
577 580 The regular xmlrpclib transports ignore cookies. Which causes
578 581 a bit of a problem when you need a cookie-based login, as with
579 582 the Bugzilla XMLRPC interface prior to 4.4.3.
580 583
581 584 So this is a helper for defining a Transport which looks for
582 585 cookies being set in responses and saves them to add to all future
583 586 requests.
584 587 """
585 588
586 589 # Inspiration drawn from
587 590 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
588 591 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
589 592
590 593 cookies = []
591 594 def send_cookies(self, connection):
592 595 if self.cookies:
593 596 for cookie in self.cookies:
594 597 connection.putheader("Cookie", cookie)
595 598
596 599 def request(self, host, handler, request_body, verbose=0):
597 600 self.verbose = verbose
598 601 self.accept_gzip_encoding = False
599 602
600 603 # issue XML-RPC request
601 604 h = self.make_connection(host)
602 605 if verbose:
603 606 h.set_debuglevel(1)
604 607
605 608 self.send_request(h, handler, request_body)
606 609 self.send_host(h, host)
607 610 self.send_cookies(h)
608 611 self.send_user_agent(h)
609 612 self.send_content(h, request_body)
610 613
611 614 # Deal with differences between Python 2.6 and 2.7.
612 615 # In the former h is a HTTP(S). In the latter it's a
613 616 # HTTP(S)Connection. Luckily, the 2.6 implementation of
614 617 # HTTP(S) has an underlying HTTP(S)Connection, so extract
615 618 # that and use it.
616 619 try:
617 620 response = h.getresponse()
618 621 except AttributeError:
619 622 response = h._conn.getresponse()
620 623
621 624 # Add any cookie definitions to our list.
622 625 for header in response.msg.getallmatchingheaders("Set-Cookie"):
623 626 val = header.split(": ", 1)[1]
624 627 cookie = val.split(";", 1)[0]
625 628 self.cookies.append(cookie)
626 629
627 630 if response.status != 200:
628 631 raise xmlrpclib.ProtocolError(host + handler, response.status,
629 632 response.reason, response.msg.headers)
630 633
631 634 payload = response.read()
632 635 parser, unmarshaller = self.getparser()
633 636 parser.feed(payload)
634 637 parser.close()
635 638
636 639 return unmarshaller.close()
637 640
638 641 # The explicit calls to the underlying xmlrpclib __init__() methods are
639 642 # necessary. The xmlrpclib.Transport classes are old-style classes, and
640 643 # it turns out their __init__() doesn't get called when doing multiple
641 644 # inheritance with a new-style class.
642 645 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
643 646 def __init__(self, use_datetime=0):
644 647 if util.safehasattr(xmlrpclib.Transport, "__init__"):
645 648 xmlrpclib.Transport.__init__(self, use_datetime)
646 649
647 650 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
648 651 def __init__(self, use_datetime=0):
649 652 if util.safehasattr(xmlrpclib.Transport, "__init__"):
650 653 xmlrpclib.SafeTransport.__init__(self, use_datetime)
651 654
652 655 class bzxmlrpc(bzaccess):
653 656 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
654 657
655 658 Requires a minimum Bugzilla version 3.4.
656 659 """
657 660
658 661 def __init__(self, ui):
659 662 bzaccess.__init__(self, ui)
660 663
661 664 bzweb = self.ui.config('bugzilla', 'bzurl')
662 665 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
663 666
664 667 user = self.ui.config('bugzilla', 'user', 'bugs')
665 668 passwd = self.ui.config('bugzilla', 'password')
666 669
667 670 self.fixstatus = self.ui.config('bugzilla', 'fixstatus', 'RESOLVED')
668 671 self.fixresolution = self.ui.config('bugzilla', 'fixresolution',
669 672 'FIXED')
670 673
671 674 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
672 675 ver = self.bzproxy.Bugzilla.version()['version'].split('.')
673 676 self.bzvermajor = int(ver[0])
674 677 self.bzverminor = int(ver[1])
675 678 login = self.bzproxy.User.login({'login': user, 'password': passwd,
676 679 'restrict_login': True})
677 680 self.bztoken = login.get('token', '')
678 681
679 682 def transport(self, uri):
680 683 if util.urlreq.urlparse(uri, "http")[0] == "https":
681 684 return cookiesafetransport()
682 685 else:
683 686 return cookietransport()
684 687
685 688 def get_bug_comments(self, id):
686 689 """Return a string with all comment text for a bug."""
687 690 c = self.bzproxy.Bug.comments({'ids': [id],
688 691 'include_fields': ['text'],
689 692 'token': self.bztoken})
690 693 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
691 694
692 695 def filter_real_bug_ids(self, bugs):
693 696 probe = self.bzproxy.Bug.get({'ids': sorted(bugs.keys()),
694 697 'include_fields': [],
695 698 'permissive': True,
696 699 'token': self.bztoken,
697 700 })
698 701 for badbug in probe['faults']:
699 702 id = badbug['id']
700 703 self.ui.status(_('bug %d does not exist\n') % id)
701 704 del bugs[id]
702 705
703 706 def filter_cset_known_bug_ids(self, node, bugs):
704 707 for id in sorted(bugs.keys()):
705 708 if self.get_bug_comments(id).find(short(node)) != -1:
706 709 self.ui.status(_('bug %d already knows about changeset %s\n') %
707 710 (id, short(node)))
708 711 del bugs[id]
709 712
710 713 def updatebug(self, bugid, newstate, text, committer):
711 714 args = {}
712 715 if 'hours' in newstate:
713 716 args['work_time'] = newstate['hours']
714 717
715 718 if self.bzvermajor >= 4:
716 719 args['ids'] = [bugid]
717 720 args['comment'] = {'body' : text}
718 721 if 'fix' in newstate:
719 722 args['status'] = self.fixstatus
720 723 args['resolution'] = self.fixresolution
721 724 args['token'] = self.bztoken
722 725 self.bzproxy.Bug.update(args)
723 726 else:
724 727 if 'fix' in newstate:
725 728 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
726 729 "to mark bugs fixed\n"))
727 730 args['id'] = bugid
728 731 args['comment'] = text
729 732 self.bzproxy.Bug.add_comment(args)
730 733
731 734 class bzxmlrpcemail(bzxmlrpc):
732 735 """Read data from Bugzilla via XMLRPC, send updates via email.
733 736
734 737 Advantages of sending updates via email:
735 738 1. Comments can be added as any user, not just logged in user.
736 739 2. Bug statuses or other fields not accessible via XMLRPC can
737 740 potentially be updated.
738 741
739 742 There is no XMLRPC function to change bug status before Bugzilla
740 743 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
741 744 But bugs can be marked fixed via email from 3.4 onwards.
742 745 """
743 746
744 747 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
745 748 # in-email fields are specified as '@<fieldname> = <value>'. In
746 749 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
747 750 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
748 751 # compatibility, but rather than rely on this use the new format for
749 752 # 4.0 onwards.
750 753
751 754 def __init__(self, ui):
752 755 bzxmlrpc.__init__(self, ui)
753 756
754 757 self.bzemail = self.ui.config('bugzilla', 'bzemail')
755 758 if not self.bzemail:
756 759 raise error.Abort(_("configuration 'bzemail' missing"))
757 760 mail.validateconfig(self.ui)
758 761
759 762 def makecommandline(self, fieldname, value):
760 763 if self.bzvermajor >= 4:
761 764 return "@%s %s" % (fieldname, str(value))
762 765 else:
763 766 if fieldname == "id":
764 767 fieldname = "bug_id"
765 768 return "@%s = %s" % (fieldname, str(value))
766 769
767 770 def send_bug_modify_email(self, bugid, commands, comment, committer):
768 771 '''send modification message to Bugzilla bug via email.
769 772
770 773 The message format is documented in the Bugzilla email_in.pl
771 774 specification. commands is a list of command lines, comment is the
772 775 comment text.
773 776
774 777 To stop users from crafting commit comments with
775 778 Bugzilla commands, specify the bug ID via the message body, rather
776 779 than the subject line, and leave a blank line after it.
777 780 '''
778 781 user = self.map_committer(committer)
779 782 matches = self.bzproxy.User.get({'match': [user],
780 783 'token': self.bztoken})
781 784 if not matches['users']:
782 785 user = self.ui.config('bugzilla', 'user', 'bugs')
783 786 matches = self.bzproxy.User.get({'match': [user],
784 787 'token': self.bztoken})
785 788 if not matches['users']:
786 789 raise error.Abort(_("default bugzilla user %s email not found")
787 790 % user)
788 791 user = matches['users'][0]['email']
789 792 commands.append(self.makecommandline("id", bugid))
790 793
791 794 text = "\n".join(commands) + "\n\n" + comment
792 795
793 796 _charsets = mail._charsets(self.ui)
794 797 user = mail.addressencode(self.ui, user, _charsets)
795 798 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
796 799 msg = mail.mimeencode(self.ui, text, _charsets)
797 800 msg['From'] = user
798 801 msg['To'] = bzemail
799 802 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
800 803 sendmail = mail.connect(self.ui)
801 804 sendmail(user, bzemail, msg.as_string())
802 805
803 806 def updatebug(self, bugid, newstate, text, committer):
804 807 cmds = []
805 808 if 'hours' in newstate:
806 809 cmds.append(self.makecommandline("work_time", newstate['hours']))
807 810 if 'fix' in newstate:
808 811 cmds.append(self.makecommandline("bug_status", self.fixstatus))
809 812 cmds.append(self.makecommandline("resolution", self.fixresolution))
810 813 self.send_bug_modify_email(bugid, cmds, text, committer)
811 814
812 815 class NotFound(LookupError):
813 816 pass
814 817
815 818 class bzrestapi(bzaccess):
816 819 """Read and write bugzilla data using the REST API available since
817 820 Bugzilla 5.0.
818 821 """
819 822 def __init__(self, ui):
820 823 bzaccess.__init__(self, ui)
821 824 bz = self.ui.config('bugzilla', 'bzurl')
822 825 self.bzroot = '/'.join([bz, 'rest'])
823 826 self.apikey = self.ui.config('bugzilla', 'apikey')
824 827 self.user = self.ui.config('bugzilla', 'user', 'bugs')
825 828 self.passwd = self.ui.config('bugzilla', 'password')
826 829 self.fixstatus = self.ui.config('bugzilla', 'fixstatus', 'RESOLVED')
827 830 self.fixresolution = self.ui.config('bugzilla', 'fixresolution',
828 831 'FIXED')
829 832
830 833 def apiurl(self, targets, include_fields=None):
831 834 url = '/'.join([self.bzroot] + [str(t) for t in targets])
832 835 qv = {}
833 836 if self.apikey:
834 837 qv['api_key'] = self.apikey
835 838 elif self.user and self.passwd:
836 839 qv['login'] = self.user
837 840 qv['password'] = self.passwd
838 841 if include_fields:
839 842 qv['include_fields'] = include_fields
840 843 if qv:
841 844 url = '%s?%s' % (url, util.urlreq.urlencode(qv))
842 845 return url
843 846
844 847 def _fetch(self, burl):
845 848 try:
846 849 resp = url.open(self.ui, burl)
847 850 return json.loads(resp.read())
848 851 except util.urlerr.httperror as inst:
849 852 if inst.code == 401:
850 853 raise error.Abort(_('authorization failed'))
851 854 if inst.code == 404:
852 855 raise NotFound()
853 856 else:
854 857 raise
855 858
856 859 def _submit(self, burl, data, method='POST'):
857 860 data = json.dumps(data)
858 861 if method == 'PUT':
859 862 class putrequest(util.urlreq.request):
860 863 def get_method(self):
861 864 return 'PUT'
862 865 request_type = putrequest
863 866 else:
864 867 request_type = util.urlreq.request
865 868 req = request_type(burl, data,
866 869 {'Content-Type': 'application/json'})
867 870 try:
868 871 resp = url.opener(self.ui).open(req)
869 872 return json.loads(resp.read())
870 873 except util.urlerr.httperror as inst:
871 874 if inst.code == 401:
872 875 raise error.Abort(_('authorization failed'))
873 876 if inst.code == 404:
874 877 raise NotFound()
875 878 else:
876 879 raise
877 880
878 881 def filter_real_bug_ids(self, bugs):
879 882 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
880 883 badbugs = set()
881 884 for bugid in bugs:
882 885 burl = self.apiurl(('bug', bugid), include_fields='status')
883 886 try:
884 887 self._fetch(burl)
885 888 except NotFound:
886 889 badbugs.add(bugid)
887 890 for bugid in badbugs:
888 891 del bugs[bugid]
889 892
890 893 def filter_cset_known_bug_ids(self, node, bugs):
891 894 '''remove bug IDs where node occurs in comment text from bugs.'''
892 895 sn = short(node)
893 896 for bugid in bugs.keys():
894 897 burl = self.apiurl(('bug', bugid, 'comment'), include_fields='text')
895 898 result = self._fetch(burl)
896 899 comments = result['bugs'][str(bugid)]['comments']
897 900 if any(sn in c['text'] for c in comments):
898 901 self.ui.status(_('bug %d already knows about changeset %s\n') %
899 902 (bugid, sn))
900 903 del bugs[bugid]
901 904
902 905 def updatebug(self, bugid, newstate, text, committer):
903 906 '''update the specified bug. Add comment text and set new states.
904 907
905 908 If possible add the comment as being from the committer of
906 909 the changeset. Otherwise use the default Bugzilla user.
907 910 '''
908 911 bugmod = {}
909 912 if 'hours' in newstate:
910 913 bugmod['work_time'] = newstate['hours']
911 914 if 'fix' in newstate:
912 915 bugmod['status'] = self.fixstatus
913 916 bugmod['resolution'] = self.fixresolution
914 917 if bugmod:
915 918 # if we have to change the bugs state do it here
916 919 bugmod['comment'] = {
917 920 'comment': text,
918 921 'is_private': False,
919 922 'is_markdown': False,
920 923 }
921 924 burl = self.apiurl(('bug', bugid))
922 925 self._submit(burl, bugmod, method='PUT')
923 926 self.ui.debug('updated bug %s\n' % bugid)
924 927 else:
925 928 burl = self.apiurl(('bug', bugid, 'comment'))
926 929 self._submit(burl, {
927 930 'comment': text,
928 931 'is_private': False,
929 932 'is_markdown': False,
930 933 })
931 934 self.ui.debug('added comment to bug %s\n' % bugid)
932 935
933 936 def notify(self, bugs, committer):
934 937 '''Force sending of Bugzilla notification emails.
935 938
936 939 Only required if the access method does not trigger notification
937 940 emails automatically.
938 941 '''
939 942 pass
940 943
941 944 class bugzilla(object):
942 945 # supported versions of bugzilla. different versions have
943 946 # different schemas.
944 947 _versions = {
945 948 '2.16': bzmysql,
946 949 '2.18': bzmysql_2_18,
947 950 '3.0': bzmysql_3_0,
948 951 'xmlrpc': bzxmlrpc,
949 952 'xmlrpc+email': bzxmlrpcemail,
950 953 'restapi': bzrestapi,
951 954 }
952 955
953 956 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
954 957 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
955 958 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
956 959
957 960 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
958 961 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
959 962 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
960 963 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
961 964
962 965 def __init__(self, ui, repo):
963 966 self.ui = ui
964 967 self.repo = repo
965 968
966 969 bzversion = self.ui.config('bugzilla', 'version')
967 970 try:
968 971 bzclass = bugzilla._versions[bzversion]
969 972 except KeyError:
970 973 raise error.Abort(_('bugzilla version %s not supported') %
971 974 bzversion)
972 975 self.bzdriver = bzclass(self.ui)
973 976
974 977 self.bug_re = re.compile(
975 978 self.ui.config('bugzilla', 'regexp',
976 979 bugzilla._default_bug_re), re.IGNORECASE)
977 980 self.fix_re = re.compile(
978 self.ui.config('bugzilla', 'fixregexp',
979 bugzilla._default_fix_re), re.IGNORECASE)
981 self.ui.config('bugzilla', 'fixregexp'), re.IGNORECASE)
980 982 self.split_re = re.compile(r'\D+')
981 983
982 984 def find_bugs(self, ctx):
983 985 '''return bugs dictionary created from commit comment.
984 986
985 987 Extract bug info from changeset comments. Filter out any that are
986 988 not known to Bugzilla, and any that already have a reference to
987 989 the given changeset in their comments.
988 990 '''
989 991 start = 0
990 992 hours = 0.0
991 993 bugs = {}
992 994 bugmatch = self.bug_re.search(ctx.description(), start)
993 995 fixmatch = self.fix_re.search(ctx.description(), start)
994 996 while True:
995 997 bugattribs = {}
996 998 if not bugmatch and not fixmatch:
997 999 break
998 1000 if not bugmatch:
999 1001 m = fixmatch
1000 1002 elif not fixmatch:
1001 1003 m = bugmatch
1002 1004 else:
1003 1005 if bugmatch.start() < fixmatch.start():
1004 1006 m = bugmatch
1005 1007 else:
1006 1008 m = fixmatch
1007 1009 start = m.end()
1008 1010 if m is bugmatch:
1009 1011 bugmatch = self.bug_re.search(ctx.description(), start)
1010 1012 if 'fix' in bugattribs:
1011 1013 del bugattribs['fix']
1012 1014 else:
1013 1015 fixmatch = self.fix_re.search(ctx.description(), start)
1014 1016 bugattribs['fix'] = None
1015 1017
1016 1018 try:
1017 1019 ids = m.group('ids')
1018 1020 except IndexError:
1019 1021 ids = m.group(1)
1020 1022 try:
1021 1023 hours = float(m.group('hours'))
1022 1024 bugattribs['hours'] = hours
1023 1025 except IndexError:
1024 1026 pass
1025 1027 except TypeError:
1026 1028 pass
1027 1029 except ValueError:
1028 1030 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
1029 1031
1030 1032 for id in self.split_re.split(ids):
1031 1033 if not id:
1032 1034 continue
1033 1035 bugs[int(id)] = bugattribs
1034 1036 if bugs:
1035 1037 self.bzdriver.filter_real_bug_ids(bugs)
1036 1038 if bugs:
1037 1039 self.bzdriver.filter_cset_known_bug_ids(ctx.node(), bugs)
1038 1040 return bugs
1039 1041
1040 1042 def update(self, bugid, newstate, ctx):
1041 1043 '''update bugzilla bug with reference to changeset.'''
1042 1044
1043 1045 def webroot(root):
1044 1046 '''strip leading prefix of repo root and turn into
1045 1047 url-safe path.'''
1046 1048 count = int(self.ui.config('bugzilla', 'strip', 0))
1047 1049 root = util.pconvert(root)
1048 1050 while count > 0:
1049 1051 c = root.find('/')
1050 1052 if c == -1:
1051 1053 break
1052 1054 root = root[c + 1:]
1053 1055 count -= 1
1054 1056 return root
1055 1057
1056 1058 mapfile = None
1057 1059 tmpl = self.ui.config('bugzilla', 'template')
1058 1060 if not tmpl:
1059 1061 mapfile = self.ui.config('bugzilla', 'style')
1060 1062 if not mapfile and not tmpl:
1061 1063 tmpl = _('changeset {node|short} in repo {root} refers '
1062 1064 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
1063 1065 spec = cmdutil.logtemplatespec(tmpl, mapfile)
1064 1066 t = cmdutil.changeset_templater(self.ui, self.repo, spec,
1065 1067 False, None, False)
1066 1068 self.ui.pushbuffer()
1067 1069 t.show(ctx, changes=ctx.changeset(),
1068 1070 bug=str(bugid),
1069 1071 hgweb=self.ui.config('web', 'baseurl'),
1070 1072 root=self.repo.root,
1071 1073 webroot=webroot(self.repo.root))
1072 1074 data = self.ui.popbuffer()
1073 1075 self.bzdriver.updatebug(bugid, newstate, data, util.email(ctx.user()))
1074 1076
1075 1077 def notify(self, bugs, committer):
1076 1078 '''ensure Bugzilla users are notified of bug change.'''
1077 1079 self.bzdriver.notify(bugs, committer)
1078 1080
1079 1081 def hook(ui, repo, hooktype, node=None, **kwargs):
1080 1082 '''add comment to bugzilla for each changeset that refers to a
1081 1083 bugzilla bug id. only add a comment once per bug, so same change
1082 1084 seen multiple times does not fill bug with duplicate data.'''
1083 1085 if node is None:
1084 1086 raise error.Abort(_('hook type %s does not pass a changeset id') %
1085 1087 hooktype)
1086 1088 try:
1087 1089 bz = bugzilla(ui, repo)
1088 1090 ctx = repo[node]
1089 1091 bugs = bz.find_bugs(ctx)
1090 1092 if bugs:
1091 1093 for bug in bugs:
1092 1094 bz.update(bug, bugs[bug], ctx)
1093 1095 bz.notify(bugs, util.email(ctx.user()))
1094 1096 except Exception as e:
1095 1097 raise error.Abort(_('Bugzilla error: %s') % e)
General Comments 0
You need to be logged in to leave comments. Login now