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