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