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