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