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