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