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