##// END OF EJS Templates
en-us: recognized
timeless@mozdev.org -
r17534:c5f7c4b5 default
parent child Browse files
Show More
@@ -1,915 +1,915 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 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 that the Mercurial user email is not recognised by Bugzilla as a Bugzilla
48 that the Mercurial user email is not recognized 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 This access type to use. Values recognised are:
56 This access type to use. Values recognized 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 testedwith = 'internal'
286 286
287 287 class bzaccess(object):
288 288 '''Base class for access to Bugzilla.'''
289 289
290 290 def __init__(self, ui):
291 291 self.ui = ui
292 292 usermap = self.ui.config('bugzilla', 'usermap')
293 293 if usermap:
294 294 self.ui.readconfig(usermap, sections=['usermap'])
295 295
296 296 def map_committer(self, user):
297 297 '''map name of committer to Bugzilla user name.'''
298 298 for committer, bzuser in self.ui.configitems('usermap'):
299 299 if committer.lower() == user.lower():
300 300 return bzuser
301 301 return user
302 302
303 303 # Methods to be implemented by access classes.
304 304 #
305 305 # 'bugs' is a dict keyed on bug id, where values are a dict holding
306 # updates to bug state. Recognised dict keys are:
306 # updates to bug state. Recognized dict keys are:
307 307 #
308 308 # 'hours': Value, float containing work hours to be updated.
309 309 # 'fix': If key present, bug is to be marked fixed. Value ignored.
310 310
311 311 def filter_real_bug_ids(self, bugs):
312 312 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
313 313 pass
314 314
315 315 def filter_cset_known_bug_ids(self, node, bugs):
316 316 '''remove bug IDs where node occurs in comment text from bugs.'''
317 317 pass
318 318
319 319 def updatebug(self, bugid, newstate, text, committer):
320 320 '''update the specified bug. Add comment text and set new states.
321 321
322 322 If possible add the comment as being from the committer of
323 323 the changeset. Otherwise use the default Bugzilla user.
324 324 '''
325 325 pass
326 326
327 327 def notify(self, bugs, committer):
328 328 '''Force sending of Bugzilla notification emails.
329 329
330 330 Only required if the access method does not trigger notification
331 331 emails automatically.
332 332 '''
333 333 pass
334 334
335 335 # Bugzilla via direct access to MySQL database.
336 336 class bzmysql(bzaccess):
337 337 '''Support for direct MySQL access to Bugzilla.
338 338
339 339 The earliest Bugzilla version this is tested with is version 2.16.
340 340
341 341 If your Bugzilla is version 3.4 or above, you are strongly
342 342 recommended to use the XMLRPC access method instead.
343 343 '''
344 344
345 345 @staticmethod
346 346 def sql_buglist(ids):
347 347 '''return SQL-friendly list of bug ids'''
348 348 return '(' + ','.join(map(str, ids)) + ')'
349 349
350 350 _MySQLdb = None
351 351
352 352 def __init__(self, ui):
353 353 try:
354 354 import MySQLdb as mysql
355 355 bzmysql._MySQLdb = mysql
356 356 except ImportError, err:
357 357 raise util.Abort(_('python mysql support not available: %s') % err)
358 358
359 359 bzaccess.__init__(self, ui)
360 360
361 361 host = self.ui.config('bugzilla', 'host', 'localhost')
362 362 user = self.ui.config('bugzilla', 'user', 'bugs')
363 363 passwd = self.ui.config('bugzilla', 'password')
364 364 db = self.ui.config('bugzilla', 'db', 'bugs')
365 365 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
366 366 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
367 367 (host, db, user, '*' * len(passwd)))
368 368 self.conn = bzmysql._MySQLdb.connect(host=host,
369 369 user=user, passwd=passwd,
370 370 db=db,
371 371 connect_timeout=timeout)
372 372 self.cursor = self.conn.cursor()
373 373 self.longdesc_id = self.get_longdesc_id()
374 374 self.user_ids = {}
375 375 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
376 376
377 377 def run(self, *args, **kwargs):
378 378 '''run a query.'''
379 379 self.ui.note(_('query: %s %s\n') % (args, kwargs))
380 380 try:
381 381 self.cursor.execute(*args, **kwargs)
382 382 except bzmysql._MySQLdb.MySQLError:
383 383 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
384 384 raise
385 385
386 386 def get_longdesc_id(self):
387 387 '''get identity of longdesc field'''
388 388 self.run('select fieldid from fielddefs where name = "longdesc"')
389 389 ids = self.cursor.fetchall()
390 390 if len(ids) != 1:
391 391 raise util.Abort(_('unknown database schema'))
392 392 return ids[0][0]
393 393
394 394 def filter_real_bug_ids(self, bugs):
395 395 '''filter not-existing bugs from set.'''
396 396 self.run('select bug_id from bugs where bug_id in %s' %
397 397 bzmysql.sql_buglist(bugs.keys()))
398 398 existing = [id for (id,) in self.cursor.fetchall()]
399 399 for id in bugs.keys():
400 400 if id not in existing:
401 401 self.ui.status(_('bug %d does not exist\n') % id)
402 402 del bugs[id]
403 403
404 404 def filter_cset_known_bug_ids(self, node, bugs):
405 405 '''filter bug ids that already refer to this changeset from set.'''
406 406 self.run('''select bug_id from longdescs where
407 407 bug_id in %s and thetext like "%%%s%%"''' %
408 408 (bzmysql.sql_buglist(bugs.keys()), short(node)))
409 409 for (id,) in self.cursor.fetchall():
410 410 self.ui.status(_('bug %d already knows about changeset %s\n') %
411 411 (id, short(node)))
412 412 del bugs[id]
413 413
414 414 def notify(self, bugs, committer):
415 415 '''tell bugzilla to send mail.'''
416 416 self.ui.status(_('telling bugzilla to send mail:\n'))
417 417 (user, userid) = self.get_bugzilla_user(committer)
418 418 for id in bugs.keys():
419 419 self.ui.status(_(' bug %s\n') % id)
420 420 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
421 421 bzdir = self.ui.config('bugzilla', 'bzdir',
422 422 '/var/www/html/bugzilla')
423 423 try:
424 424 # Backwards-compatible with old notify string, which
425 425 # took one string. This will throw with a new format
426 426 # string.
427 427 cmd = cmdfmt % id
428 428 except TypeError:
429 429 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
430 430 self.ui.note(_('running notify command %s\n') % cmd)
431 431 fp = util.popen('(%s) 2>&1' % cmd)
432 432 out = fp.read()
433 433 ret = fp.close()
434 434 if ret:
435 435 self.ui.warn(out)
436 436 raise util.Abort(_('bugzilla notify command %s') %
437 437 util.explainexit(ret)[0])
438 438 self.ui.status(_('done\n'))
439 439
440 440 def get_user_id(self, user):
441 441 '''look up numeric bugzilla user id.'''
442 442 try:
443 443 return self.user_ids[user]
444 444 except KeyError:
445 445 try:
446 446 userid = int(user)
447 447 except ValueError:
448 448 self.ui.note(_('looking up user %s\n') % user)
449 449 self.run('''select userid from profiles
450 450 where login_name like %s''', user)
451 451 all = self.cursor.fetchall()
452 452 if len(all) != 1:
453 453 raise KeyError(user)
454 454 userid = int(all[0][0])
455 455 self.user_ids[user] = userid
456 456 return userid
457 457
458 458 def get_bugzilla_user(self, committer):
459 459 '''See if committer is a registered bugzilla user. Return
460 460 bugzilla username and userid if so. If not, return default
461 461 bugzilla username and userid.'''
462 462 user = self.map_committer(committer)
463 463 try:
464 464 userid = self.get_user_id(user)
465 465 except KeyError:
466 466 try:
467 467 defaultuser = self.ui.config('bugzilla', 'bzuser')
468 468 if not defaultuser:
469 469 raise util.Abort(_('cannot find bugzilla user id for %s') %
470 470 user)
471 471 userid = self.get_user_id(defaultuser)
472 472 user = defaultuser
473 473 except KeyError:
474 474 raise util.Abort(_('cannot find bugzilla user id for %s or %s')
475 475 % (user, defaultuser))
476 476 return (user, userid)
477 477
478 478 def updatebug(self, bugid, newstate, text, committer):
479 479 '''update bug state with comment text.
480 480
481 481 Try adding comment as committer of changeset, otherwise as
482 482 default bugzilla user.'''
483 483 if len(newstate) > 0:
484 484 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
485 485
486 486 (user, userid) = self.get_bugzilla_user(committer)
487 487 now = time.strftime('%Y-%m-%d %H:%M:%S')
488 488 self.run('''insert into longdescs
489 489 (bug_id, who, bug_when, thetext)
490 490 values (%s, %s, %s, %s)''',
491 491 (bugid, userid, now, text))
492 492 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
493 493 values (%s, %s, %s, %s)''',
494 494 (bugid, userid, now, self.longdesc_id))
495 495 self.conn.commit()
496 496
497 497 class bzmysql_2_18(bzmysql):
498 498 '''support for bugzilla 2.18 series.'''
499 499
500 500 def __init__(self, ui):
501 501 bzmysql.__init__(self, ui)
502 502 self.default_notify = \
503 503 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
504 504
505 505 class bzmysql_3_0(bzmysql_2_18):
506 506 '''support for bugzilla 3.0 series.'''
507 507
508 508 def __init__(self, ui):
509 509 bzmysql_2_18.__init__(self, ui)
510 510
511 511 def get_longdesc_id(self):
512 512 '''get identity of longdesc field'''
513 513 self.run('select id from fielddefs where name = "longdesc"')
514 514 ids = self.cursor.fetchall()
515 515 if len(ids) != 1:
516 516 raise util.Abort(_('unknown database schema'))
517 517 return ids[0][0]
518 518
519 519 # Buzgilla via XMLRPC interface.
520 520
521 521 class cookietransportrequest(object):
522 522 """A Transport request method that retains cookies over its lifetime.
523 523
524 524 The regular xmlrpclib transports ignore cookies. Which causes
525 525 a bit of a problem when you need a cookie-based login, as with
526 526 the Bugzilla XMLRPC interface.
527 527
528 528 So this is a helper for defining a Transport which looks for
529 529 cookies being set in responses and saves them to add to all future
530 530 requests.
531 531 """
532 532
533 533 # Inspiration drawn from
534 534 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
535 535 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
536 536
537 537 cookies = []
538 538 def send_cookies(self, connection):
539 539 if self.cookies:
540 540 for cookie in self.cookies:
541 541 connection.putheader("Cookie", cookie)
542 542
543 543 def request(self, host, handler, request_body, verbose=0):
544 544 self.verbose = verbose
545 545 self.accept_gzip_encoding = False
546 546
547 547 # issue XML-RPC request
548 548 h = self.make_connection(host)
549 549 if verbose:
550 550 h.set_debuglevel(1)
551 551
552 552 self.send_request(h, handler, request_body)
553 553 self.send_host(h, host)
554 554 self.send_cookies(h)
555 555 self.send_user_agent(h)
556 556 self.send_content(h, request_body)
557 557
558 558 # Deal with differences between Python 2.4-2.6 and 2.7.
559 559 # In the former h is a HTTP(S). In the latter it's a
560 560 # HTTP(S)Connection. Luckily, the 2.4-2.6 implementation of
561 561 # HTTP(S) has an underlying HTTP(S)Connection, so extract
562 562 # that and use it.
563 563 try:
564 564 response = h.getresponse()
565 565 except AttributeError:
566 566 response = h._conn.getresponse()
567 567
568 568 # Add any cookie definitions to our list.
569 569 for header in response.msg.getallmatchingheaders("Set-Cookie"):
570 570 val = header.split(": ", 1)[1]
571 571 cookie = val.split(";", 1)[0]
572 572 self.cookies.append(cookie)
573 573
574 574 if response.status != 200:
575 575 raise xmlrpclib.ProtocolError(host + handler, response.status,
576 576 response.reason, response.msg.headers)
577 577
578 578 payload = response.read()
579 579 parser, unmarshaller = self.getparser()
580 580 parser.feed(payload)
581 581 parser.close()
582 582
583 583 return unmarshaller.close()
584 584
585 585 # The explicit calls to the underlying xmlrpclib __init__() methods are
586 586 # necessary. The xmlrpclib.Transport classes are old-style classes, and
587 587 # it turns out their __init__() doesn't get called when doing multiple
588 588 # inheritance with a new-style class.
589 589 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
590 590 def __init__(self, use_datetime=0):
591 591 if util.safehasattr(xmlrpclib.Transport, "__init__"):
592 592 xmlrpclib.Transport.__init__(self, use_datetime)
593 593
594 594 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
595 595 def __init__(self, use_datetime=0):
596 596 if util.safehasattr(xmlrpclib.Transport, "__init__"):
597 597 xmlrpclib.SafeTransport.__init__(self, use_datetime)
598 598
599 599 class bzxmlrpc(bzaccess):
600 600 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
601 601
602 602 Requires a minimum Bugzilla version 3.4.
603 603 """
604 604
605 605 def __init__(self, ui):
606 606 bzaccess.__init__(self, ui)
607 607
608 608 bzweb = self.ui.config('bugzilla', 'bzurl',
609 609 'http://localhost/bugzilla/')
610 610 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
611 611
612 612 user = self.ui.config('bugzilla', 'user', 'bugs')
613 613 passwd = self.ui.config('bugzilla', 'password')
614 614
615 615 self.fixstatus = self.ui.config('bugzilla', 'fixstatus', 'RESOLVED')
616 616 self.fixresolution = self.ui.config('bugzilla', 'fixresolution',
617 617 'FIXED')
618 618
619 619 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
620 620 ver = self.bzproxy.Bugzilla.version()['version'].split('.')
621 621 self.bzvermajor = int(ver[0])
622 622 self.bzverminor = int(ver[1])
623 623 self.bzproxy.User.login(dict(login=user, password=passwd))
624 624
625 625 def transport(self, uri):
626 626 if urlparse.urlparse(uri, "http")[0] == "https":
627 627 return cookiesafetransport()
628 628 else:
629 629 return cookietransport()
630 630
631 631 def get_bug_comments(self, id):
632 632 """Return a string with all comment text for a bug."""
633 633 c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
634 634 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
635 635
636 636 def filter_real_bug_ids(self, bugs):
637 637 probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
638 638 include_fields=[],
639 639 permissive=True))
640 640 for badbug in probe['faults']:
641 641 id = badbug['id']
642 642 self.ui.status(_('bug %d does not exist\n') % id)
643 643 del bugs[id]
644 644
645 645 def filter_cset_known_bug_ids(self, node, bugs):
646 646 for id in sorted(bugs.keys()):
647 647 if self.get_bug_comments(id).find(short(node)) != -1:
648 648 self.ui.status(_('bug %d already knows about changeset %s\n') %
649 649 (id, short(node)))
650 650 del bugs[id]
651 651
652 652 def updatebug(self, bugid, newstate, text, committer):
653 653 args = {}
654 654 if 'hours' in newstate:
655 655 args['work_time'] = newstate['hours']
656 656
657 657 if self.bzvermajor >= 4:
658 658 args['ids'] = [bugid]
659 659 args['comment'] = {'body' : text}
660 660 if 'fix' in newstate:
661 661 args['status'] = self.fixstatus
662 662 args['resolution'] = self.fixresolution
663 663 self.bzproxy.Bug.update(args)
664 664 else:
665 665 if 'fix' in newstate:
666 666 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
667 667 "to mark bugs fixed\n"))
668 668 args['id'] = bugid
669 669 args['comment'] = text
670 670 self.bzproxy.Bug.add_comment(args)
671 671
672 672 class bzxmlrpcemail(bzxmlrpc):
673 673 """Read data from Bugzilla via XMLRPC, send updates via email.
674 674
675 675 Advantages of sending updates via email:
676 676 1. Comments can be added as any user, not just logged in user.
677 677 2. Bug statuses or other fields not accessible via XMLRPC can
678 678 potentially be updated.
679 679
680 680 There is no XMLRPC function to change bug status before Bugzilla
681 681 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
682 682 But bugs can be marked fixed via email from 3.4 onwards.
683 683 """
684 684
685 685 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
686 686 # in-email fields are specified as '@<fieldname> = <value>'. In
687 687 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
688 688 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
689 689 # compatibility, but rather than rely on this use the new format for
690 690 # 4.0 onwards.
691 691
692 692 def __init__(self, ui):
693 693 bzxmlrpc.__init__(self, ui)
694 694
695 695 self.bzemail = self.ui.config('bugzilla', 'bzemail')
696 696 if not self.bzemail:
697 697 raise util.Abort(_("configuration 'bzemail' missing"))
698 698 mail.validateconfig(self.ui)
699 699
700 700 def makecommandline(self, fieldname, value):
701 701 if self.bzvermajor >= 4:
702 702 return "@%s %s" % (fieldname, str(value))
703 703 else:
704 704 if fieldname == "id":
705 705 fieldname = "bug_id"
706 706 return "@%s = %s" % (fieldname, str(value))
707 707
708 708 def send_bug_modify_email(self, bugid, commands, comment, committer):
709 709 '''send modification message to Bugzilla bug via email.
710 710
711 711 The message format is documented in the Bugzilla email_in.pl
712 712 specification. commands is a list of command lines, comment is the
713 713 comment text.
714 714
715 715 To stop users from crafting commit comments with
716 716 Bugzilla commands, specify the bug ID via the message body, rather
717 717 than the subject line, and leave a blank line after it.
718 718 '''
719 719 user = self.map_committer(committer)
720 720 matches = self.bzproxy.User.get(dict(match=[user]))
721 721 if not matches['users']:
722 722 user = self.ui.config('bugzilla', 'user', 'bugs')
723 723 matches = self.bzproxy.User.get(dict(match=[user]))
724 724 if not matches['users']:
725 725 raise util.Abort(_("default bugzilla user %s email not found") %
726 726 user)
727 727 user = matches['users'][0]['email']
728 728 commands.append(self.makecommandline("id", bugid))
729 729
730 730 text = "\n".join(commands) + "\n\n" + comment
731 731
732 732 _charsets = mail._charsets(self.ui)
733 733 user = mail.addressencode(self.ui, user, _charsets)
734 734 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
735 735 msg = mail.mimeencode(self.ui, text, _charsets)
736 736 msg['From'] = user
737 737 msg['To'] = bzemail
738 738 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
739 739 sendmail = mail.connect(self.ui)
740 740 sendmail(user, bzemail, msg.as_string())
741 741
742 742 def updatebug(self, bugid, newstate, text, committer):
743 743 cmds = []
744 744 if 'hours' in newstate:
745 745 cmds.append(self.makecommandline("work_time", newstate['hours']))
746 746 if 'fix' in newstate:
747 747 cmds.append(self.makecommandline("bug_status", self.fixstatus))
748 748 cmds.append(self.makecommandline("resolution", self.fixresolution))
749 749 self.send_bug_modify_email(bugid, cmds, text, committer)
750 750
751 751 class bugzilla(object):
752 752 # supported versions of bugzilla. different versions have
753 753 # different schemas.
754 754 _versions = {
755 755 '2.16': bzmysql,
756 756 '2.18': bzmysql_2_18,
757 757 '3.0': bzmysql_3_0,
758 758 'xmlrpc': bzxmlrpc,
759 759 'xmlrpc+email': bzxmlrpcemail
760 760 }
761 761
762 762 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
763 763 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
764 764 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
765 765
766 766 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
767 767 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
768 768 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
769 769 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
770 770
771 771 _bz = None
772 772
773 773 def __init__(self, ui, repo):
774 774 self.ui = ui
775 775 self.repo = repo
776 776
777 777 def bz(self):
778 778 '''return object that knows how to talk to bugzilla version in
779 779 use.'''
780 780
781 781 if bugzilla._bz is None:
782 782 bzversion = self.ui.config('bugzilla', 'version')
783 783 try:
784 784 bzclass = bugzilla._versions[bzversion]
785 785 except KeyError:
786 786 raise util.Abort(_('bugzilla version %s not supported') %
787 787 bzversion)
788 788 bugzilla._bz = bzclass(self.ui)
789 789 return bugzilla._bz
790 790
791 791 def __getattr__(self, key):
792 792 return getattr(self.bz(), key)
793 793
794 794 _bug_re = None
795 795 _fix_re = None
796 796 _split_re = None
797 797
798 798 def find_bugs(self, ctx):
799 799 '''return bugs dictionary created from commit comment.
800 800
801 801 Extract bug info from changeset comments. Filter out any that are
802 802 not known to Bugzilla, and any that already have a reference to
803 803 the given changeset in their comments.
804 804 '''
805 805 if bugzilla._bug_re is None:
806 806 bugzilla._bug_re = re.compile(
807 807 self.ui.config('bugzilla', 'regexp',
808 808 bugzilla._default_bug_re), re.IGNORECASE)
809 809 bugzilla._fix_re = re.compile(
810 810 self.ui.config('bugzilla', 'fixregexp',
811 811 bugzilla._default_fix_re), re.IGNORECASE)
812 812 bugzilla._split_re = re.compile(r'\D+')
813 813 start = 0
814 814 hours = 0.0
815 815 bugs = {}
816 816 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
817 817 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
818 818 while True:
819 819 bugattribs = {}
820 820 if not bugmatch and not fixmatch:
821 821 break
822 822 if not bugmatch:
823 823 m = fixmatch
824 824 elif not fixmatch:
825 825 m = bugmatch
826 826 else:
827 827 if bugmatch.start() < fixmatch.start():
828 828 m = bugmatch
829 829 else:
830 830 m = fixmatch
831 831 start = m.end()
832 832 if m is bugmatch:
833 833 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
834 834 if 'fix' in bugattribs:
835 835 del bugattribs['fix']
836 836 else:
837 837 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
838 838 bugattribs['fix'] = None
839 839
840 840 try:
841 841 ids = m.group('ids')
842 842 except IndexError:
843 843 ids = m.group(1)
844 844 try:
845 845 hours = float(m.group('hours'))
846 846 bugattribs['hours'] = hours
847 847 except IndexError:
848 848 pass
849 849 except TypeError:
850 850 pass
851 851 except ValueError:
852 852 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
853 853
854 854 for id in bugzilla._split_re.split(ids):
855 855 if not id:
856 856 continue
857 857 bugs[int(id)] = bugattribs
858 858 if bugs:
859 859 self.filter_real_bug_ids(bugs)
860 860 if bugs:
861 861 self.filter_cset_known_bug_ids(ctx.node(), bugs)
862 862 return bugs
863 863
864 864 def update(self, bugid, newstate, ctx):
865 865 '''update bugzilla bug with reference to changeset.'''
866 866
867 867 def webroot(root):
868 868 '''strip leading prefix of repo root and turn into
869 869 url-safe path.'''
870 870 count = int(self.ui.config('bugzilla', 'strip', 0))
871 871 root = util.pconvert(root)
872 872 while count > 0:
873 873 c = root.find('/')
874 874 if c == -1:
875 875 break
876 876 root = root[c + 1:]
877 877 count -= 1
878 878 return root
879 879
880 880 mapfile = self.ui.config('bugzilla', 'style')
881 881 tmpl = self.ui.config('bugzilla', 'template')
882 882 t = cmdutil.changeset_templater(self.ui, self.repo,
883 883 False, None, mapfile, False)
884 884 if not mapfile and not tmpl:
885 885 tmpl = _('changeset {node|short} in repo {root} refers '
886 886 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
887 887 if tmpl:
888 888 tmpl = templater.parsestring(tmpl, quoted=False)
889 889 t.use_template(tmpl)
890 890 self.ui.pushbuffer()
891 891 t.show(ctx, changes=ctx.changeset(),
892 892 bug=str(bugid),
893 893 hgweb=self.ui.config('web', 'baseurl'),
894 894 root=self.repo.root,
895 895 webroot=webroot(self.repo.root))
896 896 data = self.ui.popbuffer()
897 897 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
898 898
899 899 def hook(ui, repo, hooktype, node=None, **kwargs):
900 900 '''add comment to bugzilla for each changeset that refers to a
901 901 bugzilla bug id. only add a comment once per bug, so same change
902 902 seen multiple times does not fill bug with duplicate data.'''
903 903 if node is None:
904 904 raise util.Abort(_('hook type %s does not pass a changeset id') %
905 905 hooktype)
906 906 try:
907 907 bz = bugzilla(ui, repo)
908 908 ctx = repo[node]
909 909 bugs = bz.find_bugs(ctx)
910 910 if bugs:
911 911 for bug in bugs:
912 912 bz.update(bug, bugs[bug], ctx)
913 913 bz.notify(bugs, util.email(ctx.user()))
914 914 except Exception, e:
915 915 raise util.Abort(_('Bugzilla error: %s') % e)
General Comments 0
You need to be logged in to leave comments. Login now