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