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