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