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