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