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