##// END OF EJS Templates
bugzilla: extract optional hours from commit message and update bug time...
Jim Hague -
r16222:d7b7b453 default
parent child Browse files
Show More
@@ -1,787 +1,823
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 # Copyright 2011-2 Jim Hague <jim.hague@acm.org>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 '''hooks for integrating with the Bugzilla bug tracker
10 10
11 11 This hook extension adds comments on bugs in Bugzilla when changesets
12 12 that refer to bugs by Bugzilla ID are seen. The comment is formatted using
13 13 the Mercurial template mechanism.
14 14
15 The hook does not change bug status.
15 The bug references can optionally include an update for Bugzilla of the
16 hours spent working on the bug.
16 17
17 18 Three basic modes of access to Bugzilla are provided:
18 19
19 20 1. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
20 21
21 22 2. Check data via the Bugzilla XMLRPC interface and submit bug change
22 23 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
23 24
24 25 3. Writing directly to the Bugzilla database. Only Bugzilla installations
25 26 using MySQL are supported. Requires Python MySQLdb.
26 27
27 28 Writing directly to the database is susceptible to schema changes, and
28 29 relies on a Bugzilla contrib script to send out bug change
29 30 notification emails. This script runs as the user running Mercurial,
30 31 must be run on the host with the Bugzilla install, and requires
31 32 permission to read Bugzilla configuration details and the necessary
32 33 MySQL user and password to have full access rights to the Bugzilla
33 34 database. For these reasons this access mode is now considered
34 35 deprecated, and will not be updated for new Bugzilla versions going
35 forward.
36 forward. Only adding comments is supported in this access mode.
36 37
37 38 Access via XMLRPC needs a Bugzilla username and password to be specified
38 39 in the configuration. Comments are added under that username. Since the
39 40 configuration must be readable by all Mercurial users, it is recommended
40 41 that the rights of that user are restricted in Bugzilla to the minimum
41 42 necessary to add comments.
42 43
43 44 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
44 45 email to the Bugzilla email interface to submit comments to bugs.
45 46 The From: address in the email is set to the email address of the Mercurial
46 47 user, so the comment appears to come from the Mercurial user. In the event
47 48 that the Mercurial user email is not recognised by Bugzilla as a Bugzilla
48 49 user, the email associated with the Bugzilla username used to log into
49 50 Bugzilla is used instead as the source of the comment.
50 51
51 52 Configuration items common to all access modes:
52 53
53 54 bugzilla.version
54 55 This access type to use. Values recognised are:
55 56
56 57 :``xmlrpc``: Bugzilla XMLRPC interface.
57 58 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
58 59 :``3.0``: MySQL access, Bugzilla 3.0 and later.
59 60 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
60 61 including 3.0.
61 62 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
62 63 including 2.18.
63 64
64 65 bugzilla.regexp
65 66 Regular expression to match bug IDs in changeset commit message.
66 Must contain one "()" group. The default expression matches ``Bug
67 1234``, ``Bug no. 1234``, ``Bug number 1234``, ``Bugs 1234,5678``,
68 ``Bug 1234 and 5678`` and variations thereof. Matching is case
69 insensitive.
67 It must contain one "()" named group ``<ids>`` containing the bug
68 IDs separated by non-digit characters. It may also contain
69 a named group ``<hours>`` with a floating-point number giving the
70 hours worked on the bug. If no named groups are present, the first
71 "()" group is assumed to contain the bug IDs, and work time is not
72 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
73 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
74 variations thereof, followed by an hours number prefixed by ``h`` or
75 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
70 76
71 77 bugzilla.style
72 78 The style file to use when formatting comments.
73 79
74 80 bugzilla.template
75 81 Template to use when formatting comments. Overrides style if
76 82 specified. In addition to the usual Mercurial keywords, the
77 83 extension specifies:
78 84
79 85 :``{bug}``: The Bugzilla bug ID.
80 86 :``{root}``: The full pathname of the Mercurial repository.
81 87 :``{webroot}``: Stripped pathname of the Mercurial repository.
82 88 :``{hgweb}``: Base URL for browsing Mercurial repositories.
83 89
84 90 Default ``changeset {node|short} in repo {root} refers to bug
85 91 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
86 92
87 93 bugzilla.strip
88 94 The number of path separator characters to strip from the front of
89 95 the Mercurial repository path (``{root}`` in templates) to produce
90 96 ``{webroot}``. For example, a repository with ``{root}``
91 97 ``/var/local/my-project`` with a strip of 2 gives a value for
92 98 ``{webroot}`` of ``my-project``. Default 0.
93 99
94 100 web.baseurl
95 101 Base URL for browsing Mercurial repositories. Referenced from
96 102 templates as ``{hgweb}``.
97 103
98 104 Configuration items common to XMLRPC+email and MySQL access modes:
99 105
100 106 bugzilla.usermap
101 107 Path of file containing Mercurial committer email to Bugzilla user email
102 108 mappings. If specified, the file should contain one mapping per
103 109 line::
104 110
105 111 committer = Bugzilla user
106 112
107 113 See also the ``[usermap]`` section.
108 114
109 115 The ``[usermap]`` section is used to specify mappings of Mercurial
110 116 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
111 117 Contains entries of the form ``committer = Bugzilla user``.
112 118
113 119 XMLRPC access mode configuration:
114 120
115 121 bugzilla.bzurl
116 122 The base URL for the Bugzilla installation.
117 123 Default ``http://localhost/bugzilla``.
118 124
119 125 bugzilla.user
120 126 The username to use to log into Bugzilla via XMLRPC. Default
121 127 ``bugs``.
122 128
123 129 bugzilla.password
124 130 The password for Bugzilla login.
125 131
126 132 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
127 133 and also:
128 134
129 135 bugzilla.bzemail
130 136 The Bugzilla email address.
131 137
132 138 In addition, the Mercurial email settings must be configured. See the
133 139 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
134 140
135 141 MySQL access mode configuration:
136 142
137 143 bugzilla.host
138 144 Hostname of the MySQL server holding the Bugzilla database.
139 145 Default ``localhost``.
140 146
141 147 bugzilla.db
142 148 Name of the Bugzilla database in MySQL. Default ``bugs``.
143 149
144 150 bugzilla.user
145 151 Username to use to access MySQL server. Default ``bugs``.
146 152
147 153 bugzilla.password
148 154 Password to use to access MySQL server.
149 155
150 156 bugzilla.timeout
151 157 Database connection timeout (seconds). Default 5.
152 158
153 159 bugzilla.bzuser
154 160 Fallback Bugzilla user name to record comments with, if changeset
155 161 committer cannot be found as a Bugzilla user.
156 162
157 163 bugzilla.bzdir
158 164 Bugzilla install directory. Used by default notify. Default
159 165 ``/var/www/html/bugzilla``.
160 166
161 167 bugzilla.notify
162 168 The command to run to get Bugzilla to send bug change notification
163 169 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
164 170 id) and ``user`` (committer bugzilla email). Default depends on
165 171 version; from 2.18 it is "cd %(bzdir)s && perl -T
166 172 contrib/sendbugmail.pl %(id)s %(user)s".
167 173
168 174 Activating the extension::
169 175
170 176 [extensions]
171 177 bugzilla =
172 178
173 179 [hooks]
174 180 # run bugzilla hook on every change pulled or pushed in here
175 181 incoming.bugzilla = python:hgext.bugzilla.hook
176 182
177 183 Example configurations:
178 184
179 185 XMLRPC example configuration. This uses the Bugzilla at
180 186 ``http://my-project.org/bugzilla``, logging in as user
181 187 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
182 188 collection of Mercurial repositories in ``/var/local/hg/repos/``,
183 189 with a web interface at ``http://my-project.org/hg``. ::
184 190
185 191 [bugzilla]
186 192 bzurl=http://my-project.org/bugzilla
187 193 user=bugmail@my-project.org
188 194 password=plugh
189 195 version=xmlrpc
190 196 template=Changeset {node|short} in {root|basename}.
191 197 {hgweb}/{webroot}/rev/{node|short}\\n
192 198 {desc}\\n
193 199 strip=5
194 200
195 201 [web]
196 202 baseurl=http://my-project.org/hg
197 203
198 204 XMLRPC+email example configuration. This uses the Bugzilla at
199 205 ``http://my-project.org/bugzilla``, logging in as user
200 206 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
201 207 collection of Mercurial repositories in ``/var/local/hg/repos/``,
202 208 with a web interface at ``http://my-project.org/hg``. Bug comments
203 209 are sent to the Bugzilla email address
204 210 ``bugzilla@my-project.org``. ::
205 211
206 212 [bugzilla]
207 213 bzurl=http://my-project.org/bugzilla
208 214 user=bugmail@my-project.org
209 215 password=plugh
210 216 version=xmlrpc
211 217 bzemail=bugzilla@my-project.org
212 218 template=Changeset {node|short} in {root|basename}.
213 219 {hgweb}/{webroot}/rev/{node|short}\\n
214 220 {desc}\\n
215 221 strip=5
216 222
217 223 [web]
218 224 baseurl=http://my-project.org/hg
219 225
220 226 [usermap]
221 227 user@emaildomain.com=user.name@bugzilladomain.com
222 228
223 229 MySQL example configuration. This has a local Bugzilla 3.2 installation
224 230 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
225 231 the Bugzilla database name is ``bugs`` and MySQL is
226 232 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
227 233 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
228 234 with a web interface at ``http://my-project.org/hg``. ::
229 235
230 236 [bugzilla]
231 237 host=localhost
232 238 password=XYZZY
233 239 version=3.0
234 240 bzuser=unknown@domain.com
235 241 bzdir=/opt/bugzilla-3.2
236 242 template=Changeset {node|short} in {root|basename}.
237 243 {hgweb}/{webroot}/rev/{node|short}\\n
238 244 {desc}\\n
239 245 strip=5
240 246
241 247 [web]
242 248 baseurl=http://my-project.org/hg
243 249
244 250 [usermap]
245 251 user@emaildomain.com=user.name@bugzilladomain.com
246 252
247 253 All the above add a comment to the Bugzilla bug record of the form::
248 254
249 255 Changeset 3b16791d6642 in repository-name.
250 256 http://my-project.org/hg/repository-name/rev/3b16791d6642
251 257
252 258 Changeset commit comment. Bug 1234.
253 259 '''
254 260
255 261 from mercurial.i18n import _
256 262 from mercurial.node import short
257 263 from mercurial import cmdutil, mail, templater, util
258 264 import re, time, urlparse, xmlrpclib
259 265
260 266 class bzaccess(object):
261 267 '''Base class for access to Bugzilla.'''
262 268
263 269 def __init__(self, ui):
264 270 self.ui = ui
265 271 usermap = self.ui.config('bugzilla', 'usermap')
266 272 if usermap:
267 273 self.ui.readconfig(usermap, sections=['usermap'])
268 274
269 275 def map_committer(self, user):
270 276 '''map name of committer to Bugzilla user name.'''
271 277 for committer, bzuser in self.ui.configitems('usermap'):
272 278 if committer.lower() == user.lower():
273 279 return bzuser
274 280 return user
275 281
276 282 # Methods to be implemented by access classes.
277 283 #
278 284 # 'bugs' is a dict keyed on bug id, where values are a dict holding
279 # updates to bug state. Currently no states are recognised, but this
280 # will change soon.
285 # updates to bug state. Recognised dict keys are:
286 #
287 # 'hours': Value, float containing work hours to be updated.
288
281 289 def filter_real_bug_ids(self, bugs):
282 290 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
283 291 pass
284 292
285 293 def filter_cset_known_bug_ids(self, node, bugs):
286 294 '''remove bug IDs where node occurs in comment text from bugs.'''
287 295 pass
288 296
289 297 def updatebug(self, bugid, newstate, text, committer):
290 298 '''update the specified bug. Add comment text and set new states.
291 299
292 300 If possible add the comment as being from the committer of
293 301 the changeset. Otherwise use the default Bugzilla user.
294 302 '''
295 303 pass
296 304
297 305 def notify(self, bugs, committer):
298 306 '''Force sending of Bugzilla notification emails.
299 307
300 308 Only required if the access method does not trigger notification
301 309 emails automatically.
302 310 '''
303 311 pass
304 312
305 313 # Bugzilla via direct access to MySQL database.
306 314 class bzmysql(bzaccess):
307 315 '''Support for direct MySQL access to Bugzilla.
308 316
309 317 The earliest Bugzilla version this is tested with is version 2.16.
310 318
311 319 If your Bugzilla is version 3.2 or above, you are strongly
312 320 recommended to use the XMLRPC access method instead.
313 321 '''
314 322
315 323 @staticmethod
316 324 def sql_buglist(ids):
317 325 '''return SQL-friendly list of bug ids'''
318 326 return '(' + ','.join(map(str, ids)) + ')'
319 327
320 328 _MySQLdb = None
321 329
322 330 def __init__(self, ui):
323 331 try:
324 332 import MySQLdb as mysql
325 333 bzmysql._MySQLdb = mysql
326 334 except ImportError, err:
327 335 raise util.Abort(_('python mysql support not available: %s') % err)
328 336
329 337 bzaccess.__init__(self, ui)
330 338
331 339 host = self.ui.config('bugzilla', 'host', 'localhost')
332 340 user = self.ui.config('bugzilla', 'user', 'bugs')
333 341 passwd = self.ui.config('bugzilla', 'password')
334 342 db = self.ui.config('bugzilla', 'db', 'bugs')
335 343 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
336 344 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
337 345 (host, db, user, '*' * len(passwd)))
338 346 self.conn = bzmysql._MySQLdb.connect(host=host,
339 347 user=user, passwd=passwd,
340 348 db=db,
341 349 connect_timeout=timeout)
342 350 self.cursor = self.conn.cursor()
343 351 self.longdesc_id = self.get_longdesc_id()
344 352 self.user_ids = {}
345 353 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
346 354
347 355 def run(self, *args, **kwargs):
348 356 '''run a query.'''
349 357 self.ui.note(_('query: %s %s\n') % (args, kwargs))
350 358 try:
351 359 self.cursor.execute(*args, **kwargs)
352 360 except bzmysql._MySQLdb.MySQLError:
353 361 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
354 362 raise
355 363
356 364 def get_longdesc_id(self):
357 365 '''get identity of longdesc field'''
358 366 self.run('select fieldid from fielddefs where name = "longdesc"')
359 367 ids = self.cursor.fetchall()
360 368 if len(ids) != 1:
361 369 raise util.Abort(_('unknown database schema'))
362 370 return ids[0][0]
363 371
364 372 def filter_real_bug_ids(self, bugs):
365 373 '''filter not-existing bugs from set.'''
366 374 self.run('select bug_id from bugs where bug_id in %s' %
367 375 bzmysql.sql_buglist(bugs.keys()))
368 376 existing = [id for (id,) in self.cursor.fetchall()]
369 377 for id in bugs.keys():
370 378 if id not in existing:
371 379 self.ui.status(_('bug %d does not exist\n') % id)
372 380 del bugs[id]
373 381
374 382 def filter_cset_known_bug_ids(self, node, bugs):
375 383 '''filter bug ids that already refer to this changeset from set.'''
376 384 self.run('''select bug_id from longdescs where
377 385 bug_id in %s and thetext like "%%%s%%"''' %
378 386 (bzmysql.sql_buglist(bugs.keys()), short(node)))
379 387 for (id,) in self.cursor.fetchall():
380 388 self.ui.status(_('bug %d already knows about changeset %s\n') %
381 389 (id, short(node)))
382 390 del bugs[id]
383 391
384 392 def notify(self, bugs, committer):
385 393 '''tell bugzilla to send mail.'''
386 394 self.ui.status(_('telling bugzilla to send mail:\n'))
387 395 (user, userid) = self.get_bugzilla_user(committer)
388 396 for id in bugs.keys():
389 397 self.ui.status(_(' bug %s\n') % id)
390 398 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
391 399 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
392 400 try:
393 401 # Backwards-compatible with old notify string, which
394 402 # took one string. This will throw with a new format
395 403 # string.
396 404 cmd = cmdfmt % id
397 405 except TypeError:
398 406 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
399 407 self.ui.note(_('running notify command %s\n') % cmd)
400 408 fp = util.popen('(%s) 2>&1' % cmd)
401 409 out = fp.read()
402 410 ret = fp.close()
403 411 if ret:
404 412 self.ui.warn(out)
405 413 raise util.Abort(_('bugzilla notify command %s') %
406 414 util.explainexit(ret)[0])
407 415 self.ui.status(_('done\n'))
408 416
409 417 def get_user_id(self, user):
410 418 '''look up numeric bugzilla user id.'''
411 419 try:
412 420 return self.user_ids[user]
413 421 except KeyError:
414 422 try:
415 423 userid = int(user)
416 424 except ValueError:
417 425 self.ui.note(_('looking up user %s\n') % user)
418 426 self.run('''select userid from profiles
419 427 where login_name like %s''', user)
420 428 all = self.cursor.fetchall()
421 429 if len(all) != 1:
422 430 raise KeyError(user)
423 431 userid = int(all[0][0])
424 432 self.user_ids[user] = userid
425 433 return userid
426 434
427 435 def get_bugzilla_user(self, committer):
428 436 '''See if committer is a registered bugzilla user. Return
429 437 bugzilla username and userid if so. If not, return default
430 438 bugzilla username and userid.'''
431 439 user = self.map_committer(committer)
432 440 try:
433 441 userid = self.get_user_id(user)
434 442 except KeyError:
435 443 try:
436 444 defaultuser = self.ui.config('bugzilla', 'bzuser')
437 445 if not defaultuser:
438 446 raise util.Abort(_('cannot find bugzilla user id for %s') %
439 447 user)
440 448 userid = self.get_user_id(defaultuser)
441 449 user = defaultuser
442 450 except KeyError:
443 451 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
444 452 (user, defaultuser))
445 453 return (user, userid)
446 454
447 455 def updatebug(self, bugid, newstate, text, committer):
448 456 '''update bug state with comment text.
449 457
450 458 Try adding comment as committer of changeset, otherwise as
451 459 default bugzilla user.'''
460 if len(newstate) > 0:
461 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
462
452 463 (user, userid) = self.get_bugzilla_user(committer)
453 464 now = time.strftime('%Y-%m-%d %H:%M:%S')
454 465 self.run('''insert into longdescs
455 466 (bug_id, who, bug_when, thetext)
456 467 values (%s, %s, %s, %s)''',
457 468 (bugid, userid, now, text))
458 469 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
459 470 values (%s, %s, %s, %s)''',
460 471 (bugid, userid, now, self.longdesc_id))
461 472 self.conn.commit()
462 473
463 474 class bzmysql_2_18(bzmysql):
464 475 '''support for bugzilla 2.18 series.'''
465 476
466 477 def __init__(self, ui):
467 478 bzmysql.__init__(self, ui)
468 479 self.default_notify = \
469 480 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
470 481
471 482 class bzmysql_3_0(bzmysql_2_18):
472 483 '''support for bugzilla 3.0 series.'''
473 484
474 485 def __init__(self, ui):
475 486 bzmysql_2_18.__init__(self, ui)
476 487
477 488 def get_longdesc_id(self):
478 489 '''get identity of longdesc field'''
479 490 self.run('select id from fielddefs where name = "longdesc"')
480 491 ids = self.cursor.fetchall()
481 492 if len(ids) != 1:
482 493 raise util.Abort(_('unknown database schema'))
483 494 return ids[0][0]
484 495
485 496 # Buzgilla via XMLRPC interface.
486 497
487 498 class cookietransportrequest(object):
488 499 """A Transport request method that retains cookies over its lifetime.
489 500
490 501 The regular xmlrpclib transports ignore cookies. Which causes
491 502 a bit of a problem when you need a cookie-based login, as with
492 503 the Bugzilla XMLRPC interface.
493 504
494 505 So this is a helper for defining a Transport which looks for
495 506 cookies being set in responses and saves them to add to all future
496 507 requests.
497 508 """
498 509
499 510 # Inspiration drawn from
500 511 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
501 512 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
502 513
503 514 cookies = []
504 515 def send_cookies(self, connection):
505 516 if self.cookies:
506 517 for cookie in self.cookies:
507 518 connection.putheader("Cookie", cookie)
508 519
509 520 def request(self, host, handler, request_body, verbose=0):
510 521 self.verbose = verbose
511 522 self.accept_gzip_encoding = False
512 523
513 524 # issue XML-RPC request
514 525 h = self.make_connection(host)
515 526 if verbose:
516 527 h.set_debuglevel(1)
517 528
518 529 self.send_request(h, handler, request_body)
519 530 self.send_host(h, host)
520 531 self.send_cookies(h)
521 532 self.send_user_agent(h)
522 533 self.send_content(h, request_body)
523 534
524 535 # Deal with differences between Python 2.4-2.6 and 2.7.
525 536 # In the former h is a HTTP(S). In the latter it's a
526 537 # HTTP(S)Connection. Luckily, the 2.4-2.6 implementation of
527 538 # HTTP(S) has an underlying HTTP(S)Connection, so extract
528 539 # that and use it.
529 540 try:
530 541 response = h.getresponse()
531 542 except AttributeError:
532 543 response = h._conn.getresponse()
533 544
534 545 # Add any cookie definitions to our list.
535 546 for header in response.msg.getallmatchingheaders("Set-Cookie"):
536 547 val = header.split(": ", 1)[1]
537 548 cookie = val.split(";", 1)[0]
538 549 self.cookies.append(cookie)
539 550
540 551 if response.status != 200:
541 552 raise xmlrpclib.ProtocolError(host + handler, response.status,
542 553 response.reason, response.msg.headers)
543 554
544 555 payload = response.read()
545 556 parser, unmarshaller = self.getparser()
546 557 parser.feed(payload)
547 558 parser.close()
548 559
549 560 return unmarshaller.close()
550 561
551 562 # The explicit calls to the underlying xmlrpclib __init__() methods are
552 563 # necessary. The xmlrpclib.Transport classes are old-style classes, and
553 564 # it turns out their __init__() doesn't get called when doing multiple
554 565 # inheritance with a new-style class.
555 566 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
556 567 def __init__(self, use_datetime=0):
557 568 xmlrpclib.Transport.__init__(self, use_datetime)
558 569
559 570 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
560 571 def __init__(self, use_datetime=0):
561 572 xmlrpclib.SafeTransport.__init__(self, use_datetime)
562 573
563 574 class bzxmlrpc(bzaccess):
564 575 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
565 576
566 577 Requires a minimum Bugzilla version 3.4.
567 578 """
568 579
569 580 def __init__(self, ui):
570 581 bzaccess.__init__(self, ui)
571 582
572 583 bzweb = self.ui.config('bugzilla', 'bzurl',
573 584 'http://localhost/bugzilla/')
574 585 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
575 586
576 587 user = self.ui.config('bugzilla', 'user', 'bugs')
577 588 passwd = self.ui.config('bugzilla', 'password')
578 589
579 590 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
580 591 self.bzproxy.User.login(dict(login=user, password=passwd))
581 592
582 593 def transport(self, uri):
583 594 if urlparse.urlparse(uri, "http")[0] == "https":
584 595 return cookiesafetransport()
585 596 else:
586 597 return cookietransport()
587 598
588 599 def get_bug_comments(self, id):
589 600 """Return a string with all comment text for a bug."""
590 601 c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
591 602 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
592 603
593 604 def filter_real_bug_ids(self, bugs):
594 605 probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
595 606 include_fields=[],
596 607 permissive=True))
597 608 for badbug in probe['faults']:
598 609 id = badbug['id']
599 610 self.ui.status(_('bug %d does not exist\n') % id)
600 611 del bugs[id]
601 612
602 613 def filter_cset_known_bug_ids(self, node, bugs):
603 614 for id in sorted(bugs.keys()):
604 615 if self.get_bug_comments(id).find(short(node)) != -1:
605 616 self.ui.status(_('bug %d already knows about changeset %s\n') %
606 617 (id, short(node)))
607 618 del bugs[id]
608 619
609 620 def updatebug(self, bugid, newstate, text, committer):
610 621 args = dict(id=bugid, comment=text)
622 if 'hours' in newstate:
623 args['work_time'] = newstate['hours']
611 624 self.bzproxy.Bug.add_comment(args)
612 625
613 626 class bzxmlrpcemail(bzxmlrpc):
614 627 """Read data from Bugzilla via XMLRPC, send updates via email.
615 628
616 629 Advantages of sending updates via email:
617 630 1. Comments can be added as any user, not just logged in user.
618 2. Bug statuses and other fields not accessible via XMLRPC can
619 be updated. This is not currently used.
631 2. Bug statuses or other fields not accessible via XMLRPC can
632 potentially be updated.
633
634 Currently all status updates recognised can be done via XMLRPC, so
635 item 1 is the only actual advantage.
620 636 """
621 637
622 638 def __init__(self, ui):
623 639 bzxmlrpc.__init__(self, ui)
624 640
625 641 self.bzemail = self.ui.config('bugzilla', 'bzemail')
626 642 if not self.bzemail:
627 643 raise util.Abort(_("configuration 'bzemail' missing"))
628 644 mail.validateconfig(self.ui)
629 645
630 646 def send_bug_modify_email(self, bugid, commands, comment, committer):
631 647 '''send modification message to Bugzilla bug via email.
632 648
633 649 The message format is documented in the Bugzilla email_in.pl
634 650 specification. commands is a list of command lines, comment is the
635 651 comment text.
636 652
637 653 To stop users from crafting commit comments with
638 654 Bugzilla commands, specify the bug ID via the message body, rather
639 655 than the subject line, and leave a blank line after it.
640 656 '''
641 657 user = self.map_committer(committer)
642 658 matches = self.bzproxy.User.get(dict(match=[user]))
643 659 if not matches['users']:
644 660 user = self.ui.config('bugzilla', 'user', 'bugs')
645 661 matches = self.bzproxy.User.get(dict(match=[user]))
646 662 if not matches['users']:
647 663 raise util.Abort(_("default bugzilla user %s email not found") %
648 664 user)
649 665 user = matches['users'][0]['email']
650 666
651 667 text = "\n".join(commands) + "\n@bug_id = %d\n\n" % bugid + comment
652 668
653 669 _charsets = mail._charsets(self.ui)
654 670 user = mail.addressencode(self.ui, user, _charsets)
655 671 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
656 672 msg = mail.mimeencode(self.ui, text, _charsets)
657 673 msg['From'] = user
658 674 msg['To'] = bzemail
659 675 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
660 676 sendmail = mail.connect(self.ui)
661 677 sendmail(user, bzemail, msg.as_string())
662 678
663 679 def updatebug(self, bugid, newstate, text, committer):
664 self.send_bug_modify_email(bugid, [], text, committer)
680 cmds = []
681 if 'hours' in newstate:
682 cmds.append(self.makecommandline("work_time", newstate['hours']))
683 self.send_bug_modify_email(bugid, cmds, text, committer)
665 684
666 685 class bugzilla(object):
667 686 # supported versions of bugzilla. different versions have
668 687 # different schemas.
669 688 _versions = {
670 689 '2.16': bzmysql,
671 690 '2.18': bzmysql_2_18,
672 691 '3.0': bzmysql_3_0,
673 692 'xmlrpc': bzxmlrpc,
674 693 'xmlrpc+email': bzxmlrpcemail
675 694 }
676 695
677 696 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
678 r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
697 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
698 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
679 699
680 700 _bz = None
681 701
682 702 def __init__(self, ui, repo):
683 703 self.ui = ui
684 704 self.repo = repo
685 705
686 706 def bz(self):
687 707 '''return object that knows how to talk to bugzilla version in
688 708 use.'''
689 709
690 710 if bugzilla._bz is None:
691 711 bzversion = self.ui.config('bugzilla', 'version')
692 712 try:
693 713 bzclass = bugzilla._versions[bzversion]
694 714 except KeyError:
695 715 raise util.Abort(_('bugzilla version %s not supported') %
696 716 bzversion)
697 717 bugzilla._bz = bzclass(self.ui)
698 718 return bugzilla._bz
699 719
700 720 def __getattr__(self, key):
701 721 return getattr(self.bz(), key)
702 722
703 723 _bug_re = None
704 724 _split_re = None
705 725
706 726 def find_bugs(self, ctx):
707 727 '''return bugs dictionary created from commit comment.
708 728
709 729 Extract bug info from changeset comments. Filter out any that are
710 730 not known to Bugzilla, and any that already have a reference to
711 731 the given changeset in their comments.
712 732 '''
713 733 if bugzilla._bug_re is None:
714 734 bugzilla._bug_re = re.compile(
715 735 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
716 736 re.IGNORECASE)
717 737 bugzilla._split_re = re.compile(r'\D+')
718 738 start = 0
739 hours = 0.0
719 740 bugs = {}
720 741 while True:
742 bugattribs = {}
721 743 m = bugzilla._bug_re.search(ctx.description(), start)
722 744 if not m:
723 745 break
724 746 start = m.end()
725 for id in bugzilla._split_re.split(m.group(1)):
747 try:
748 ids = m.group('ids')
749 except IndexError:
750 ids = m.group(1)
751 try:
752 hours = float(m.group('hours'))
753 bugattribs['hours'] = hours
754 except IndexError:
755 pass
756 except TypeError:
757 pass
758 except ValueError:
759 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
760
761 for id in bugzilla._split_re.split(ids):
726 762 if not id:
727 763 continue
728 bugs[int(id)] = {}
764 bugs[int(id)] = bugattribs
729 765 if bugs:
730 766 self.filter_real_bug_ids(bugs)
731 767 if bugs:
732 768 self.filter_cset_known_bug_ids(ctx.node(), bugs)
733 769 return bugs
734 770
735 771 def update(self, bugid, newstate, ctx):
736 772 '''update bugzilla bug with reference to changeset.'''
737 773
738 774 def webroot(root):
739 775 '''strip leading prefix of repo root and turn into
740 776 url-safe path.'''
741 777 count = int(self.ui.config('bugzilla', 'strip', 0))
742 778 root = util.pconvert(root)
743 779 while count > 0:
744 780 c = root.find('/')
745 781 if c == -1:
746 782 break
747 783 root = root[c + 1:]
748 784 count -= 1
749 785 return root
750 786
751 787 mapfile = self.ui.config('bugzilla', 'style')
752 788 tmpl = self.ui.config('bugzilla', 'template')
753 789 t = cmdutil.changeset_templater(self.ui, self.repo,
754 790 False, None, mapfile, False)
755 791 if not mapfile and not tmpl:
756 792 tmpl = _('changeset {node|short} in repo {root} refers '
757 793 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
758 794 if tmpl:
759 795 tmpl = templater.parsestring(tmpl, quoted=False)
760 796 t.use_template(tmpl)
761 797 self.ui.pushbuffer()
762 798 t.show(ctx, changes=ctx.changeset(),
763 799 bug=str(bugid),
764 800 hgweb=self.ui.config('web', 'baseurl'),
765 801 root=self.repo.root,
766 802 webroot=webroot(self.repo.root))
767 803 data = self.ui.popbuffer()
768 804 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
769 805
770 806 def hook(ui, repo, hooktype, node=None, **kwargs):
771 807 '''add comment to bugzilla for each changeset that refers to a
772 808 bugzilla bug id. only add a comment once per bug, so same change
773 809 seen multiple times does not fill bug with duplicate data.'''
774 810 if node is None:
775 811 raise util.Abort(_('hook type %s does not pass a changeset id') %
776 812 hooktype)
777 813 try:
778 814 bz = bugzilla(ui, repo)
779 815 ctx = repo[node]
780 816 bugs = bz.find_bugs(ctx)
781 817 if bugs:
782 818 for bug in bugs:
783 819 bz.update(bug, bugs[bug], ctx)
784 820 bz.notify(bugs, util.email(ctx.user()))
785 821 except Exception, e:
786 822 raise util.Abort(_('Bugzilla error: %s') % e)
787 823
General Comments 0
You need to be logged in to leave comments. Login now