##// END OF EJS Templates
bugzilla: stop bugs always being marked as fixed in xmlrpc (issue3484)...
Jim Hague -
r16876:fdc87904 stable
parent child Browse files
Show More
@@ -1,912 +1,913
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-2 Jim Hague <jim.hague@acm.org>
4 # Copyright 2011-2 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 Three basic modes of access to Bugzilla are provided:
18 Three basic modes of access to Bugzilla are provided:
19
19
20 1. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
20 1. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
21
21
22 2. Check data via the Bugzilla XMLRPC interface and submit bug change
22 2. Check data via the Bugzilla XMLRPC interface and submit bug change
23 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
23 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
24
24
25 3. Writing directly to the Bugzilla database. Only Bugzilla installations
25 3. Writing directly to the Bugzilla database. Only Bugzilla installations
26 using MySQL are supported. Requires Python MySQLdb.
26 using MySQL are supported. Requires Python MySQLdb.
27
27
28 Writing directly to the database is susceptible to schema changes, and
28 Writing directly to the database is susceptible to schema changes, and
29 relies on a Bugzilla contrib script to send out bug change
29 relies on a Bugzilla contrib script to send out bug change
30 notification emails. This script runs as the user running Mercurial,
30 notification emails. This script runs as the user running Mercurial,
31 must be run on the host with the Bugzilla install, and requires
31 must be run on the host with the Bugzilla install, and requires
32 permission to read Bugzilla configuration details and the necessary
32 permission to read Bugzilla configuration details and the necessary
33 MySQL user and password to have full access rights to the Bugzilla
33 MySQL user and password to have full access rights to the Bugzilla
34 database. For these reasons this access mode is now considered
34 database. For these reasons this access mode is now considered
35 deprecated, and will not be updated for new Bugzilla versions going
35 deprecated, and will not be updated for new Bugzilla versions going
36 forward. Only adding comments is supported in this access mode.
36 forward. Only adding comments is supported in this access mode.
37
37
38 Access via XMLRPC needs a Bugzilla username and password to be specified
38 Access via XMLRPC needs a Bugzilla username and password to be specified
39 in the configuration. Comments are added under that username. Since the
39 in the configuration. Comments are added under that username. Since the
40 configuration must be readable by all Mercurial users, it is recommended
40 configuration must be readable by all Mercurial users, it is recommended
41 that the rights of that user are restricted in Bugzilla to the minimum
41 that the rights of that user are restricted in Bugzilla to the minimum
42 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
42 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
43
43
44 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
44 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
45 email to the Bugzilla email interface to submit comments to bugs.
45 email to the Bugzilla email interface to submit comments to bugs.
46 The From: address in the email is set to the email address of the Mercurial
46 The From: address in the email is set to the email address of the Mercurial
47 user, so the comment appears to come from the Mercurial user. In the event
47 user, so the comment appears to come from the Mercurial user. In the event
48 that the Mercurial user email is not recognised by Bugzilla as a Bugzilla
48 that the Mercurial user email is not recognised by Bugzilla as a Bugzilla
49 user, the email associated with the Bugzilla username used to log into
49 user, the email associated with the Bugzilla username used to log into
50 Bugzilla is used instead as the source of the comment. Marking bugs fixed
50 Bugzilla is used instead as the source of the comment. Marking bugs fixed
51 works on all supported Bugzilla versions.
51 works on all supported Bugzilla versions.
52
52
53 Configuration items common to all access modes:
53 Configuration items common to all access modes:
54
54
55 bugzilla.version
55 bugzilla.version
56 This access type to use. Values recognised are:
56 This access type to use. Values recognised are:
57
57
58 :``xmlrpc``: Bugzilla XMLRPC interface.
58 :``xmlrpc``: Bugzilla XMLRPC interface.
59 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
59 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
60 :``3.0``: MySQL access, Bugzilla 3.0 and later.
60 :``3.0``: MySQL access, Bugzilla 3.0 and later.
61 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
61 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
62 including 3.0.
62 including 3.0.
63 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
63 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
64 including 2.18.
64 including 2.18.
65
65
66 bugzilla.regexp
66 bugzilla.regexp
67 Regular expression to match bug IDs for update in changeset commit message.
67 Regular expression to match bug IDs for update in changeset commit message.
68 It must contain one "()" named group ``<ids>`` containing the bug
68 It must contain one "()" named group ``<ids>`` containing the bug
69 IDs separated by non-digit characters. It may also contain
69 IDs separated by non-digit characters. It may also contain
70 a named group ``<hours>`` with a floating-point number giving the
70 a named group ``<hours>`` with a floating-point number giving the
71 hours worked on the bug. If no named groups are present, the first
71 hours worked on the bug. If no named groups are present, the first
72 "()" group is assumed to contain the bug IDs, and work time is not
72 "()" group is assumed to contain the bug IDs, and work time is not
73 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
73 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
74 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
74 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
75 variations thereof, followed by an hours number prefixed by ``h`` or
75 variations thereof, followed by an hours number prefixed by ``h`` or
76 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
76 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
77
77
78 bugzilla.fixregexp
78 bugzilla.fixregexp
79 Regular expression to match bug IDs for marking fixed in changeset
79 Regular expression to match bug IDs for marking fixed in changeset
80 commit message. This must contain a "()" named group ``<ids>` containing
80 commit message. This must contain a "()" named group ``<ids>` containing
81 the bug IDs separated by non-digit characters. It may also contain
81 the bug IDs separated by non-digit characters. It may also contain
82 a named group ``<hours>`` with a floating-point number giving the
82 a named group ``<hours>`` with a floating-point number giving the
83 hours worked on the bug. If no named groups are present, the first
83 hours worked on the bug. If no named groups are present, the first
84 "()" group is assumed to contain the bug IDs, and work time is not
84 "()" group is assumed to contain the bug IDs, and work time is not
85 updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``,
85 updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``,
86 ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and
86 ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and
87 variations thereof, followed by an hours number prefixed by ``h`` or
87 variations thereof, followed by an hours number prefixed by ``h`` or
88 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
88 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
89
89
90 bugzilla.fixstatus
90 bugzilla.fixstatus
91 The status to set a bug to when marking fixed. Default ``RESOLVED``.
91 The status to set a bug to when marking fixed. Default ``RESOLVED``.
92
92
93 bugzilla.fixresolution
93 bugzilla.fixresolution
94 The resolution to set a bug to when marking fixed. Default ``FIXED``.
94 The resolution to set a bug to when marking fixed. Default ``FIXED``.
95
95
96 bugzilla.style
96 bugzilla.style
97 The style file to use when formatting comments.
97 The style file to use when formatting comments.
98
98
99 bugzilla.template
99 bugzilla.template
100 Template to use when formatting comments. Overrides style if
100 Template to use when formatting comments. Overrides style if
101 specified. In addition to the usual Mercurial keywords, the
101 specified. In addition to the usual Mercurial keywords, the
102 extension specifies:
102 extension specifies:
103
103
104 :``{bug}``: The Bugzilla bug ID.
104 :``{bug}``: The Bugzilla bug ID.
105 :``{root}``: The full pathname of the Mercurial repository.
105 :``{root}``: The full pathname of the Mercurial repository.
106 :``{webroot}``: Stripped pathname of the Mercurial repository.
106 :``{webroot}``: Stripped pathname of the Mercurial repository.
107 :``{hgweb}``: Base URL for browsing Mercurial repositories.
107 :``{hgweb}``: Base URL for browsing Mercurial repositories.
108
108
109 Default ``changeset {node|short} in repo {root} refers to bug
109 Default ``changeset {node|short} in repo {root} refers to bug
110 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
110 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
111
111
112 bugzilla.strip
112 bugzilla.strip
113 The number of path separator characters to strip from the front of
113 The number of path separator characters to strip from the front of
114 the Mercurial repository path (``{root}`` in templates) to produce
114 the Mercurial repository path (``{root}`` in templates) to produce
115 ``{webroot}``. For example, a repository with ``{root}``
115 ``{webroot}``. For example, a repository with ``{root}``
116 ``/var/local/my-project`` with a strip of 2 gives a value for
116 ``/var/local/my-project`` with a strip of 2 gives a value for
117 ``{webroot}`` of ``my-project``. Default 0.
117 ``{webroot}`` of ``my-project``. Default 0.
118
118
119 web.baseurl
119 web.baseurl
120 Base URL for browsing Mercurial repositories. Referenced from
120 Base URL for browsing Mercurial repositories. Referenced from
121 templates as ``{hgweb}``.
121 templates as ``{hgweb}``.
122
122
123 Configuration items common to XMLRPC+email and MySQL access modes:
123 Configuration items common to XMLRPC+email and MySQL access modes:
124
124
125 bugzilla.usermap
125 bugzilla.usermap
126 Path of file containing Mercurial committer email to Bugzilla user email
126 Path of file containing Mercurial committer email to Bugzilla user email
127 mappings. If specified, the file should contain one mapping per
127 mappings. If specified, the file should contain one mapping per
128 line::
128 line::
129
129
130 committer = Bugzilla user
130 committer = Bugzilla user
131
131
132 See also the ``[usermap]`` section.
132 See also the ``[usermap]`` section.
133
133
134 The ``[usermap]`` section is used to specify mappings of Mercurial
134 The ``[usermap]`` section is used to specify mappings of Mercurial
135 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
135 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
136 Contains entries of the form ``committer = Bugzilla user``.
136 Contains entries of the form ``committer = Bugzilla user``.
137
137
138 XMLRPC access mode configuration:
138 XMLRPC access mode configuration:
139
139
140 bugzilla.bzurl
140 bugzilla.bzurl
141 The base URL for the Bugzilla installation.
141 The base URL for the Bugzilla installation.
142 Default ``http://localhost/bugzilla``.
142 Default ``http://localhost/bugzilla``.
143
143
144 bugzilla.user
144 bugzilla.user
145 The username to use to log into Bugzilla via XMLRPC. Default
145 The username to use to log into Bugzilla via XMLRPC. Default
146 ``bugs``.
146 ``bugs``.
147
147
148 bugzilla.password
148 bugzilla.password
149 The password for Bugzilla login.
149 The password for Bugzilla login.
150
150
151 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
151 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
152 and also:
152 and also:
153
153
154 bugzilla.bzemail
154 bugzilla.bzemail
155 The Bugzilla email address.
155 The Bugzilla email address.
156
156
157 In addition, the Mercurial email settings must be configured. See the
157 In addition, the Mercurial email settings must be configured. See the
158 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
158 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
159
159
160 MySQL access mode configuration:
160 MySQL access mode configuration:
161
161
162 bugzilla.host
162 bugzilla.host
163 Hostname of the MySQL server holding the Bugzilla database.
163 Hostname of the MySQL server holding the Bugzilla database.
164 Default ``localhost``.
164 Default ``localhost``.
165
165
166 bugzilla.db
166 bugzilla.db
167 Name of the Bugzilla database in MySQL. Default ``bugs``.
167 Name of the Bugzilla database in MySQL. Default ``bugs``.
168
168
169 bugzilla.user
169 bugzilla.user
170 Username to use to access MySQL server. Default ``bugs``.
170 Username to use to access MySQL server. Default ``bugs``.
171
171
172 bugzilla.password
172 bugzilla.password
173 Password to use to access MySQL server.
173 Password to use to access MySQL server.
174
174
175 bugzilla.timeout
175 bugzilla.timeout
176 Database connection timeout (seconds). Default 5.
176 Database connection timeout (seconds). Default 5.
177
177
178 bugzilla.bzuser
178 bugzilla.bzuser
179 Fallback Bugzilla user name to record comments with, if changeset
179 Fallback Bugzilla user name to record comments with, if changeset
180 committer cannot be found as a Bugzilla user.
180 committer cannot be found as a Bugzilla user.
181
181
182 bugzilla.bzdir
182 bugzilla.bzdir
183 Bugzilla install directory. Used by default notify. Default
183 Bugzilla install directory. Used by default notify. Default
184 ``/var/www/html/bugzilla``.
184 ``/var/www/html/bugzilla``.
185
185
186 bugzilla.notify
186 bugzilla.notify
187 The command to run to get Bugzilla to send bug change notification
187 The command to run to get Bugzilla to send bug change notification
188 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
188 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
189 id) and ``user`` (committer bugzilla email). Default depends on
189 id) and ``user`` (committer bugzilla email). Default depends on
190 version; from 2.18 it is "cd %(bzdir)s && perl -T
190 version; from 2.18 it is "cd %(bzdir)s && perl -T
191 contrib/sendbugmail.pl %(id)s %(user)s".
191 contrib/sendbugmail.pl %(id)s %(user)s".
192
192
193 Activating the extension::
193 Activating the extension::
194
194
195 [extensions]
195 [extensions]
196 bugzilla =
196 bugzilla =
197
197
198 [hooks]
198 [hooks]
199 # run bugzilla hook on every change pulled or pushed in here
199 # run bugzilla hook on every change pulled or pushed in here
200 incoming.bugzilla = python:hgext.bugzilla.hook
200 incoming.bugzilla = python:hgext.bugzilla.hook
201
201
202 Example configurations:
202 Example configurations:
203
203
204 XMLRPC example configuration. This uses the Bugzilla at
204 XMLRPC example configuration. This uses the Bugzilla at
205 ``http://my-project.org/bugzilla``, logging in as user
205 ``http://my-project.org/bugzilla``, logging in as user
206 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
206 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
207 collection of Mercurial repositories in ``/var/local/hg/repos/``,
207 collection of Mercurial repositories in ``/var/local/hg/repos/``,
208 with a web interface at ``http://my-project.org/hg``. ::
208 with a web interface at ``http://my-project.org/hg``. ::
209
209
210 [bugzilla]
210 [bugzilla]
211 bzurl=http://my-project.org/bugzilla
211 bzurl=http://my-project.org/bugzilla
212 user=bugmail@my-project.org
212 user=bugmail@my-project.org
213 password=plugh
213 password=plugh
214 version=xmlrpc
214 version=xmlrpc
215 template=Changeset {node|short} in {root|basename}.
215 template=Changeset {node|short} in {root|basename}.
216 {hgweb}/{webroot}/rev/{node|short}\\n
216 {hgweb}/{webroot}/rev/{node|short}\\n
217 {desc}\\n
217 {desc}\\n
218 strip=5
218 strip=5
219
219
220 [web]
220 [web]
221 baseurl=http://my-project.org/hg
221 baseurl=http://my-project.org/hg
222
222
223 XMLRPC+email example configuration. This uses the Bugzilla at
223 XMLRPC+email example configuration. This uses the Bugzilla at
224 ``http://my-project.org/bugzilla``, logging in as user
224 ``http://my-project.org/bugzilla``, logging in as user
225 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
225 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
226 collection of Mercurial repositories in ``/var/local/hg/repos/``,
226 collection of Mercurial repositories in ``/var/local/hg/repos/``,
227 with a web interface at ``http://my-project.org/hg``. Bug comments
227 with a web interface at ``http://my-project.org/hg``. Bug comments
228 are sent to the Bugzilla email address
228 are sent to the Bugzilla email address
229 ``bugzilla@my-project.org``. ::
229 ``bugzilla@my-project.org``. ::
230
230
231 [bugzilla]
231 [bugzilla]
232 bzurl=http://my-project.org/bugzilla
232 bzurl=http://my-project.org/bugzilla
233 user=bugmail@my-project.org
233 user=bugmail@my-project.org
234 password=plugh
234 password=plugh
235 version=xmlrpc
235 version=xmlrpc
236 bzemail=bugzilla@my-project.org
236 bzemail=bugzilla@my-project.org
237 template=Changeset {node|short} in {root|basename}.
237 template=Changeset {node|short} in {root|basename}.
238 {hgweb}/{webroot}/rev/{node|short}\\n
238 {hgweb}/{webroot}/rev/{node|short}\\n
239 {desc}\\n
239 {desc}\\n
240 strip=5
240 strip=5
241
241
242 [web]
242 [web]
243 baseurl=http://my-project.org/hg
243 baseurl=http://my-project.org/hg
244
244
245 [usermap]
245 [usermap]
246 user@emaildomain.com=user.name@bugzilladomain.com
246 user@emaildomain.com=user.name@bugzilladomain.com
247
247
248 MySQL example configuration. This has a local Bugzilla 3.2 installation
248 MySQL example configuration. This has a local Bugzilla 3.2 installation
249 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
249 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
250 the Bugzilla database name is ``bugs`` and MySQL is
250 the Bugzilla database name is ``bugs`` and MySQL is
251 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
251 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
252 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
252 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
253 with a web interface at ``http://my-project.org/hg``. ::
253 with a web interface at ``http://my-project.org/hg``. ::
254
254
255 [bugzilla]
255 [bugzilla]
256 host=localhost
256 host=localhost
257 password=XYZZY
257 password=XYZZY
258 version=3.0
258 version=3.0
259 bzuser=unknown@domain.com
259 bzuser=unknown@domain.com
260 bzdir=/opt/bugzilla-3.2
260 bzdir=/opt/bugzilla-3.2
261 template=Changeset {node|short} in {root|basename}.
261 template=Changeset {node|short} in {root|basename}.
262 {hgweb}/{webroot}/rev/{node|short}\\n
262 {hgweb}/{webroot}/rev/{node|short}\\n
263 {desc}\\n
263 {desc}\\n
264 strip=5
264 strip=5
265
265
266 [web]
266 [web]
267 baseurl=http://my-project.org/hg
267 baseurl=http://my-project.org/hg
268
268
269 [usermap]
269 [usermap]
270 user@emaildomain.com=user.name@bugzilladomain.com
270 user@emaildomain.com=user.name@bugzilladomain.com
271
271
272 All the above add a comment to the Bugzilla bug record of the form::
272 All the above add a comment to the Bugzilla bug record of the form::
273
273
274 Changeset 3b16791d6642 in repository-name.
274 Changeset 3b16791d6642 in repository-name.
275 http://my-project.org/hg/repository-name/rev/3b16791d6642
275 http://my-project.org/hg/repository-name/rev/3b16791d6642
276
276
277 Changeset commit comment. Bug 1234.
277 Changeset commit comment. Bug 1234.
278 '''
278 '''
279
279
280 from mercurial.i18n import _
280 from mercurial.i18n import _
281 from mercurial.node import short
281 from mercurial.node import short
282 from mercurial import cmdutil, mail, templater, util
282 from mercurial import cmdutil, mail, templater, util
283 import re, time, urlparse, xmlrpclib
283 import re, time, urlparse, xmlrpclib
284
284
285 class bzaccess(object):
285 class bzaccess(object):
286 '''Base class for access to Bugzilla.'''
286 '''Base class for access to Bugzilla.'''
287
287
288 def __init__(self, ui):
288 def __init__(self, ui):
289 self.ui = ui
289 self.ui = ui
290 usermap = self.ui.config('bugzilla', 'usermap')
290 usermap = self.ui.config('bugzilla', 'usermap')
291 if usermap:
291 if usermap:
292 self.ui.readconfig(usermap, sections=['usermap'])
292 self.ui.readconfig(usermap, sections=['usermap'])
293
293
294 def map_committer(self, user):
294 def map_committer(self, user):
295 '''map name of committer to Bugzilla user name.'''
295 '''map name of committer to Bugzilla user name.'''
296 for committer, bzuser in self.ui.configitems('usermap'):
296 for committer, bzuser in self.ui.configitems('usermap'):
297 if committer.lower() == user.lower():
297 if committer.lower() == user.lower():
298 return bzuser
298 return bzuser
299 return user
299 return user
300
300
301 # Methods to be implemented by access classes.
301 # Methods to be implemented by access classes.
302 #
302 #
303 # 'bugs' is a dict keyed on bug id, where values are a dict holding
303 # 'bugs' is a dict keyed on bug id, where values are a dict holding
304 # updates to bug state. Recognised dict keys are:
304 # updates to bug state. Recognised dict keys are:
305 #
305 #
306 # 'hours': Value, float containing work hours to be updated.
306 # 'hours': Value, float containing work hours to be updated.
307 # 'fix': If key present, bug is to be marked fixed. Value ignored.
307 # 'fix': If key present, bug is to be marked fixed. Value ignored.
308
308
309 def filter_real_bug_ids(self, bugs):
309 def filter_real_bug_ids(self, bugs):
310 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
310 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
311 pass
311 pass
312
312
313 def filter_cset_known_bug_ids(self, node, bugs):
313 def filter_cset_known_bug_ids(self, node, bugs):
314 '''remove bug IDs where node occurs in comment text from bugs.'''
314 '''remove bug IDs where node occurs in comment text from bugs.'''
315 pass
315 pass
316
316
317 def updatebug(self, bugid, newstate, text, committer):
317 def updatebug(self, bugid, newstate, text, committer):
318 '''update the specified bug. Add comment text and set new states.
318 '''update the specified bug. Add comment text and set new states.
319
319
320 If possible add the comment as being from the committer of
320 If possible add the comment as being from the committer of
321 the changeset. Otherwise use the default Bugzilla user.
321 the changeset. Otherwise use the default Bugzilla user.
322 '''
322 '''
323 pass
323 pass
324
324
325 def notify(self, bugs, committer):
325 def notify(self, bugs, committer):
326 '''Force sending of Bugzilla notification emails.
326 '''Force sending of Bugzilla notification emails.
327
327
328 Only required if the access method does not trigger notification
328 Only required if the access method does not trigger notification
329 emails automatically.
329 emails automatically.
330 '''
330 '''
331 pass
331 pass
332
332
333 # Bugzilla via direct access to MySQL database.
333 # Bugzilla via direct access to MySQL database.
334 class bzmysql(bzaccess):
334 class bzmysql(bzaccess):
335 '''Support for direct MySQL access to Bugzilla.
335 '''Support for direct MySQL access to Bugzilla.
336
336
337 The earliest Bugzilla version this is tested with is version 2.16.
337 The earliest Bugzilla version this is tested with is version 2.16.
338
338
339 If your Bugzilla is version 3.4 or above, you are strongly
339 If your Bugzilla is version 3.4 or above, you are strongly
340 recommended to use the XMLRPC access method instead.
340 recommended to use the XMLRPC access method instead.
341 '''
341 '''
342
342
343 @staticmethod
343 @staticmethod
344 def sql_buglist(ids):
344 def sql_buglist(ids):
345 '''return SQL-friendly list of bug ids'''
345 '''return SQL-friendly list of bug ids'''
346 return '(' + ','.join(map(str, ids)) + ')'
346 return '(' + ','.join(map(str, ids)) + ')'
347
347
348 _MySQLdb = None
348 _MySQLdb = None
349
349
350 def __init__(self, ui):
350 def __init__(self, ui):
351 try:
351 try:
352 import MySQLdb as mysql
352 import MySQLdb as mysql
353 bzmysql._MySQLdb = mysql
353 bzmysql._MySQLdb = mysql
354 except ImportError, err:
354 except ImportError, err:
355 raise util.Abort(_('python mysql support not available: %s') % err)
355 raise util.Abort(_('python mysql support not available: %s') % err)
356
356
357 bzaccess.__init__(self, ui)
357 bzaccess.__init__(self, ui)
358
358
359 host = self.ui.config('bugzilla', 'host', 'localhost')
359 host = self.ui.config('bugzilla', 'host', 'localhost')
360 user = self.ui.config('bugzilla', 'user', 'bugs')
360 user = self.ui.config('bugzilla', 'user', 'bugs')
361 passwd = self.ui.config('bugzilla', 'password')
361 passwd = self.ui.config('bugzilla', 'password')
362 db = self.ui.config('bugzilla', 'db', 'bugs')
362 db = self.ui.config('bugzilla', 'db', 'bugs')
363 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
363 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
364 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
364 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
365 (host, db, user, '*' * len(passwd)))
365 (host, db, user, '*' * len(passwd)))
366 self.conn = bzmysql._MySQLdb.connect(host=host,
366 self.conn = bzmysql._MySQLdb.connect(host=host,
367 user=user, passwd=passwd,
367 user=user, passwd=passwd,
368 db=db,
368 db=db,
369 connect_timeout=timeout)
369 connect_timeout=timeout)
370 self.cursor = self.conn.cursor()
370 self.cursor = self.conn.cursor()
371 self.longdesc_id = self.get_longdesc_id()
371 self.longdesc_id = self.get_longdesc_id()
372 self.user_ids = {}
372 self.user_ids = {}
373 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
373 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
374
374
375 def run(self, *args, **kwargs):
375 def run(self, *args, **kwargs):
376 '''run a query.'''
376 '''run a query.'''
377 self.ui.note(_('query: %s %s\n') % (args, kwargs))
377 self.ui.note(_('query: %s %s\n') % (args, kwargs))
378 try:
378 try:
379 self.cursor.execute(*args, **kwargs)
379 self.cursor.execute(*args, **kwargs)
380 except bzmysql._MySQLdb.MySQLError:
380 except bzmysql._MySQLdb.MySQLError:
381 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
381 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
382 raise
382 raise
383
383
384 def get_longdesc_id(self):
384 def get_longdesc_id(self):
385 '''get identity of longdesc field'''
385 '''get identity of longdesc field'''
386 self.run('select fieldid from fielddefs where name = "longdesc"')
386 self.run('select fieldid from fielddefs where name = "longdesc"')
387 ids = self.cursor.fetchall()
387 ids = self.cursor.fetchall()
388 if len(ids) != 1:
388 if len(ids) != 1:
389 raise util.Abort(_('unknown database schema'))
389 raise util.Abort(_('unknown database schema'))
390 return ids[0][0]
390 return ids[0][0]
391
391
392 def filter_real_bug_ids(self, bugs):
392 def filter_real_bug_ids(self, bugs):
393 '''filter not-existing bugs from set.'''
393 '''filter not-existing bugs from set.'''
394 self.run('select bug_id from bugs where bug_id in %s' %
394 self.run('select bug_id from bugs where bug_id in %s' %
395 bzmysql.sql_buglist(bugs.keys()))
395 bzmysql.sql_buglist(bugs.keys()))
396 existing = [id for (id,) in self.cursor.fetchall()]
396 existing = [id for (id,) in self.cursor.fetchall()]
397 for id in bugs.keys():
397 for id in bugs.keys():
398 if id not in existing:
398 if id not in existing:
399 self.ui.status(_('bug %d does not exist\n') % id)
399 self.ui.status(_('bug %d does not exist\n') % id)
400 del bugs[id]
400 del bugs[id]
401
401
402 def filter_cset_known_bug_ids(self, node, bugs):
402 def filter_cset_known_bug_ids(self, node, bugs):
403 '''filter bug ids that already refer to this changeset from set.'''
403 '''filter bug ids that already refer to this changeset from set.'''
404 self.run('''select bug_id from longdescs where
404 self.run('''select bug_id from longdescs where
405 bug_id in %s and thetext like "%%%s%%"''' %
405 bug_id in %s and thetext like "%%%s%%"''' %
406 (bzmysql.sql_buglist(bugs.keys()), short(node)))
406 (bzmysql.sql_buglist(bugs.keys()), short(node)))
407 for (id,) in self.cursor.fetchall():
407 for (id,) in self.cursor.fetchall():
408 self.ui.status(_('bug %d already knows about changeset %s\n') %
408 self.ui.status(_('bug %d already knows about changeset %s\n') %
409 (id, short(node)))
409 (id, short(node)))
410 del bugs[id]
410 del bugs[id]
411
411
412 def notify(self, bugs, committer):
412 def notify(self, bugs, committer):
413 '''tell bugzilla to send mail.'''
413 '''tell bugzilla to send mail.'''
414 self.ui.status(_('telling bugzilla to send mail:\n'))
414 self.ui.status(_('telling bugzilla to send mail:\n'))
415 (user, userid) = self.get_bugzilla_user(committer)
415 (user, userid) = self.get_bugzilla_user(committer)
416 for id in bugs.keys():
416 for id in bugs.keys():
417 self.ui.status(_(' bug %s\n') % id)
417 self.ui.status(_(' bug %s\n') % id)
418 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
418 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
419 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
419 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
420 try:
420 try:
421 # Backwards-compatible with old notify string, which
421 # Backwards-compatible with old notify string, which
422 # took one string. This will throw with a new format
422 # took one string. This will throw with a new format
423 # string.
423 # string.
424 cmd = cmdfmt % id
424 cmd = cmdfmt % id
425 except TypeError:
425 except TypeError:
426 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
426 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
427 self.ui.note(_('running notify command %s\n') % cmd)
427 self.ui.note(_('running notify command %s\n') % cmd)
428 fp = util.popen('(%s) 2>&1' % cmd)
428 fp = util.popen('(%s) 2>&1' % cmd)
429 out = fp.read()
429 out = fp.read()
430 ret = fp.close()
430 ret = fp.close()
431 if ret:
431 if ret:
432 self.ui.warn(out)
432 self.ui.warn(out)
433 raise util.Abort(_('bugzilla notify command %s') %
433 raise util.Abort(_('bugzilla notify command %s') %
434 util.explainexit(ret)[0])
434 util.explainexit(ret)[0])
435 self.ui.status(_('done\n'))
435 self.ui.status(_('done\n'))
436
436
437 def get_user_id(self, user):
437 def get_user_id(self, user):
438 '''look up numeric bugzilla user id.'''
438 '''look up numeric bugzilla user id.'''
439 try:
439 try:
440 return self.user_ids[user]
440 return self.user_ids[user]
441 except KeyError:
441 except KeyError:
442 try:
442 try:
443 userid = int(user)
443 userid = int(user)
444 except ValueError:
444 except ValueError:
445 self.ui.note(_('looking up user %s\n') % user)
445 self.ui.note(_('looking up user %s\n') % user)
446 self.run('''select userid from profiles
446 self.run('''select userid from profiles
447 where login_name like %s''', user)
447 where login_name like %s''', user)
448 all = self.cursor.fetchall()
448 all = self.cursor.fetchall()
449 if len(all) != 1:
449 if len(all) != 1:
450 raise KeyError(user)
450 raise KeyError(user)
451 userid = int(all[0][0])
451 userid = int(all[0][0])
452 self.user_ids[user] = userid
452 self.user_ids[user] = userid
453 return userid
453 return userid
454
454
455 def get_bugzilla_user(self, committer):
455 def get_bugzilla_user(self, committer):
456 '''See if committer is a registered bugzilla user. Return
456 '''See if committer is a registered bugzilla user. Return
457 bugzilla username and userid if so. If not, return default
457 bugzilla username and userid if so. If not, return default
458 bugzilla username and userid.'''
458 bugzilla username and userid.'''
459 user = self.map_committer(committer)
459 user = self.map_committer(committer)
460 try:
460 try:
461 userid = self.get_user_id(user)
461 userid = self.get_user_id(user)
462 except KeyError:
462 except KeyError:
463 try:
463 try:
464 defaultuser = self.ui.config('bugzilla', 'bzuser')
464 defaultuser = self.ui.config('bugzilla', 'bzuser')
465 if not defaultuser:
465 if not defaultuser:
466 raise util.Abort(_('cannot find bugzilla user id for %s') %
466 raise util.Abort(_('cannot find bugzilla user id for %s') %
467 user)
467 user)
468 userid = self.get_user_id(defaultuser)
468 userid = self.get_user_id(defaultuser)
469 user = defaultuser
469 user = defaultuser
470 except KeyError:
470 except KeyError:
471 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
471 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
472 (user, defaultuser))
472 (user, defaultuser))
473 return (user, userid)
473 return (user, userid)
474
474
475 def updatebug(self, bugid, newstate, text, committer):
475 def updatebug(self, bugid, newstate, text, committer):
476 '''update bug state with comment text.
476 '''update bug state with comment text.
477
477
478 Try adding comment as committer of changeset, otherwise as
478 Try adding comment as committer of changeset, otherwise as
479 default bugzilla user.'''
479 default bugzilla user.'''
480 if len(newstate) > 0:
480 if len(newstate) > 0:
481 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
481 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
482
482
483 (user, userid) = self.get_bugzilla_user(committer)
483 (user, userid) = self.get_bugzilla_user(committer)
484 now = time.strftime('%Y-%m-%d %H:%M:%S')
484 now = time.strftime('%Y-%m-%d %H:%M:%S')
485 self.run('''insert into longdescs
485 self.run('''insert into longdescs
486 (bug_id, who, bug_when, thetext)
486 (bug_id, who, bug_when, thetext)
487 values (%s, %s, %s, %s)''',
487 values (%s, %s, %s, %s)''',
488 (bugid, userid, now, text))
488 (bugid, userid, now, text))
489 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
489 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
490 values (%s, %s, %s, %s)''',
490 values (%s, %s, %s, %s)''',
491 (bugid, userid, now, self.longdesc_id))
491 (bugid, userid, now, self.longdesc_id))
492 self.conn.commit()
492 self.conn.commit()
493
493
494 class bzmysql_2_18(bzmysql):
494 class bzmysql_2_18(bzmysql):
495 '''support for bugzilla 2.18 series.'''
495 '''support for bugzilla 2.18 series.'''
496
496
497 def __init__(self, ui):
497 def __init__(self, ui):
498 bzmysql.__init__(self, ui)
498 bzmysql.__init__(self, ui)
499 self.default_notify = \
499 self.default_notify = \
500 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
500 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
501
501
502 class bzmysql_3_0(bzmysql_2_18):
502 class bzmysql_3_0(bzmysql_2_18):
503 '''support for bugzilla 3.0 series.'''
503 '''support for bugzilla 3.0 series.'''
504
504
505 def __init__(self, ui):
505 def __init__(self, ui):
506 bzmysql_2_18.__init__(self, ui)
506 bzmysql_2_18.__init__(self, ui)
507
507
508 def get_longdesc_id(self):
508 def get_longdesc_id(self):
509 '''get identity of longdesc field'''
509 '''get identity of longdesc field'''
510 self.run('select id from fielddefs where name = "longdesc"')
510 self.run('select id from fielddefs where name = "longdesc"')
511 ids = self.cursor.fetchall()
511 ids = self.cursor.fetchall()
512 if len(ids) != 1:
512 if len(ids) != 1:
513 raise util.Abort(_('unknown database schema'))
513 raise util.Abort(_('unknown database schema'))
514 return ids[0][0]
514 return ids[0][0]
515
515
516 # Buzgilla via XMLRPC interface.
516 # Buzgilla via XMLRPC interface.
517
517
518 class cookietransportrequest(object):
518 class cookietransportrequest(object):
519 """A Transport request method that retains cookies over its lifetime.
519 """A Transport request method that retains cookies over its lifetime.
520
520
521 The regular xmlrpclib transports ignore cookies. Which causes
521 The regular xmlrpclib transports ignore cookies. Which causes
522 a bit of a problem when you need a cookie-based login, as with
522 a bit of a problem when you need a cookie-based login, as with
523 the Bugzilla XMLRPC interface.
523 the Bugzilla XMLRPC interface.
524
524
525 So this is a helper for defining a Transport which looks for
525 So this is a helper for defining a Transport which looks for
526 cookies being set in responses and saves them to add to all future
526 cookies being set in responses and saves them to add to all future
527 requests.
527 requests.
528 """
528 """
529
529
530 # Inspiration drawn from
530 # Inspiration drawn from
531 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
531 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
532 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
532 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
533
533
534 cookies = []
534 cookies = []
535 def send_cookies(self, connection):
535 def send_cookies(self, connection):
536 if self.cookies:
536 if self.cookies:
537 for cookie in self.cookies:
537 for cookie in self.cookies:
538 connection.putheader("Cookie", cookie)
538 connection.putheader("Cookie", cookie)
539
539
540 def request(self, host, handler, request_body, verbose=0):
540 def request(self, host, handler, request_body, verbose=0):
541 self.verbose = verbose
541 self.verbose = verbose
542 self.accept_gzip_encoding = False
542 self.accept_gzip_encoding = False
543
543
544 # issue XML-RPC request
544 # issue XML-RPC request
545 h = self.make_connection(host)
545 h = self.make_connection(host)
546 if verbose:
546 if verbose:
547 h.set_debuglevel(1)
547 h.set_debuglevel(1)
548
548
549 self.send_request(h, handler, request_body)
549 self.send_request(h, handler, request_body)
550 self.send_host(h, host)
550 self.send_host(h, host)
551 self.send_cookies(h)
551 self.send_cookies(h)
552 self.send_user_agent(h)
552 self.send_user_agent(h)
553 self.send_content(h, request_body)
553 self.send_content(h, request_body)
554
554
555 # Deal with differences between Python 2.4-2.6 and 2.7.
555 # Deal with differences between Python 2.4-2.6 and 2.7.
556 # In the former h is a HTTP(S). In the latter it's a
556 # In the former h is a HTTP(S). In the latter it's a
557 # HTTP(S)Connection. Luckily, the 2.4-2.6 implementation of
557 # HTTP(S)Connection. Luckily, the 2.4-2.6 implementation of
558 # HTTP(S) has an underlying HTTP(S)Connection, so extract
558 # HTTP(S) has an underlying HTTP(S)Connection, so extract
559 # that and use it.
559 # that and use it.
560 try:
560 try:
561 response = h.getresponse()
561 response = h.getresponse()
562 except AttributeError:
562 except AttributeError:
563 response = h._conn.getresponse()
563 response = h._conn.getresponse()
564
564
565 # Add any cookie definitions to our list.
565 # Add any cookie definitions to our list.
566 for header in response.msg.getallmatchingheaders("Set-Cookie"):
566 for header in response.msg.getallmatchingheaders("Set-Cookie"):
567 val = header.split(": ", 1)[1]
567 val = header.split(": ", 1)[1]
568 cookie = val.split(";", 1)[0]
568 cookie = val.split(";", 1)[0]
569 self.cookies.append(cookie)
569 self.cookies.append(cookie)
570
570
571 if response.status != 200:
571 if response.status != 200:
572 raise xmlrpclib.ProtocolError(host + handler, response.status,
572 raise xmlrpclib.ProtocolError(host + handler, response.status,
573 response.reason, response.msg.headers)
573 response.reason, response.msg.headers)
574
574
575 payload = response.read()
575 payload = response.read()
576 parser, unmarshaller = self.getparser()
576 parser, unmarshaller = self.getparser()
577 parser.feed(payload)
577 parser.feed(payload)
578 parser.close()
578 parser.close()
579
579
580 return unmarshaller.close()
580 return unmarshaller.close()
581
581
582 # The explicit calls to the underlying xmlrpclib __init__() methods are
582 # The explicit calls to the underlying xmlrpclib __init__() methods are
583 # necessary. The xmlrpclib.Transport classes are old-style classes, and
583 # necessary. The xmlrpclib.Transport classes are old-style classes, and
584 # it turns out their __init__() doesn't get called when doing multiple
584 # it turns out their __init__() doesn't get called when doing multiple
585 # inheritance with a new-style class.
585 # inheritance with a new-style class.
586 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
586 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
587 def __init__(self, use_datetime=0):
587 def __init__(self, use_datetime=0):
588 if util.safehasattr(xmlrpclib.Transport, "__init__"):
588 if util.safehasattr(xmlrpclib.Transport, "__init__"):
589 xmlrpclib.Transport.__init__(self, use_datetime)
589 xmlrpclib.Transport.__init__(self, use_datetime)
590
590
591 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
591 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
592 def __init__(self, use_datetime=0):
592 def __init__(self, use_datetime=0):
593 if util.safehasattr(xmlrpclib.Transport, "__init__"):
593 if util.safehasattr(xmlrpclib.Transport, "__init__"):
594 xmlrpclib.SafeTransport.__init__(self, use_datetime)
594 xmlrpclib.SafeTransport.__init__(self, use_datetime)
595
595
596 class bzxmlrpc(bzaccess):
596 class bzxmlrpc(bzaccess):
597 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
597 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
598
598
599 Requires a minimum Bugzilla version 3.4.
599 Requires a minimum Bugzilla version 3.4.
600 """
600 """
601
601
602 def __init__(self, ui):
602 def __init__(self, ui):
603 bzaccess.__init__(self, ui)
603 bzaccess.__init__(self, ui)
604
604
605 bzweb = self.ui.config('bugzilla', 'bzurl',
605 bzweb = self.ui.config('bugzilla', 'bzurl',
606 'http://localhost/bugzilla/')
606 'http://localhost/bugzilla/')
607 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
607 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
608
608
609 user = self.ui.config('bugzilla', 'user', 'bugs')
609 user = self.ui.config('bugzilla', 'user', 'bugs')
610 passwd = self.ui.config('bugzilla', 'password')
610 passwd = self.ui.config('bugzilla', 'password')
611
611
612 self.fixstatus = self.ui.config('bugzilla', 'fixstatus', 'RESOLVED')
612 self.fixstatus = self.ui.config('bugzilla', 'fixstatus', 'RESOLVED')
613 self.fixresolution = self.ui.config('bugzilla', 'fixresolution',
613 self.fixresolution = self.ui.config('bugzilla', 'fixresolution',
614 'FIXED')
614 'FIXED')
615
615
616 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
616 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
617 ver = self.bzproxy.Bugzilla.version()['version'].split('.')
617 ver = self.bzproxy.Bugzilla.version()['version'].split('.')
618 self.bzvermajor = int(ver[0])
618 self.bzvermajor = int(ver[0])
619 self.bzverminor = int(ver[1])
619 self.bzverminor = int(ver[1])
620 self.bzproxy.User.login(dict(login=user, password=passwd))
620 self.bzproxy.User.login(dict(login=user, password=passwd))
621
621
622 def transport(self, uri):
622 def transport(self, uri):
623 if urlparse.urlparse(uri, "http")[0] == "https":
623 if urlparse.urlparse(uri, "http")[0] == "https":
624 return cookiesafetransport()
624 return cookiesafetransport()
625 else:
625 else:
626 return cookietransport()
626 return cookietransport()
627
627
628 def get_bug_comments(self, id):
628 def get_bug_comments(self, id):
629 """Return a string with all comment text for a bug."""
629 """Return a string with all comment text for a bug."""
630 c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
630 c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
631 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
631 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
632
632
633 def filter_real_bug_ids(self, bugs):
633 def filter_real_bug_ids(self, bugs):
634 probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
634 probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
635 include_fields=[],
635 include_fields=[],
636 permissive=True))
636 permissive=True))
637 for badbug in probe['faults']:
637 for badbug in probe['faults']:
638 id = badbug['id']
638 id = badbug['id']
639 self.ui.status(_('bug %d does not exist\n') % id)
639 self.ui.status(_('bug %d does not exist\n') % id)
640 del bugs[id]
640 del bugs[id]
641
641
642 def filter_cset_known_bug_ids(self, node, bugs):
642 def filter_cset_known_bug_ids(self, node, bugs):
643 for id in sorted(bugs.keys()):
643 for id in sorted(bugs.keys()):
644 if self.get_bug_comments(id).find(short(node)) != -1:
644 if self.get_bug_comments(id).find(short(node)) != -1:
645 self.ui.status(_('bug %d already knows about changeset %s\n') %
645 self.ui.status(_('bug %d already knows about changeset %s\n') %
646 (id, short(node)))
646 (id, short(node)))
647 del bugs[id]
647 del bugs[id]
648
648
649 def updatebug(self, bugid, newstate, text, committer):
649 def updatebug(self, bugid, newstate, text, committer):
650 args = {}
650 args = {}
651 if 'hours' in newstate:
651 if 'hours' in newstate:
652 args['work_time'] = newstate['hours']
652 args['work_time'] = newstate['hours']
653
653
654 if self.bzvermajor >= 4:
654 if self.bzvermajor >= 4:
655 args['ids'] = [bugid]
655 args['ids'] = [bugid]
656 args['comment'] = {'body' : text}
656 args['comment'] = {'body' : text}
657 args['status'] = self.fixstatus
657 if 'fix' in newstate:
658 args['resolution'] = self.fixresolution
658 args['status'] = self.fixstatus
659 args['resolution'] = self.fixresolution
659 self.bzproxy.Bug.update(args)
660 self.bzproxy.Bug.update(args)
660 else:
661 else:
661 if 'fix' in newstate:
662 if 'fix' in newstate:
662 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
663 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
663 "to mark bugs fixed\n"))
664 "to mark bugs fixed\n"))
664 args['id'] = bugid
665 args['id'] = bugid
665 args['comment'] = text
666 args['comment'] = text
666 self.bzproxy.Bug.add_comment(args)
667 self.bzproxy.Bug.add_comment(args)
667
668
668 class bzxmlrpcemail(bzxmlrpc):
669 class bzxmlrpcemail(bzxmlrpc):
669 """Read data from Bugzilla via XMLRPC, send updates via email.
670 """Read data from Bugzilla via XMLRPC, send updates via email.
670
671
671 Advantages of sending updates via email:
672 Advantages of sending updates via email:
672 1. Comments can be added as any user, not just logged in user.
673 1. Comments can be added as any user, not just logged in user.
673 2. Bug statuses or other fields not accessible via XMLRPC can
674 2. Bug statuses or other fields not accessible via XMLRPC can
674 potentially be updated.
675 potentially be updated.
675
676
676 There is no XMLRPC function to change bug status before Bugzilla
677 There is no XMLRPC function to change bug status before Bugzilla
677 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
678 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
678 But bugs can be marked fixed via email from 3.4 onwards.
679 But bugs can be marked fixed via email from 3.4 onwards.
679 """
680 """
680
681
681 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
682 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
682 # in-email fields are specified as '@<fieldname> = <value>'. In
683 # in-email fields are specified as '@<fieldname> = <value>'. In
683 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
684 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
684 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
685 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
685 # compatibility, but rather than rely on this use the new format for
686 # compatibility, but rather than rely on this use the new format for
686 # 4.0 onwards.
687 # 4.0 onwards.
687
688
688 def __init__(self, ui):
689 def __init__(self, ui):
689 bzxmlrpc.__init__(self, ui)
690 bzxmlrpc.__init__(self, ui)
690
691
691 self.bzemail = self.ui.config('bugzilla', 'bzemail')
692 self.bzemail = self.ui.config('bugzilla', 'bzemail')
692 if not self.bzemail:
693 if not self.bzemail:
693 raise util.Abort(_("configuration 'bzemail' missing"))
694 raise util.Abort(_("configuration 'bzemail' missing"))
694 mail.validateconfig(self.ui)
695 mail.validateconfig(self.ui)
695
696
696 def makecommandline(self, fieldname, value):
697 def makecommandline(self, fieldname, value):
697 if self.bzvermajor >= 4:
698 if self.bzvermajor >= 4:
698 return "@%s %s" % (fieldname, str(value))
699 return "@%s %s" % (fieldname, str(value))
699 else:
700 else:
700 if fieldname == "id":
701 if fieldname == "id":
701 fieldname = "bug_id"
702 fieldname = "bug_id"
702 return "@%s = %s" % (fieldname, str(value))
703 return "@%s = %s" % (fieldname, str(value))
703
704
704 def send_bug_modify_email(self, bugid, commands, comment, committer):
705 def send_bug_modify_email(self, bugid, commands, comment, committer):
705 '''send modification message to Bugzilla bug via email.
706 '''send modification message to Bugzilla bug via email.
706
707
707 The message format is documented in the Bugzilla email_in.pl
708 The message format is documented in the Bugzilla email_in.pl
708 specification. commands is a list of command lines, comment is the
709 specification. commands is a list of command lines, comment is the
709 comment text.
710 comment text.
710
711
711 To stop users from crafting commit comments with
712 To stop users from crafting commit comments with
712 Bugzilla commands, specify the bug ID via the message body, rather
713 Bugzilla commands, specify the bug ID via the message body, rather
713 than the subject line, and leave a blank line after it.
714 than the subject line, and leave a blank line after it.
714 '''
715 '''
715 user = self.map_committer(committer)
716 user = self.map_committer(committer)
716 matches = self.bzproxy.User.get(dict(match=[user]))
717 matches = self.bzproxy.User.get(dict(match=[user]))
717 if not matches['users']:
718 if not matches['users']:
718 user = self.ui.config('bugzilla', 'user', 'bugs')
719 user = self.ui.config('bugzilla', 'user', 'bugs')
719 matches = self.bzproxy.User.get(dict(match=[user]))
720 matches = self.bzproxy.User.get(dict(match=[user]))
720 if not matches['users']:
721 if not matches['users']:
721 raise util.Abort(_("default bugzilla user %s email not found") %
722 raise util.Abort(_("default bugzilla user %s email not found") %
722 user)
723 user)
723 user = matches['users'][0]['email']
724 user = matches['users'][0]['email']
724 commands.append(self.makecommandline("id", bugid))
725 commands.append(self.makecommandline("id", bugid))
725
726
726 text = "\n".join(commands) + "\n\n" + comment
727 text = "\n".join(commands) + "\n\n" + comment
727
728
728 _charsets = mail._charsets(self.ui)
729 _charsets = mail._charsets(self.ui)
729 user = mail.addressencode(self.ui, user, _charsets)
730 user = mail.addressencode(self.ui, user, _charsets)
730 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
731 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
731 msg = mail.mimeencode(self.ui, text, _charsets)
732 msg = mail.mimeencode(self.ui, text, _charsets)
732 msg['From'] = user
733 msg['From'] = user
733 msg['To'] = bzemail
734 msg['To'] = bzemail
734 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
735 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
735 sendmail = mail.connect(self.ui)
736 sendmail = mail.connect(self.ui)
736 sendmail(user, bzemail, msg.as_string())
737 sendmail(user, bzemail, msg.as_string())
737
738
738 def updatebug(self, bugid, newstate, text, committer):
739 def updatebug(self, bugid, newstate, text, committer):
739 cmds = []
740 cmds = []
740 if 'hours' in newstate:
741 if 'hours' in newstate:
741 cmds.append(self.makecommandline("work_time", newstate['hours']))
742 cmds.append(self.makecommandline("work_time", newstate['hours']))
742 if 'fix' in newstate:
743 if 'fix' in newstate:
743 cmds.append(self.makecommandline("bug_status", self.fixstatus))
744 cmds.append(self.makecommandline("bug_status", self.fixstatus))
744 cmds.append(self.makecommandline("resolution", self.fixresolution))
745 cmds.append(self.makecommandline("resolution", self.fixresolution))
745 self.send_bug_modify_email(bugid, cmds, text, committer)
746 self.send_bug_modify_email(bugid, cmds, text, committer)
746
747
747 class bugzilla(object):
748 class bugzilla(object):
748 # supported versions of bugzilla. different versions have
749 # supported versions of bugzilla. different versions have
749 # different schemas.
750 # different schemas.
750 _versions = {
751 _versions = {
751 '2.16': bzmysql,
752 '2.16': bzmysql,
752 '2.18': bzmysql_2_18,
753 '2.18': bzmysql_2_18,
753 '3.0': bzmysql_3_0,
754 '3.0': bzmysql_3_0,
754 'xmlrpc': bzxmlrpc,
755 'xmlrpc': bzxmlrpc,
755 'xmlrpc+email': bzxmlrpcemail
756 'xmlrpc+email': bzxmlrpcemail
756 }
757 }
757
758
758 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
759 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
759 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
760 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
760 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
761 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
761
762
762 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
763 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
763 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
764 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
764 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
765 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
765 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
766 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
766
767
767 _bz = None
768 _bz = None
768
769
769 def __init__(self, ui, repo):
770 def __init__(self, ui, repo):
770 self.ui = ui
771 self.ui = ui
771 self.repo = repo
772 self.repo = repo
772
773
773 def bz(self):
774 def bz(self):
774 '''return object that knows how to talk to bugzilla version in
775 '''return object that knows how to talk to bugzilla version in
775 use.'''
776 use.'''
776
777
777 if bugzilla._bz is None:
778 if bugzilla._bz is None:
778 bzversion = self.ui.config('bugzilla', 'version')
779 bzversion = self.ui.config('bugzilla', 'version')
779 try:
780 try:
780 bzclass = bugzilla._versions[bzversion]
781 bzclass = bugzilla._versions[bzversion]
781 except KeyError:
782 except KeyError:
782 raise util.Abort(_('bugzilla version %s not supported') %
783 raise util.Abort(_('bugzilla version %s not supported') %
783 bzversion)
784 bzversion)
784 bugzilla._bz = bzclass(self.ui)
785 bugzilla._bz = bzclass(self.ui)
785 return bugzilla._bz
786 return bugzilla._bz
786
787
787 def __getattr__(self, key):
788 def __getattr__(self, key):
788 return getattr(self.bz(), key)
789 return getattr(self.bz(), key)
789
790
790 _bug_re = None
791 _bug_re = None
791 _fix_re = None
792 _fix_re = None
792 _split_re = None
793 _split_re = None
793
794
794 def find_bugs(self, ctx):
795 def find_bugs(self, ctx):
795 '''return bugs dictionary created from commit comment.
796 '''return bugs dictionary created from commit comment.
796
797
797 Extract bug info from changeset comments. Filter out any that are
798 Extract bug info from changeset comments. Filter out any that are
798 not known to Bugzilla, and any that already have a reference to
799 not known to Bugzilla, and any that already have a reference to
799 the given changeset in their comments.
800 the given changeset in their comments.
800 '''
801 '''
801 if bugzilla._bug_re is None:
802 if bugzilla._bug_re is None:
802 bugzilla._bug_re = re.compile(
803 bugzilla._bug_re = re.compile(
803 self.ui.config('bugzilla', 'regexp',
804 self.ui.config('bugzilla', 'regexp',
804 bugzilla._default_bug_re), re.IGNORECASE)
805 bugzilla._default_bug_re), re.IGNORECASE)
805 bugzilla._fix_re = re.compile(
806 bugzilla._fix_re = re.compile(
806 self.ui.config('bugzilla', 'fixregexp',
807 self.ui.config('bugzilla', 'fixregexp',
807 bugzilla._default_fix_re), re.IGNORECASE)
808 bugzilla._default_fix_re), re.IGNORECASE)
808 bugzilla._split_re = re.compile(r'\D+')
809 bugzilla._split_re = re.compile(r'\D+')
809 start = 0
810 start = 0
810 hours = 0.0
811 hours = 0.0
811 bugs = {}
812 bugs = {}
812 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
813 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
813 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
814 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
814 while True:
815 while True:
815 bugattribs = {}
816 bugattribs = {}
816 if not bugmatch and not fixmatch:
817 if not bugmatch and not fixmatch:
817 break
818 break
818 if not bugmatch:
819 if not bugmatch:
819 m = fixmatch
820 m = fixmatch
820 elif not fixmatch:
821 elif not fixmatch:
821 m = bugmatch
822 m = bugmatch
822 else:
823 else:
823 if bugmatch.start() < fixmatch.start():
824 if bugmatch.start() < fixmatch.start():
824 m = bugmatch
825 m = bugmatch
825 else:
826 else:
826 m = fixmatch
827 m = fixmatch
827 start = m.end()
828 start = m.end()
828 if m is bugmatch:
829 if m is bugmatch:
829 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
830 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
830 if 'fix' in bugattribs:
831 if 'fix' in bugattribs:
831 del bugattribs['fix']
832 del bugattribs['fix']
832 else:
833 else:
833 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
834 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
834 bugattribs['fix'] = None
835 bugattribs['fix'] = None
835
836
836 try:
837 try:
837 ids = m.group('ids')
838 ids = m.group('ids')
838 except IndexError:
839 except IndexError:
839 ids = m.group(1)
840 ids = m.group(1)
840 try:
841 try:
841 hours = float(m.group('hours'))
842 hours = float(m.group('hours'))
842 bugattribs['hours'] = hours
843 bugattribs['hours'] = hours
843 except IndexError:
844 except IndexError:
844 pass
845 pass
845 except TypeError:
846 except TypeError:
846 pass
847 pass
847 except ValueError:
848 except ValueError:
848 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
849 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
849
850
850 for id in bugzilla._split_re.split(ids):
851 for id in bugzilla._split_re.split(ids):
851 if not id:
852 if not id:
852 continue
853 continue
853 bugs[int(id)] = bugattribs
854 bugs[int(id)] = bugattribs
854 if bugs:
855 if bugs:
855 self.filter_real_bug_ids(bugs)
856 self.filter_real_bug_ids(bugs)
856 if bugs:
857 if bugs:
857 self.filter_cset_known_bug_ids(ctx.node(), bugs)
858 self.filter_cset_known_bug_ids(ctx.node(), bugs)
858 return bugs
859 return bugs
859
860
860 def update(self, bugid, newstate, ctx):
861 def update(self, bugid, newstate, ctx):
861 '''update bugzilla bug with reference to changeset.'''
862 '''update bugzilla bug with reference to changeset.'''
862
863
863 def webroot(root):
864 def webroot(root):
864 '''strip leading prefix of repo root and turn into
865 '''strip leading prefix of repo root and turn into
865 url-safe path.'''
866 url-safe path.'''
866 count = int(self.ui.config('bugzilla', 'strip', 0))
867 count = int(self.ui.config('bugzilla', 'strip', 0))
867 root = util.pconvert(root)
868 root = util.pconvert(root)
868 while count > 0:
869 while count > 0:
869 c = root.find('/')
870 c = root.find('/')
870 if c == -1:
871 if c == -1:
871 break
872 break
872 root = root[c + 1:]
873 root = root[c + 1:]
873 count -= 1
874 count -= 1
874 return root
875 return root
875
876
876 mapfile = self.ui.config('bugzilla', 'style')
877 mapfile = self.ui.config('bugzilla', 'style')
877 tmpl = self.ui.config('bugzilla', 'template')
878 tmpl = self.ui.config('bugzilla', 'template')
878 t = cmdutil.changeset_templater(self.ui, self.repo,
879 t = cmdutil.changeset_templater(self.ui, self.repo,
879 False, None, mapfile, False)
880 False, None, mapfile, False)
880 if not mapfile and not tmpl:
881 if not mapfile and not tmpl:
881 tmpl = _('changeset {node|short} in repo {root} refers '
882 tmpl = _('changeset {node|short} in repo {root} refers '
882 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
883 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
883 if tmpl:
884 if tmpl:
884 tmpl = templater.parsestring(tmpl, quoted=False)
885 tmpl = templater.parsestring(tmpl, quoted=False)
885 t.use_template(tmpl)
886 t.use_template(tmpl)
886 self.ui.pushbuffer()
887 self.ui.pushbuffer()
887 t.show(ctx, changes=ctx.changeset(),
888 t.show(ctx, changes=ctx.changeset(),
888 bug=str(bugid),
889 bug=str(bugid),
889 hgweb=self.ui.config('web', 'baseurl'),
890 hgweb=self.ui.config('web', 'baseurl'),
890 root=self.repo.root,
891 root=self.repo.root,
891 webroot=webroot(self.repo.root))
892 webroot=webroot(self.repo.root))
892 data = self.ui.popbuffer()
893 data = self.ui.popbuffer()
893 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
894 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
894
895
895 def hook(ui, repo, hooktype, node=None, **kwargs):
896 def hook(ui, repo, hooktype, node=None, **kwargs):
896 '''add comment to bugzilla for each changeset that refers to a
897 '''add comment to bugzilla for each changeset that refers to a
897 bugzilla bug id. only add a comment once per bug, so same change
898 bugzilla bug id. only add a comment once per bug, so same change
898 seen multiple times does not fill bug with duplicate data.'''
899 seen multiple times does not fill bug with duplicate data.'''
899 if node is None:
900 if node is None:
900 raise util.Abort(_('hook type %s does not pass a changeset id') %
901 raise util.Abort(_('hook type %s does not pass a changeset id') %
901 hooktype)
902 hooktype)
902 try:
903 try:
903 bz = bugzilla(ui, repo)
904 bz = bugzilla(ui, repo)
904 ctx = repo[node]
905 ctx = repo[node]
905 bugs = bz.find_bugs(ctx)
906 bugs = bz.find_bugs(ctx)
906 if bugs:
907 if bugs:
907 for bug in bugs:
908 for bug in bugs:
908 bz.update(bug, bugs[bug], ctx)
909 bz.update(bug, bugs[bug], ctx)
909 bz.notify(bugs, util.email(ctx.user()))
910 bz.notify(bugs, util.email(ctx.user()))
910 except Exception, e:
911 except Exception, e:
911 raise util.Abort(_('Bugzilla error: %s') % e)
912 raise util.Abort(_('Bugzilla error: %s') % e)
912
913
General Comments 0
You need to be logged in to leave comments. Login now