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