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