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