##// END OF EJS Templates
bugzilla: stop bugs always being marked as fixed in xmlrpc (issue3484)...
Jim Hague -
r16876:fdc87904 stable
parent child Browse files
Show More
@@ -1,912 +1,913
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 # Copyright 2011-2 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 Three basic modes of access to Bugzilla are provided:
19 19
20 20 1. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
21 21
22 22 2. Check data via the Bugzilla XMLRPC interface and submit bug change
23 23 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
24 24
25 25 3. Writing directly to the Bugzilla database. Only Bugzilla installations
26 26 using MySQL are supported. Requires Python MySQLdb.
27 27
28 28 Writing directly to the database is susceptible to schema changes, and
29 29 relies on a Bugzilla contrib script to send out bug change
30 30 notification emails. This script runs as the user running Mercurial,
31 31 must be run on the host with the Bugzilla install, and requires
32 32 permission to read Bugzilla configuration details and the necessary
33 33 MySQL user and password to have full access rights to the Bugzilla
34 34 database. For these reasons this access mode is now considered
35 35 deprecated, and will not be updated for new Bugzilla versions going
36 36 forward. Only adding comments is supported in this access mode.
37 37
38 38 Access via XMLRPC needs a Bugzilla username and password to be specified
39 39 in the configuration. Comments are added under that username. Since the
40 40 configuration must be readable by all Mercurial users, it is recommended
41 41 that the rights of that user are restricted in Bugzilla to the minimum
42 42 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
43 43
44 44 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
45 45 email to the Bugzilla email interface to submit comments to bugs.
46 46 The From: address in the email is set to the email address of the Mercurial
47 47 user, so the comment appears to come from the Mercurial user. In the event
48 48 that the Mercurial user email is not recognised by Bugzilla as a Bugzilla
49 49 user, the email associated with the Bugzilla username used to log into
50 50 Bugzilla is used instead as the source of the comment. Marking bugs fixed
51 51 works on all supported Bugzilla versions.
52 52
53 53 Configuration items common to all access modes:
54 54
55 55 bugzilla.version
56 56 This access type to use. Values recognised are:
57 57
58 58 :``xmlrpc``: Bugzilla XMLRPC interface.
59 59 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
60 60 :``3.0``: MySQL access, Bugzilla 3.0 and later.
61 61 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
62 62 including 3.0.
63 63 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
64 64 including 2.18.
65 65
66 66 bugzilla.regexp
67 67 Regular expression to match bug IDs for update in changeset commit message.
68 68 It must contain one "()" named group ``<ids>`` containing the bug
69 69 IDs separated by non-digit characters. It may also contain
70 70 a named group ``<hours>`` with a floating-point number giving the
71 71 hours worked on the bug. If no named groups are present, the first
72 72 "()" group is assumed to contain the bug IDs, and work time is not
73 73 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
74 74 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
75 75 variations thereof, followed by an hours number prefixed by ``h`` or
76 76 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
77 77
78 78 bugzilla.fixregexp
79 79 Regular expression to match bug IDs for marking fixed in changeset
80 80 commit message. This must contain a "()" named group ``<ids>` containing
81 81 the bug IDs separated by non-digit characters. It may also contain
82 82 a named group ``<hours>`` with a floating-point number giving the
83 83 hours worked on the bug. If no named groups are present, the first
84 84 "()" group is assumed to contain the bug IDs, and work time is not
85 85 updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``,
86 86 ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and
87 87 variations thereof, followed by an hours number prefixed by ``h`` or
88 88 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
89 89
90 90 bugzilla.fixstatus
91 91 The status to set a bug to when marking fixed. Default ``RESOLVED``.
92 92
93 93 bugzilla.fixresolution
94 94 The resolution to set a bug to when marking fixed. Default ``FIXED``.
95 95
96 96 bugzilla.style
97 97 The style file to use when formatting comments.
98 98
99 99 bugzilla.template
100 100 Template to use when formatting comments. Overrides style if
101 101 specified. In addition to the usual Mercurial keywords, the
102 102 extension specifies:
103 103
104 104 :``{bug}``: The Bugzilla bug ID.
105 105 :``{root}``: The full pathname of the Mercurial repository.
106 106 :``{webroot}``: Stripped pathname of the Mercurial repository.
107 107 :``{hgweb}``: Base URL for browsing Mercurial repositories.
108 108
109 109 Default ``changeset {node|short} in repo {root} refers to bug
110 110 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
111 111
112 112 bugzilla.strip
113 113 The number of path separator characters to strip from the front of
114 114 the Mercurial repository path (``{root}`` in templates) to produce
115 115 ``{webroot}``. For example, a repository with ``{root}``
116 116 ``/var/local/my-project`` with a strip of 2 gives a value for
117 117 ``{webroot}`` of ``my-project``. Default 0.
118 118
119 119 web.baseurl
120 120 Base URL for browsing Mercurial repositories. Referenced from
121 121 templates as ``{hgweb}``.
122 122
123 123 Configuration items common to XMLRPC+email and MySQL access modes:
124 124
125 125 bugzilla.usermap
126 126 Path of file containing Mercurial committer email to Bugzilla user email
127 127 mappings. If specified, the file should contain one mapping per
128 128 line::
129 129
130 130 committer = Bugzilla user
131 131
132 132 See also the ``[usermap]`` section.
133 133
134 134 The ``[usermap]`` section is used to specify mappings of Mercurial
135 135 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
136 136 Contains entries of the form ``committer = Bugzilla user``.
137 137
138 138 XMLRPC access mode configuration:
139 139
140 140 bugzilla.bzurl
141 141 The base URL for the Bugzilla installation.
142 142 Default ``http://localhost/bugzilla``.
143 143
144 144 bugzilla.user
145 145 The username to use to log into Bugzilla via XMLRPC. Default
146 146 ``bugs``.
147 147
148 148 bugzilla.password
149 149 The password for Bugzilla login.
150 150
151 151 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
152 152 and also:
153 153
154 154 bugzilla.bzemail
155 155 The Bugzilla email address.
156 156
157 157 In addition, the Mercurial email settings must be configured. See the
158 158 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
159 159
160 160 MySQL access mode configuration:
161 161
162 162 bugzilla.host
163 163 Hostname of the MySQL server holding the Bugzilla database.
164 164 Default ``localhost``.
165 165
166 166 bugzilla.db
167 167 Name of the Bugzilla database in MySQL. Default ``bugs``.
168 168
169 169 bugzilla.user
170 170 Username to use to access MySQL server. Default ``bugs``.
171 171
172 172 bugzilla.password
173 173 Password to use to access MySQL server.
174 174
175 175 bugzilla.timeout
176 176 Database connection timeout (seconds). Default 5.
177 177
178 178 bugzilla.bzuser
179 179 Fallback Bugzilla user name to record comments with, if changeset
180 180 committer cannot be found as a Bugzilla user.
181 181
182 182 bugzilla.bzdir
183 183 Bugzilla install directory. Used by default notify. Default
184 184 ``/var/www/html/bugzilla``.
185 185
186 186 bugzilla.notify
187 187 The command to run to get Bugzilla to send bug change notification
188 188 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
189 189 id) and ``user`` (committer bugzilla email). Default depends on
190 190 version; from 2.18 it is "cd %(bzdir)s && perl -T
191 191 contrib/sendbugmail.pl %(id)s %(user)s".
192 192
193 193 Activating the extension::
194 194
195 195 [extensions]
196 196 bugzilla =
197 197
198 198 [hooks]
199 199 # run bugzilla hook on every change pulled or pushed in here
200 200 incoming.bugzilla = python:hgext.bugzilla.hook
201 201
202 202 Example configurations:
203 203
204 204 XMLRPC example configuration. This uses the Bugzilla at
205 205 ``http://my-project.org/bugzilla``, logging in as user
206 206 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
207 207 collection of Mercurial repositories in ``/var/local/hg/repos/``,
208 208 with a web interface at ``http://my-project.org/hg``. ::
209 209
210 210 [bugzilla]
211 211 bzurl=http://my-project.org/bugzilla
212 212 user=bugmail@my-project.org
213 213 password=plugh
214 214 version=xmlrpc
215 215 template=Changeset {node|short} in {root|basename}.
216 216 {hgweb}/{webroot}/rev/{node|short}\\n
217 217 {desc}\\n
218 218 strip=5
219 219
220 220 [web]
221 221 baseurl=http://my-project.org/hg
222 222
223 223 XMLRPC+email example configuration. This uses the Bugzilla at
224 224 ``http://my-project.org/bugzilla``, logging in as user
225 225 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
226 226 collection of Mercurial repositories in ``/var/local/hg/repos/``,
227 227 with a web interface at ``http://my-project.org/hg``. Bug comments
228 228 are sent to the Bugzilla email address
229 229 ``bugzilla@my-project.org``. ::
230 230
231 231 [bugzilla]
232 232 bzurl=http://my-project.org/bugzilla
233 233 user=bugmail@my-project.org
234 234 password=plugh
235 235 version=xmlrpc
236 236 bzemail=bugzilla@my-project.org
237 237 template=Changeset {node|short} in {root|basename}.
238 238 {hgweb}/{webroot}/rev/{node|short}\\n
239 239 {desc}\\n
240 240 strip=5
241 241
242 242 [web]
243 243 baseurl=http://my-project.org/hg
244 244
245 245 [usermap]
246 246 user@emaildomain.com=user.name@bugzilladomain.com
247 247
248 248 MySQL example configuration. This has a local Bugzilla 3.2 installation
249 249 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
250 250 the Bugzilla database name is ``bugs`` and MySQL is
251 251 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
252 252 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
253 253 with a web interface at ``http://my-project.org/hg``. ::
254 254
255 255 [bugzilla]
256 256 host=localhost
257 257 password=XYZZY
258 258 version=3.0
259 259 bzuser=unknown@domain.com
260 260 bzdir=/opt/bugzilla-3.2
261 261 template=Changeset {node|short} in {root|basename}.
262 262 {hgweb}/{webroot}/rev/{node|short}\\n
263 263 {desc}\\n
264 264 strip=5
265 265
266 266 [web]
267 267 baseurl=http://my-project.org/hg
268 268
269 269 [usermap]
270 270 user@emaildomain.com=user.name@bugzilladomain.com
271 271
272 272 All the above add a comment to the Bugzilla bug record of the form::
273 273
274 274 Changeset 3b16791d6642 in repository-name.
275 275 http://my-project.org/hg/repository-name/rev/3b16791d6642
276 276
277 277 Changeset commit comment. Bug 1234.
278 278 '''
279 279
280 280 from mercurial.i18n import _
281 281 from mercurial.node import short
282 282 from mercurial import cmdutil, mail, templater, util
283 283 import re, time, urlparse, xmlrpclib
284 284
285 285 class bzaccess(object):
286 286 '''Base class for access to Bugzilla.'''
287 287
288 288 def __init__(self, ui):
289 289 self.ui = ui
290 290 usermap = self.ui.config('bugzilla', 'usermap')
291 291 if usermap:
292 292 self.ui.readconfig(usermap, sections=['usermap'])
293 293
294 294 def map_committer(self, user):
295 295 '''map name of committer to Bugzilla user name.'''
296 296 for committer, bzuser in self.ui.configitems('usermap'):
297 297 if committer.lower() == user.lower():
298 298 return bzuser
299 299 return user
300 300
301 301 # Methods to be implemented by access classes.
302 302 #
303 303 # 'bugs' is a dict keyed on bug id, where values are a dict holding
304 304 # updates to bug state. Recognised dict keys are:
305 305 #
306 306 # 'hours': Value, float containing work hours to be updated.
307 307 # 'fix': If key present, bug is to be marked fixed. Value ignored.
308 308
309 309 def filter_real_bug_ids(self, bugs):
310 310 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
311 311 pass
312 312
313 313 def filter_cset_known_bug_ids(self, node, bugs):
314 314 '''remove bug IDs where node occurs in comment text from bugs.'''
315 315 pass
316 316
317 317 def updatebug(self, bugid, newstate, text, committer):
318 318 '''update the specified bug. Add comment text and set new states.
319 319
320 320 If possible add the comment as being from the committer of
321 321 the changeset. Otherwise use the default Bugzilla user.
322 322 '''
323 323 pass
324 324
325 325 def notify(self, bugs, committer):
326 326 '''Force sending of Bugzilla notification emails.
327 327
328 328 Only required if the access method does not trigger notification
329 329 emails automatically.
330 330 '''
331 331 pass
332 332
333 333 # Bugzilla via direct access to MySQL database.
334 334 class bzmysql(bzaccess):
335 335 '''Support for direct MySQL access to Bugzilla.
336 336
337 337 The earliest Bugzilla version this is tested with is version 2.16.
338 338
339 339 If your Bugzilla is version 3.4 or above, you are strongly
340 340 recommended to use the XMLRPC access method instead.
341 341 '''
342 342
343 343 @staticmethod
344 344 def sql_buglist(ids):
345 345 '''return SQL-friendly list of bug ids'''
346 346 return '(' + ','.join(map(str, ids)) + ')'
347 347
348 348 _MySQLdb = None
349 349
350 350 def __init__(self, ui):
351 351 try:
352 352 import MySQLdb as mysql
353 353 bzmysql._MySQLdb = mysql
354 354 except ImportError, err:
355 355 raise util.Abort(_('python mysql support not available: %s') % err)
356 356
357 357 bzaccess.__init__(self, ui)
358 358
359 359 host = self.ui.config('bugzilla', 'host', 'localhost')
360 360 user = self.ui.config('bugzilla', 'user', 'bugs')
361 361 passwd = self.ui.config('bugzilla', 'password')
362 362 db = self.ui.config('bugzilla', 'db', 'bugs')
363 363 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
364 364 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
365 365 (host, db, user, '*' * len(passwd)))
366 366 self.conn = bzmysql._MySQLdb.connect(host=host,
367 367 user=user, passwd=passwd,
368 368 db=db,
369 369 connect_timeout=timeout)
370 370 self.cursor = self.conn.cursor()
371 371 self.longdesc_id = self.get_longdesc_id()
372 372 self.user_ids = {}
373 373 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
374 374
375 375 def run(self, *args, **kwargs):
376 376 '''run a query.'''
377 377 self.ui.note(_('query: %s %s\n') % (args, kwargs))
378 378 try:
379 379 self.cursor.execute(*args, **kwargs)
380 380 except bzmysql._MySQLdb.MySQLError:
381 381 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
382 382 raise
383 383
384 384 def get_longdesc_id(self):
385 385 '''get identity of longdesc field'''
386 386 self.run('select fieldid from fielddefs where name = "longdesc"')
387 387 ids = self.cursor.fetchall()
388 388 if len(ids) != 1:
389 389 raise util.Abort(_('unknown database schema'))
390 390 return ids[0][0]
391 391
392 392 def filter_real_bug_ids(self, bugs):
393 393 '''filter not-existing bugs from set.'''
394 394 self.run('select bug_id from bugs where bug_id in %s' %
395 395 bzmysql.sql_buglist(bugs.keys()))
396 396 existing = [id for (id,) in self.cursor.fetchall()]
397 397 for id in bugs.keys():
398 398 if id not in existing:
399 399 self.ui.status(_('bug %d does not exist\n') % id)
400 400 del bugs[id]
401 401
402 402 def filter_cset_known_bug_ids(self, node, bugs):
403 403 '''filter bug ids that already refer to this changeset from set.'''
404 404 self.run('''select bug_id from longdescs where
405 405 bug_id in %s and thetext like "%%%s%%"''' %
406 406 (bzmysql.sql_buglist(bugs.keys()), short(node)))
407 407 for (id,) in self.cursor.fetchall():
408 408 self.ui.status(_('bug %d already knows about changeset %s\n') %
409 409 (id, short(node)))
410 410 del bugs[id]
411 411
412 412 def notify(self, bugs, committer):
413 413 '''tell bugzilla to send mail.'''
414 414 self.ui.status(_('telling bugzilla to send mail:\n'))
415 415 (user, userid) = self.get_bugzilla_user(committer)
416 416 for id in bugs.keys():
417 417 self.ui.status(_(' bug %s\n') % id)
418 418 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
419 419 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
420 420 try:
421 421 # Backwards-compatible with old notify string, which
422 422 # took one string. This will throw with a new format
423 423 # string.
424 424 cmd = cmdfmt % id
425 425 except TypeError:
426 426 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
427 427 self.ui.note(_('running notify command %s\n') % cmd)
428 428 fp = util.popen('(%s) 2>&1' % cmd)
429 429 out = fp.read()
430 430 ret = fp.close()
431 431 if ret:
432 432 self.ui.warn(out)
433 433 raise util.Abort(_('bugzilla notify command %s') %
434 434 util.explainexit(ret)[0])
435 435 self.ui.status(_('done\n'))
436 436
437 437 def get_user_id(self, user):
438 438 '''look up numeric bugzilla user id.'''
439 439 try:
440 440 return self.user_ids[user]
441 441 except KeyError:
442 442 try:
443 443 userid = int(user)
444 444 except ValueError:
445 445 self.ui.note(_('looking up user %s\n') % user)
446 446 self.run('''select userid from profiles
447 447 where login_name like %s''', user)
448 448 all = self.cursor.fetchall()
449 449 if len(all) != 1:
450 450 raise KeyError(user)
451 451 userid = int(all[0][0])
452 452 self.user_ids[user] = userid
453 453 return userid
454 454
455 455 def get_bugzilla_user(self, committer):
456 456 '''See if committer is a registered bugzilla user. Return
457 457 bugzilla username and userid if so. If not, return default
458 458 bugzilla username and userid.'''
459 459 user = self.map_committer(committer)
460 460 try:
461 461 userid = self.get_user_id(user)
462 462 except KeyError:
463 463 try:
464 464 defaultuser = self.ui.config('bugzilla', 'bzuser')
465 465 if not defaultuser:
466 466 raise util.Abort(_('cannot find bugzilla user id for %s') %
467 467 user)
468 468 userid = self.get_user_id(defaultuser)
469 469 user = defaultuser
470 470 except KeyError:
471 471 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
472 472 (user, defaultuser))
473 473 return (user, userid)
474 474
475 475 def updatebug(self, bugid, newstate, text, committer):
476 476 '''update bug state with comment text.
477 477
478 478 Try adding comment as committer of changeset, otherwise as
479 479 default bugzilla user.'''
480 480 if len(newstate) > 0:
481 481 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
482 482
483 483 (user, userid) = self.get_bugzilla_user(committer)
484 484 now = time.strftime('%Y-%m-%d %H:%M:%S')
485 485 self.run('''insert into longdescs
486 486 (bug_id, who, bug_when, thetext)
487 487 values (%s, %s, %s, %s)''',
488 488 (bugid, userid, now, text))
489 489 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
490 490 values (%s, %s, %s, %s)''',
491 491 (bugid, userid, now, self.longdesc_id))
492 492 self.conn.commit()
493 493
494 494 class bzmysql_2_18(bzmysql):
495 495 '''support for bugzilla 2.18 series.'''
496 496
497 497 def __init__(self, ui):
498 498 bzmysql.__init__(self, ui)
499 499 self.default_notify = \
500 500 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
501 501
502 502 class bzmysql_3_0(bzmysql_2_18):
503 503 '''support for bugzilla 3.0 series.'''
504 504
505 505 def __init__(self, ui):
506 506 bzmysql_2_18.__init__(self, ui)
507 507
508 508 def get_longdesc_id(self):
509 509 '''get identity of longdesc field'''
510 510 self.run('select id from fielddefs where name = "longdesc"')
511 511 ids = self.cursor.fetchall()
512 512 if len(ids) != 1:
513 513 raise util.Abort(_('unknown database schema'))
514 514 return ids[0][0]
515 515
516 516 # Buzgilla via XMLRPC interface.
517 517
518 518 class cookietransportrequest(object):
519 519 """A Transport request method that retains cookies over its lifetime.
520 520
521 521 The regular xmlrpclib transports ignore cookies. Which causes
522 522 a bit of a problem when you need a cookie-based login, as with
523 523 the Bugzilla XMLRPC interface.
524 524
525 525 So this is a helper for defining a Transport which looks for
526 526 cookies being set in responses and saves them to add to all future
527 527 requests.
528 528 """
529 529
530 530 # Inspiration drawn from
531 531 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
532 532 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
533 533
534 534 cookies = []
535 535 def send_cookies(self, connection):
536 536 if self.cookies:
537 537 for cookie in self.cookies:
538 538 connection.putheader("Cookie", cookie)
539 539
540 540 def request(self, host, handler, request_body, verbose=0):
541 541 self.verbose = verbose
542 542 self.accept_gzip_encoding = False
543 543
544 544 # issue XML-RPC request
545 545 h = self.make_connection(host)
546 546 if verbose:
547 547 h.set_debuglevel(1)
548 548
549 549 self.send_request(h, handler, request_body)
550 550 self.send_host(h, host)
551 551 self.send_cookies(h)
552 552 self.send_user_agent(h)
553 553 self.send_content(h, request_body)
554 554
555 555 # Deal with differences between Python 2.4-2.6 and 2.7.
556 556 # In the former h is a HTTP(S). In the latter it's a
557 557 # HTTP(S)Connection. Luckily, the 2.4-2.6 implementation of
558 558 # HTTP(S) has an underlying HTTP(S)Connection, so extract
559 559 # that and use it.
560 560 try:
561 561 response = h.getresponse()
562 562 except AttributeError:
563 563 response = h._conn.getresponse()
564 564
565 565 # Add any cookie definitions to our list.
566 566 for header in response.msg.getallmatchingheaders("Set-Cookie"):
567 567 val = header.split(": ", 1)[1]
568 568 cookie = val.split(";", 1)[0]
569 569 self.cookies.append(cookie)
570 570
571 571 if response.status != 200:
572 572 raise xmlrpclib.ProtocolError(host + handler, response.status,
573 573 response.reason, response.msg.headers)
574 574
575 575 payload = response.read()
576 576 parser, unmarshaller = self.getparser()
577 577 parser.feed(payload)
578 578 parser.close()
579 579
580 580 return unmarshaller.close()
581 581
582 582 # The explicit calls to the underlying xmlrpclib __init__() methods are
583 583 # necessary. The xmlrpclib.Transport classes are old-style classes, and
584 584 # it turns out their __init__() doesn't get called when doing multiple
585 585 # inheritance with a new-style class.
586 586 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
587 587 def __init__(self, use_datetime=0):
588 588 if util.safehasattr(xmlrpclib.Transport, "__init__"):
589 589 xmlrpclib.Transport.__init__(self, use_datetime)
590 590
591 591 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
592 592 def __init__(self, use_datetime=0):
593 593 if util.safehasattr(xmlrpclib.Transport, "__init__"):
594 594 xmlrpclib.SafeTransport.__init__(self, use_datetime)
595 595
596 596 class bzxmlrpc(bzaccess):
597 597 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
598 598
599 599 Requires a minimum Bugzilla version 3.4.
600 600 """
601 601
602 602 def __init__(self, ui):
603 603 bzaccess.__init__(self, ui)
604 604
605 605 bzweb = self.ui.config('bugzilla', 'bzurl',
606 606 'http://localhost/bugzilla/')
607 607 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
608 608
609 609 user = self.ui.config('bugzilla', 'user', 'bugs')
610 610 passwd = self.ui.config('bugzilla', 'password')
611 611
612 612 self.fixstatus = self.ui.config('bugzilla', 'fixstatus', 'RESOLVED')
613 613 self.fixresolution = self.ui.config('bugzilla', 'fixresolution',
614 614 'FIXED')
615 615
616 616 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
617 617 ver = self.bzproxy.Bugzilla.version()['version'].split('.')
618 618 self.bzvermajor = int(ver[0])
619 619 self.bzverminor = int(ver[1])
620 620 self.bzproxy.User.login(dict(login=user, password=passwd))
621 621
622 622 def transport(self, uri):
623 623 if urlparse.urlparse(uri, "http")[0] == "https":
624 624 return cookiesafetransport()
625 625 else:
626 626 return cookietransport()
627 627
628 628 def get_bug_comments(self, id):
629 629 """Return a string with all comment text for a bug."""
630 630 c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
631 631 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
632 632
633 633 def filter_real_bug_ids(self, bugs):
634 634 probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
635 635 include_fields=[],
636 636 permissive=True))
637 637 for badbug in probe['faults']:
638 638 id = badbug['id']
639 639 self.ui.status(_('bug %d does not exist\n') % id)
640 640 del bugs[id]
641 641
642 642 def filter_cset_known_bug_ids(self, node, bugs):
643 643 for id in sorted(bugs.keys()):
644 644 if self.get_bug_comments(id).find(short(node)) != -1:
645 645 self.ui.status(_('bug %d already knows about changeset %s\n') %
646 646 (id, short(node)))
647 647 del bugs[id]
648 648
649 649 def updatebug(self, bugid, newstate, text, committer):
650 650 args = {}
651 651 if 'hours' in newstate:
652 652 args['work_time'] = newstate['hours']
653 653
654 654 if self.bzvermajor >= 4:
655 655 args['ids'] = [bugid]
656 656 args['comment'] = {'body' : text}
657 args['status'] = self.fixstatus
658 args['resolution'] = self.fixresolution
657 if 'fix' in newstate:
658 args['status'] = self.fixstatus
659 args['resolution'] = self.fixresolution
659 660 self.bzproxy.Bug.update(args)
660 661 else:
661 662 if 'fix' in newstate:
662 663 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
663 664 "to mark bugs fixed\n"))
664 665 args['id'] = bugid
665 666 args['comment'] = text
666 667 self.bzproxy.Bug.add_comment(args)
667 668
668 669 class bzxmlrpcemail(bzxmlrpc):
669 670 """Read data from Bugzilla via XMLRPC, send updates via email.
670 671
671 672 Advantages of sending updates via email:
672 673 1. Comments can be added as any user, not just logged in user.
673 674 2. Bug statuses or other fields not accessible via XMLRPC can
674 675 potentially be updated.
675 676
676 677 There is no XMLRPC function to change bug status before Bugzilla
677 678 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
678 679 But bugs can be marked fixed via email from 3.4 onwards.
679 680 """
680 681
681 682 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
682 683 # in-email fields are specified as '@<fieldname> = <value>'. In
683 684 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
684 685 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
685 686 # compatibility, but rather than rely on this use the new format for
686 687 # 4.0 onwards.
687 688
688 689 def __init__(self, ui):
689 690 bzxmlrpc.__init__(self, ui)
690 691
691 692 self.bzemail = self.ui.config('bugzilla', 'bzemail')
692 693 if not self.bzemail:
693 694 raise util.Abort(_("configuration 'bzemail' missing"))
694 695 mail.validateconfig(self.ui)
695 696
696 697 def makecommandline(self, fieldname, value):
697 698 if self.bzvermajor >= 4:
698 699 return "@%s %s" % (fieldname, str(value))
699 700 else:
700 701 if fieldname == "id":
701 702 fieldname = "bug_id"
702 703 return "@%s = %s" % (fieldname, str(value))
703 704
704 705 def send_bug_modify_email(self, bugid, commands, comment, committer):
705 706 '''send modification message to Bugzilla bug via email.
706 707
707 708 The message format is documented in the Bugzilla email_in.pl
708 709 specification. commands is a list of command lines, comment is the
709 710 comment text.
710 711
711 712 To stop users from crafting commit comments with
712 713 Bugzilla commands, specify the bug ID via the message body, rather
713 714 than the subject line, and leave a blank line after it.
714 715 '''
715 716 user = self.map_committer(committer)
716 717 matches = self.bzproxy.User.get(dict(match=[user]))
717 718 if not matches['users']:
718 719 user = self.ui.config('bugzilla', 'user', 'bugs')
719 720 matches = self.bzproxy.User.get(dict(match=[user]))
720 721 if not matches['users']:
721 722 raise util.Abort(_("default bugzilla user %s email not found") %
722 723 user)
723 724 user = matches['users'][0]['email']
724 725 commands.append(self.makecommandline("id", bugid))
725 726
726 727 text = "\n".join(commands) + "\n\n" + comment
727 728
728 729 _charsets = mail._charsets(self.ui)
729 730 user = mail.addressencode(self.ui, user, _charsets)
730 731 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
731 732 msg = mail.mimeencode(self.ui, text, _charsets)
732 733 msg['From'] = user
733 734 msg['To'] = bzemail
734 735 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
735 736 sendmail = mail.connect(self.ui)
736 737 sendmail(user, bzemail, msg.as_string())
737 738
738 739 def updatebug(self, bugid, newstate, text, committer):
739 740 cmds = []
740 741 if 'hours' in newstate:
741 742 cmds.append(self.makecommandline("work_time", newstate['hours']))
742 743 if 'fix' in newstate:
743 744 cmds.append(self.makecommandline("bug_status", self.fixstatus))
744 745 cmds.append(self.makecommandline("resolution", self.fixresolution))
745 746 self.send_bug_modify_email(bugid, cmds, text, committer)
746 747
747 748 class bugzilla(object):
748 749 # supported versions of bugzilla. different versions have
749 750 # different schemas.
750 751 _versions = {
751 752 '2.16': bzmysql,
752 753 '2.18': bzmysql_2_18,
753 754 '3.0': bzmysql_3_0,
754 755 'xmlrpc': bzxmlrpc,
755 756 'xmlrpc+email': bzxmlrpcemail
756 757 }
757 758
758 759 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
759 760 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
760 761 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
761 762
762 763 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
763 764 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
764 765 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
765 766 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
766 767
767 768 _bz = None
768 769
769 770 def __init__(self, ui, repo):
770 771 self.ui = ui
771 772 self.repo = repo
772 773
773 774 def bz(self):
774 775 '''return object that knows how to talk to bugzilla version in
775 776 use.'''
776 777
777 778 if bugzilla._bz is None:
778 779 bzversion = self.ui.config('bugzilla', 'version')
779 780 try:
780 781 bzclass = bugzilla._versions[bzversion]
781 782 except KeyError:
782 783 raise util.Abort(_('bugzilla version %s not supported') %
783 784 bzversion)
784 785 bugzilla._bz = bzclass(self.ui)
785 786 return bugzilla._bz
786 787
787 788 def __getattr__(self, key):
788 789 return getattr(self.bz(), key)
789 790
790 791 _bug_re = None
791 792 _fix_re = None
792 793 _split_re = None
793 794
794 795 def find_bugs(self, ctx):
795 796 '''return bugs dictionary created from commit comment.
796 797
797 798 Extract bug info from changeset comments. Filter out any that are
798 799 not known to Bugzilla, and any that already have a reference to
799 800 the given changeset in their comments.
800 801 '''
801 802 if bugzilla._bug_re is None:
802 803 bugzilla._bug_re = re.compile(
803 804 self.ui.config('bugzilla', 'regexp',
804 805 bugzilla._default_bug_re), re.IGNORECASE)
805 806 bugzilla._fix_re = re.compile(
806 807 self.ui.config('bugzilla', 'fixregexp',
807 808 bugzilla._default_fix_re), re.IGNORECASE)
808 809 bugzilla._split_re = re.compile(r'\D+')
809 810 start = 0
810 811 hours = 0.0
811 812 bugs = {}
812 813 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
813 814 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
814 815 while True:
815 816 bugattribs = {}
816 817 if not bugmatch and not fixmatch:
817 818 break
818 819 if not bugmatch:
819 820 m = fixmatch
820 821 elif not fixmatch:
821 822 m = bugmatch
822 823 else:
823 824 if bugmatch.start() < fixmatch.start():
824 825 m = bugmatch
825 826 else:
826 827 m = fixmatch
827 828 start = m.end()
828 829 if m is bugmatch:
829 830 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
830 831 if 'fix' in bugattribs:
831 832 del bugattribs['fix']
832 833 else:
833 834 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
834 835 bugattribs['fix'] = None
835 836
836 837 try:
837 838 ids = m.group('ids')
838 839 except IndexError:
839 840 ids = m.group(1)
840 841 try:
841 842 hours = float(m.group('hours'))
842 843 bugattribs['hours'] = hours
843 844 except IndexError:
844 845 pass
845 846 except TypeError:
846 847 pass
847 848 except ValueError:
848 849 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
849 850
850 851 for id in bugzilla._split_re.split(ids):
851 852 if not id:
852 853 continue
853 854 bugs[int(id)] = bugattribs
854 855 if bugs:
855 856 self.filter_real_bug_ids(bugs)
856 857 if bugs:
857 858 self.filter_cset_known_bug_ids(ctx.node(), bugs)
858 859 return bugs
859 860
860 861 def update(self, bugid, newstate, ctx):
861 862 '''update bugzilla bug with reference to changeset.'''
862 863
863 864 def webroot(root):
864 865 '''strip leading prefix of repo root and turn into
865 866 url-safe path.'''
866 867 count = int(self.ui.config('bugzilla', 'strip', 0))
867 868 root = util.pconvert(root)
868 869 while count > 0:
869 870 c = root.find('/')
870 871 if c == -1:
871 872 break
872 873 root = root[c + 1:]
873 874 count -= 1
874 875 return root
875 876
876 877 mapfile = self.ui.config('bugzilla', 'style')
877 878 tmpl = self.ui.config('bugzilla', 'template')
878 879 t = cmdutil.changeset_templater(self.ui, self.repo,
879 880 False, None, mapfile, False)
880 881 if not mapfile and not tmpl:
881 882 tmpl = _('changeset {node|short} in repo {root} refers '
882 883 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
883 884 if tmpl:
884 885 tmpl = templater.parsestring(tmpl, quoted=False)
885 886 t.use_template(tmpl)
886 887 self.ui.pushbuffer()
887 888 t.show(ctx, changes=ctx.changeset(),
888 889 bug=str(bugid),
889 890 hgweb=self.ui.config('web', 'baseurl'),
890 891 root=self.repo.root,
891 892 webroot=webroot(self.repo.root))
892 893 data = self.ui.popbuffer()
893 894 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
894 895
895 896 def hook(ui, repo, hooktype, node=None, **kwargs):
896 897 '''add comment to bugzilla for each changeset that refers to a
897 898 bugzilla bug id. only add a comment once per bug, so same change
898 899 seen multiple times does not fill bug with duplicate data.'''
899 900 if node is None:
900 901 raise util.Abort(_('hook type %s does not pass a changeset id') %
901 902 hooktype)
902 903 try:
903 904 bz = bugzilla(ui, repo)
904 905 ctx = repo[node]
905 906 bugs = bz.find_bugs(ctx)
906 907 if bugs:
907 908 for bug in bugs:
908 909 bz.update(bug, bugs[bug], ctx)
909 910 bz.notify(bugs, util.email(ctx.user()))
910 911 except Exception, e:
911 912 raise util.Abort(_('Bugzilla error: %s') % e)
912 913
General Comments 0
You need to be logged in to leave comments. Login now