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