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