##// END OF EJS Templates
merge with stable
Matt Mackall -
r16881:2255950e merge default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,914 +1,915
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 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 306 # updates to bug state. Recognised 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 args['status'] = self.fixstatus
661 args['resolution'] = self.fixresolution
660 if 'fix' in newstate:
661 args['status'] = self.fixstatus
662 args['resolution'] = self.fixresolution
662 663 self.bzproxy.Bug.update(args)
663 664 else:
664 665 if 'fix' in newstate:
665 666 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
666 667 "to mark bugs fixed\n"))
667 668 args['id'] = bugid
668 669 args['comment'] = text
669 670 self.bzproxy.Bug.add_comment(args)
670 671
671 672 class bzxmlrpcemail(bzxmlrpc):
672 673 """Read data from Bugzilla via XMLRPC, send updates via email.
673 674
674 675 Advantages of sending updates via email:
675 676 1. Comments can be added as any user, not just logged in user.
676 677 2. Bug statuses or other fields not accessible via XMLRPC can
677 678 potentially be updated.
678 679
679 680 There is no XMLRPC function to change bug status before Bugzilla
680 681 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
681 682 But bugs can be marked fixed via email from 3.4 onwards.
682 683 """
683 684
684 685 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
685 686 # in-email fields are specified as '@<fieldname> = <value>'. In
686 687 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
687 688 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
688 689 # compatibility, but rather than rely on this use the new format for
689 690 # 4.0 onwards.
690 691
691 692 def __init__(self, ui):
692 693 bzxmlrpc.__init__(self, ui)
693 694
694 695 self.bzemail = self.ui.config('bugzilla', 'bzemail')
695 696 if not self.bzemail:
696 697 raise util.Abort(_("configuration 'bzemail' missing"))
697 698 mail.validateconfig(self.ui)
698 699
699 700 def makecommandline(self, fieldname, value):
700 701 if self.bzvermajor >= 4:
701 702 return "@%s %s" % (fieldname, str(value))
702 703 else:
703 704 if fieldname == "id":
704 705 fieldname = "bug_id"
705 706 return "@%s = %s" % (fieldname, str(value))
706 707
707 708 def send_bug_modify_email(self, bugid, commands, comment, committer):
708 709 '''send modification message to Bugzilla bug via email.
709 710
710 711 The message format is documented in the Bugzilla email_in.pl
711 712 specification. commands is a list of command lines, comment is the
712 713 comment text.
713 714
714 715 To stop users from crafting commit comments with
715 716 Bugzilla commands, specify the bug ID via the message body, rather
716 717 than the subject line, and leave a blank line after it.
717 718 '''
718 719 user = self.map_committer(committer)
719 720 matches = self.bzproxy.User.get(dict(match=[user]))
720 721 if not matches['users']:
721 722 user = self.ui.config('bugzilla', 'user', 'bugs')
722 723 matches = self.bzproxy.User.get(dict(match=[user]))
723 724 if not matches['users']:
724 725 raise util.Abort(_("default bugzilla user %s email not found") %
725 726 user)
726 727 user = matches['users'][0]['email']
727 728 commands.append(self.makecommandline("id", bugid))
728 729
729 730 text = "\n".join(commands) + "\n\n" + comment
730 731
731 732 _charsets = mail._charsets(self.ui)
732 733 user = mail.addressencode(self.ui, user, _charsets)
733 734 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
734 735 msg = mail.mimeencode(self.ui, text, _charsets)
735 736 msg['From'] = user
736 737 msg['To'] = bzemail
737 738 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
738 739 sendmail = mail.connect(self.ui)
739 740 sendmail(user, bzemail, msg.as_string())
740 741
741 742 def updatebug(self, bugid, newstate, text, committer):
742 743 cmds = []
743 744 if 'hours' in newstate:
744 745 cmds.append(self.makecommandline("work_time", newstate['hours']))
745 746 if 'fix' in newstate:
746 747 cmds.append(self.makecommandline("bug_status", self.fixstatus))
747 748 cmds.append(self.makecommandline("resolution", self.fixresolution))
748 749 self.send_bug_modify_email(bugid, cmds, text, committer)
749 750
750 751 class bugzilla(object):
751 752 # supported versions of bugzilla. different versions have
752 753 # different schemas.
753 754 _versions = {
754 755 '2.16': bzmysql,
755 756 '2.18': bzmysql_2_18,
756 757 '3.0': bzmysql_3_0,
757 758 'xmlrpc': bzxmlrpc,
758 759 'xmlrpc+email': bzxmlrpcemail
759 760 }
760 761
761 762 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
762 763 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
763 764 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
764 765
765 766 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
766 767 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
767 768 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
768 769 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
769 770
770 771 _bz = None
771 772
772 773 def __init__(self, ui, repo):
773 774 self.ui = ui
774 775 self.repo = repo
775 776
776 777 def bz(self):
777 778 '''return object that knows how to talk to bugzilla version in
778 779 use.'''
779 780
780 781 if bugzilla._bz is None:
781 782 bzversion = self.ui.config('bugzilla', 'version')
782 783 try:
783 784 bzclass = bugzilla._versions[bzversion]
784 785 except KeyError:
785 786 raise util.Abort(_('bugzilla version %s not supported') %
786 787 bzversion)
787 788 bugzilla._bz = bzclass(self.ui)
788 789 return bugzilla._bz
789 790
790 791 def __getattr__(self, key):
791 792 return getattr(self.bz(), key)
792 793
793 794 _bug_re = None
794 795 _fix_re = None
795 796 _split_re = None
796 797
797 798 def find_bugs(self, ctx):
798 799 '''return bugs dictionary created from commit comment.
799 800
800 801 Extract bug info from changeset comments. Filter out any that are
801 802 not known to Bugzilla, and any that already have a reference to
802 803 the given changeset in their comments.
803 804 '''
804 805 if bugzilla._bug_re is None:
805 806 bugzilla._bug_re = re.compile(
806 807 self.ui.config('bugzilla', 'regexp',
807 808 bugzilla._default_bug_re), re.IGNORECASE)
808 809 bugzilla._fix_re = re.compile(
809 810 self.ui.config('bugzilla', 'fixregexp',
810 811 bugzilla._default_fix_re), re.IGNORECASE)
811 812 bugzilla._split_re = re.compile(r'\D+')
812 813 start = 0
813 814 hours = 0.0
814 815 bugs = {}
815 816 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
816 817 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
817 818 while True:
818 819 bugattribs = {}
819 820 if not bugmatch and not fixmatch:
820 821 break
821 822 if not bugmatch:
822 823 m = fixmatch
823 824 elif not fixmatch:
824 825 m = bugmatch
825 826 else:
826 827 if bugmatch.start() < fixmatch.start():
827 828 m = bugmatch
828 829 else:
829 830 m = fixmatch
830 831 start = m.end()
831 832 if m is bugmatch:
832 833 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
833 834 if 'fix' in bugattribs:
834 835 del bugattribs['fix']
835 836 else:
836 837 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
837 838 bugattribs['fix'] = None
838 839
839 840 try:
840 841 ids = m.group('ids')
841 842 except IndexError:
842 843 ids = m.group(1)
843 844 try:
844 845 hours = float(m.group('hours'))
845 846 bugattribs['hours'] = hours
846 847 except IndexError:
847 848 pass
848 849 except TypeError:
849 850 pass
850 851 except ValueError:
851 852 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
852 853
853 854 for id in bugzilla._split_re.split(ids):
854 855 if not id:
855 856 continue
856 857 bugs[int(id)] = bugattribs
857 858 if bugs:
858 859 self.filter_real_bug_ids(bugs)
859 860 if bugs:
860 861 self.filter_cset_known_bug_ids(ctx.node(), bugs)
861 862 return bugs
862 863
863 864 def update(self, bugid, newstate, ctx):
864 865 '''update bugzilla bug with reference to changeset.'''
865 866
866 867 def webroot(root):
867 868 '''strip leading prefix of repo root and turn into
868 869 url-safe path.'''
869 870 count = int(self.ui.config('bugzilla', 'strip', 0))
870 871 root = util.pconvert(root)
871 872 while count > 0:
872 873 c = root.find('/')
873 874 if c == -1:
874 875 break
875 876 root = root[c + 1:]
876 877 count -= 1
877 878 return root
878 879
879 880 mapfile = self.ui.config('bugzilla', 'style')
880 881 tmpl = self.ui.config('bugzilla', 'template')
881 882 t = cmdutil.changeset_templater(self.ui, self.repo,
882 883 False, None, mapfile, False)
883 884 if not mapfile and not tmpl:
884 885 tmpl = _('changeset {node|short} in repo {root} refers '
885 886 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
886 887 if tmpl:
887 888 tmpl = templater.parsestring(tmpl, quoted=False)
888 889 t.use_template(tmpl)
889 890 self.ui.pushbuffer()
890 891 t.show(ctx, changes=ctx.changeset(),
891 892 bug=str(bugid),
892 893 hgweb=self.ui.config('web', 'baseurl'),
893 894 root=self.repo.root,
894 895 webroot=webroot(self.repo.root))
895 896 data = self.ui.popbuffer()
896 897 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
897 898
898 899 def hook(ui, repo, hooktype, node=None, **kwargs):
899 900 '''add comment to bugzilla for each changeset that refers to a
900 901 bugzilla bug id. only add a comment once per bug, so same change
901 902 seen multiple times does not fill bug with duplicate data.'''
902 903 if node is None:
903 904 raise util.Abort(_('hook type %s does not pass a changeset id') %
904 905 hooktype)
905 906 try:
906 907 bz = bugzilla(ui, repo)
907 908 ctx = repo[node]
908 909 bugs = bz.find_bugs(ctx)
909 910 if bugs:
910 911 for bug in bugs:
911 912 bz.update(bug, bugs[bug], ctx)
912 913 bz.notify(bugs, util.email(ctx.user()))
913 914 except Exception, e:
914 915 raise util.Abort(_('Bugzilla error: %s') % e)
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now