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