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