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