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