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