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