##// END OF EJS Templates
bugzilla: support Bugzilla 4.4.3+ API login token authentication (issue4257)...
Jim Hague -
r21542:d12d8d41 stable
parent child Browse files
Show More
@@ -1,916 +1,923 b''
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 # Copyright 2011-2 Jim Hague <jim.hague@acm.org>
4 # Copyright 2011-4 Jim Hague <jim.hague@acm.org>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 '''hooks for integrating with the Bugzilla bug tracker
10 10
11 11 This hook extension adds comments on bugs in Bugzilla when changesets
12 12 that refer to bugs by Bugzilla ID are seen. The comment is formatted using
13 13 the Mercurial template mechanism.
14 14
15 15 The bug references can optionally include an update for Bugzilla of the
16 16 hours spent working on the bug. Bugs can also be marked fixed.
17 17
18 18 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 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 56 The 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 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 # Bugzilla 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 the Bugzilla XMLRPC interface.
526 the Bugzilla XMLRPC interface prior to 4.4.3.
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 self.bzproxy.User.login({'login': user, 'password': passwd})
623 login = self.bzproxy.User.login({'login': user, 'password': passwd,
624 'restrict_login': True})
625 self.bztoken = login.get('token', '')
624 626
625 627 def transport(self, uri):
626 628 if urlparse.urlparse(uri, "http")[0] == "https":
627 629 return cookiesafetransport()
628 630 else:
629 631 return cookietransport()
630 632
631 633 def get_bug_comments(self, id):
632 634 """Return a string with all comment text for a bug."""
633 635 c = self.bzproxy.Bug.comments({'ids': [id],
634 'include_fields': ['text']})
636 'include_fields': ['text'],
637 'token': self.bztoken})
635 638 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
636 639
637 640 def filter_real_bug_ids(self, bugs):
638 641 probe = self.bzproxy.Bug.get({'ids': sorted(bugs.keys()),
639 642 'include_fields': [],
640 643 'permissive': True,
644 'token': self.bztoken,
641 645 })
642 646 for badbug in probe['faults']:
643 647 id = badbug['id']
644 648 self.ui.status(_('bug %d does not exist\n') % id)
645 649 del bugs[id]
646 650
647 651 def filter_cset_known_bug_ids(self, node, bugs):
648 652 for id in sorted(bugs.keys()):
649 653 if self.get_bug_comments(id).find(short(node)) != -1:
650 654 self.ui.status(_('bug %d already knows about changeset %s\n') %
651 655 (id, short(node)))
652 656 del bugs[id]
653 657
654 658 def updatebug(self, bugid, newstate, text, committer):
655 659 args = {}
656 660 if 'hours' in newstate:
657 661 args['work_time'] = newstate['hours']
658 662
659 663 if self.bzvermajor >= 4:
660 664 args['ids'] = [bugid]
661 665 args['comment'] = {'body' : text}
662 666 if 'fix' in newstate:
663 667 args['status'] = self.fixstatus
664 668 args['resolution'] = self.fixresolution
669 args['token'] = self.bztoken
665 670 self.bzproxy.Bug.update(args)
666 671 else:
667 672 if 'fix' in newstate:
668 673 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
669 674 "to mark bugs fixed\n"))
670 675 args['id'] = bugid
671 676 args['comment'] = text
672 677 self.bzproxy.Bug.add_comment(args)
673 678
674 679 class bzxmlrpcemail(bzxmlrpc):
675 680 """Read data from Bugzilla via XMLRPC, send updates via email.
676 681
677 682 Advantages of sending updates via email:
678 683 1. Comments can be added as any user, not just logged in user.
679 684 2. Bug statuses or other fields not accessible via XMLRPC can
680 685 potentially be updated.
681 686
682 687 There is no XMLRPC function to change bug status before Bugzilla
683 688 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
684 689 But bugs can be marked fixed via email from 3.4 onwards.
685 690 """
686 691
687 692 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
688 693 # in-email fields are specified as '@<fieldname> = <value>'. In
689 694 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
690 695 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
691 696 # compatibility, but rather than rely on this use the new format for
692 697 # 4.0 onwards.
693 698
694 699 def __init__(self, ui):
695 700 bzxmlrpc.__init__(self, ui)
696 701
697 702 self.bzemail = self.ui.config('bugzilla', 'bzemail')
698 703 if not self.bzemail:
699 704 raise util.Abort(_("configuration 'bzemail' missing"))
700 705 mail.validateconfig(self.ui)
701 706
702 707 def makecommandline(self, fieldname, value):
703 708 if self.bzvermajor >= 4:
704 709 return "@%s %s" % (fieldname, str(value))
705 710 else:
706 711 if fieldname == "id":
707 712 fieldname = "bug_id"
708 713 return "@%s = %s" % (fieldname, str(value))
709 714
710 715 def send_bug_modify_email(self, bugid, commands, comment, committer):
711 716 '''send modification message to Bugzilla bug via email.
712 717
713 718 The message format is documented in the Bugzilla email_in.pl
714 719 specification. commands is a list of command lines, comment is the
715 720 comment text.
716 721
717 722 To stop users from crafting commit comments with
718 723 Bugzilla commands, specify the bug ID via the message body, rather
719 724 than the subject line, and leave a blank line after it.
720 725 '''
721 726 user = self.map_committer(committer)
722 matches = self.bzproxy.User.get({'match': [user]})
727 matches = self.bzproxy.User.get({'match': [user],
728 'token': self.bztoken})
723 729 if not matches['users']:
724 730 user = self.ui.config('bugzilla', 'user', 'bugs')
725 matches = self.bzproxy.User.get({'match': [user]})
731 matches = self.bzproxy.User.get({'match': [user],
732 'token': self.bztoken})
726 733 if not matches['users']:
727 734 raise util.Abort(_("default bugzilla user %s email not found") %
728 735 user)
729 736 user = matches['users'][0]['email']
730 737 commands.append(self.makecommandline("id", bugid))
731 738
732 739 text = "\n".join(commands) + "\n\n" + comment
733 740
734 741 _charsets = mail._charsets(self.ui)
735 742 user = mail.addressencode(self.ui, user, _charsets)
736 743 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
737 744 msg = mail.mimeencode(self.ui, text, _charsets)
738 745 msg['From'] = user
739 746 msg['To'] = bzemail
740 747 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
741 748 sendmail = mail.connect(self.ui)
742 749 sendmail(user, bzemail, msg.as_string())
743 750
744 751 def updatebug(self, bugid, newstate, text, committer):
745 752 cmds = []
746 753 if 'hours' in newstate:
747 754 cmds.append(self.makecommandline("work_time", newstate['hours']))
748 755 if 'fix' in newstate:
749 756 cmds.append(self.makecommandline("bug_status", self.fixstatus))
750 757 cmds.append(self.makecommandline("resolution", self.fixresolution))
751 758 self.send_bug_modify_email(bugid, cmds, text, committer)
752 759
753 760 class bugzilla(object):
754 761 # supported versions of bugzilla. different versions have
755 762 # different schemas.
756 763 _versions = {
757 764 '2.16': bzmysql,
758 765 '2.18': bzmysql_2_18,
759 766 '3.0': bzmysql_3_0,
760 767 'xmlrpc': bzxmlrpc,
761 768 'xmlrpc+email': bzxmlrpcemail
762 769 }
763 770
764 771 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
765 772 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
766 773 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
767 774
768 775 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
769 776 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
770 777 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
771 778 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
772 779
773 780 _bz = None
774 781
775 782 def __init__(self, ui, repo):
776 783 self.ui = ui
777 784 self.repo = repo
778 785
779 786 def bz(self):
780 787 '''return object that knows how to talk to bugzilla version in
781 788 use.'''
782 789
783 790 if bugzilla._bz is None:
784 791 bzversion = self.ui.config('bugzilla', 'version')
785 792 try:
786 793 bzclass = bugzilla._versions[bzversion]
787 794 except KeyError:
788 795 raise util.Abort(_('bugzilla version %s not supported') %
789 796 bzversion)
790 797 bugzilla._bz = bzclass(self.ui)
791 798 return bugzilla._bz
792 799
793 800 def __getattr__(self, key):
794 801 return getattr(self.bz(), key)
795 802
796 803 _bug_re = None
797 804 _fix_re = None
798 805 _split_re = None
799 806
800 807 def find_bugs(self, ctx):
801 808 '''return bugs dictionary created from commit comment.
802 809
803 810 Extract bug info from changeset comments. Filter out any that are
804 811 not known to Bugzilla, and any that already have a reference to
805 812 the given changeset in their comments.
806 813 '''
807 814 if bugzilla._bug_re is None:
808 815 bugzilla._bug_re = re.compile(
809 816 self.ui.config('bugzilla', 'regexp',
810 817 bugzilla._default_bug_re), re.IGNORECASE)
811 818 bugzilla._fix_re = re.compile(
812 819 self.ui.config('bugzilla', 'fixregexp',
813 820 bugzilla._default_fix_re), re.IGNORECASE)
814 821 bugzilla._split_re = re.compile(r'\D+')
815 822 start = 0
816 823 hours = 0.0
817 824 bugs = {}
818 825 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
819 826 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
820 827 while True:
821 828 bugattribs = {}
822 829 if not bugmatch and not fixmatch:
823 830 break
824 831 if not bugmatch:
825 832 m = fixmatch
826 833 elif not fixmatch:
827 834 m = bugmatch
828 835 else:
829 836 if bugmatch.start() < fixmatch.start():
830 837 m = bugmatch
831 838 else:
832 839 m = fixmatch
833 840 start = m.end()
834 841 if m is bugmatch:
835 842 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
836 843 if 'fix' in bugattribs:
837 844 del bugattribs['fix']
838 845 else:
839 846 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
840 847 bugattribs['fix'] = None
841 848
842 849 try:
843 850 ids = m.group('ids')
844 851 except IndexError:
845 852 ids = m.group(1)
846 853 try:
847 854 hours = float(m.group('hours'))
848 855 bugattribs['hours'] = hours
849 856 except IndexError:
850 857 pass
851 858 except TypeError:
852 859 pass
853 860 except ValueError:
854 861 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
855 862
856 863 for id in bugzilla._split_re.split(ids):
857 864 if not id:
858 865 continue
859 866 bugs[int(id)] = bugattribs
860 867 if bugs:
861 868 self.filter_real_bug_ids(bugs)
862 869 if bugs:
863 870 self.filter_cset_known_bug_ids(ctx.node(), bugs)
864 871 return bugs
865 872
866 873 def update(self, bugid, newstate, ctx):
867 874 '''update bugzilla bug with reference to changeset.'''
868 875
869 876 def webroot(root):
870 877 '''strip leading prefix of repo root and turn into
871 878 url-safe path.'''
872 879 count = int(self.ui.config('bugzilla', 'strip', 0))
873 880 root = util.pconvert(root)
874 881 while count > 0:
875 882 c = root.find('/')
876 883 if c == -1:
877 884 break
878 885 root = root[c + 1:]
879 886 count -= 1
880 887 return root
881 888
882 889 mapfile = self.ui.config('bugzilla', 'style')
883 890 tmpl = self.ui.config('bugzilla', 'template')
884 891 if not mapfile and not tmpl:
885 892 tmpl = _('changeset {node|short} in repo {root} refers '
886 893 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
887 894 if tmpl:
888 895 tmpl = templater.parsestring(tmpl, quoted=False)
889 896 t = cmdutil.changeset_templater(self.ui, self.repo,
890 897 False, None, tmpl, mapfile, False)
891 898 self.ui.pushbuffer()
892 899 t.show(ctx, changes=ctx.changeset(),
893 900 bug=str(bugid),
894 901 hgweb=self.ui.config('web', 'baseurl'),
895 902 root=self.repo.root,
896 903 webroot=webroot(self.repo.root))
897 904 data = self.ui.popbuffer()
898 905 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
899 906
900 907 def hook(ui, repo, hooktype, node=None, **kwargs):
901 908 '''add comment to bugzilla for each changeset that refers to a
902 909 bugzilla bug id. only add a comment once per bug, so same change
903 910 seen multiple times does not fill bug with duplicate data.'''
904 911 if node is None:
905 912 raise util.Abort(_('hook type %s does not pass a changeset id') %
906 913 hooktype)
907 914 try:
908 915 bz = bugzilla(ui, repo)
909 916 ctx = repo[node]
910 917 bugs = bz.find_bugs(ctx)
911 918 if bugs:
912 919 for bug in bugs:
913 920 bz.update(bug, bugs[bug], ctx)
914 921 bz.notify(bugs, util.email(ctx.user()))
915 922 except Exception, e:
916 923 raise util.Abort(_('Bugzilla error: %s') % e)
General Comments 0
You need to be logged in to leave comments. Login now