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