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