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