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