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