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