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