##// END OF EJS Templates
en-us: recognized
timeless@mozdev.org -
r17534:c5f7c4b5 default
parent child Browse files
Show More
@@ -1,915 +1,915 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. Bugs can also be marked fixed.
16 hours spent working on the bug. Bugs can also be marked fixed.
17
17
18 Three basic modes of access to Bugzilla are provided:
18 Three basic modes of access to Bugzilla are provided:
19
19
20 1. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
20 1. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
21
21
22 2. Check data via the Bugzilla XMLRPC interface and submit bug change
22 2. Check data via the Bugzilla XMLRPC interface and submit bug change
23 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
23 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
24
24
25 3. Writing directly to the Bugzilla database. Only Bugzilla installations
25 3. Writing directly to the Bugzilla database. Only Bugzilla installations
26 using MySQL are supported. Requires Python MySQLdb.
26 using MySQL are supported. Requires Python MySQLdb.
27
27
28 Writing directly to the database is susceptible to schema changes, and
28 Writing directly to the database is susceptible to schema changes, and
29 relies on a Bugzilla contrib script to send out bug change
29 relies on a Bugzilla contrib script to send out bug change
30 notification emails. This script runs as the user running Mercurial,
30 notification emails. This script runs as the user running Mercurial,
31 must be run on the host with the Bugzilla install, and requires
31 must be run on the host with the Bugzilla install, and requires
32 permission to read Bugzilla configuration details and the necessary
32 permission to read Bugzilla configuration details and the necessary
33 MySQL user and password to have full access rights to the Bugzilla
33 MySQL user and password to have full access rights to the Bugzilla
34 database. For these reasons this access mode is now considered
34 database. For these reasons this access mode is now considered
35 deprecated, and will not be updated for new Bugzilla versions going
35 deprecated, and will not be updated for new Bugzilla versions going
36 forward. Only adding comments is supported in this access mode.
36 forward. Only adding comments is supported in this access mode.
37
37
38 Access via XMLRPC needs a Bugzilla username and password to be specified
38 Access via XMLRPC needs a Bugzilla username and password to be specified
39 in the configuration. Comments are added under that username. Since the
39 in the configuration. Comments are added under that username. Since the
40 configuration must be readable by all Mercurial users, it is recommended
40 configuration must be readable by all Mercurial users, it is recommended
41 that the rights of that user are restricted in Bugzilla to the minimum
41 that the rights of that user are restricted in Bugzilla to the minimum
42 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
42 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
43
43
44 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
44 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
45 email to the Bugzilla email interface to submit comments to bugs.
45 email to the Bugzilla email interface to submit comments to bugs.
46 The From: address in the email is set to the email address of the Mercurial
46 The From: address in the email is set to the email address of the Mercurial
47 user, so the comment appears to come from the Mercurial user. In the event
47 user, so the comment appears to come from the Mercurial user. In the event
48 that the Mercurial user email is not recognised by Bugzilla as a Bugzilla
48 that the Mercurial user email is not 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 This access type to use. Values recognised are:
56 This 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. Recognised 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 # Buzgilla via XMLRPC interface.
519 # Buzgilla 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.
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(dict(login=user, password=passwd))
623 self.bzproxy.User.login(dict(login=user, password=passwd))
624
624
625 def transport(self, uri):
625 def transport(self, uri):
626 if urlparse.urlparse(uri, "http")[0] == "https":
626 if urlparse.urlparse(uri, "http")[0] == "https":
627 return cookiesafetransport()
627 return cookiesafetransport()
628 else:
628 else:
629 return cookietransport()
629 return cookietransport()
630
630
631 def get_bug_comments(self, id):
631 def get_bug_comments(self, id):
632 """Return a string with all comment text for a bug."""
632 """Return a string with all comment text for a bug."""
633 c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
633 c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
634 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
634 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
635
635
636 def filter_real_bug_ids(self, bugs):
636 def filter_real_bug_ids(self, bugs):
637 probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
637 probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
638 include_fields=[],
638 include_fields=[],
639 permissive=True))
639 permissive=True))
640 for badbug in probe['faults']:
640 for badbug in probe['faults']:
641 id = badbug['id']
641 id = badbug['id']
642 self.ui.status(_('bug %d does not exist\n') % id)
642 self.ui.status(_('bug %d does not exist\n') % id)
643 del bugs[id]
643 del bugs[id]
644
644
645 def filter_cset_known_bug_ids(self, node, bugs):
645 def filter_cset_known_bug_ids(self, node, bugs):
646 for id in sorted(bugs.keys()):
646 for id in sorted(bugs.keys()):
647 if self.get_bug_comments(id).find(short(node)) != -1:
647 if self.get_bug_comments(id).find(short(node)) != -1:
648 self.ui.status(_('bug %d already knows about changeset %s\n') %
648 self.ui.status(_('bug %d already knows about changeset %s\n') %
649 (id, short(node)))
649 (id, short(node)))
650 del bugs[id]
650 del bugs[id]
651
651
652 def updatebug(self, bugid, newstate, text, committer):
652 def updatebug(self, bugid, newstate, text, committer):
653 args = {}
653 args = {}
654 if 'hours' in newstate:
654 if 'hours' in newstate:
655 args['work_time'] = newstate['hours']
655 args['work_time'] = newstate['hours']
656
656
657 if self.bzvermajor >= 4:
657 if self.bzvermajor >= 4:
658 args['ids'] = [bugid]
658 args['ids'] = [bugid]
659 args['comment'] = {'body' : text}
659 args['comment'] = {'body' : text}
660 if 'fix' in newstate:
660 if 'fix' in newstate:
661 args['status'] = self.fixstatus
661 args['status'] = self.fixstatus
662 args['resolution'] = self.fixresolution
662 args['resolution'] = self.fixresolution
663 self.bzproxy.Bug.update(args)
663 self.bzproxy.Bug.update(args)
664 else:
664 else:
665 if 'fix' in newstate:
665 if 'fix' in newstate:
666 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
666 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
667 "to mark bugs fixed\n"))
667 "to mark bugs fixed\n"))
668 args['id'] = bugid
668 args['id'] = bugid
669 args['comment'] = text
669 args['comment'] = text
670 self.bzproxy.Bug.add_comment(args)
670 self.bzproxy.Bug.add_comment(args)
671
671
672 class bzxmlrpcemail(bzxmlrpc):
672 class bzxmlrpcemail(bzxmlrpc):
673 """Read data from Bugzilla via XMLRPC, send updates via email.
673 """Read data from Bugzilla via XMLRPC, send updates via email.
674
674
675 Advantages of sending updates via email:
675 Advantages of sending updates via email:
676 1. Comments can be added as any user, not just logged in user.
676 1. Comments can be added as any user, not just logged in user.
677 2. Bug statuses or other fields not accessible via XMLRPC can
677 2. Bug statuses or other fields not accessible via XMLRPC can
678 potentially be updated.
678 potentially be updated.
679
679
680 There is no XMLRPC function to change bug status before Bugzilla
680 There is no XMLRPC function to change bug status before Bugzilla
681 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
681 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
682 But bugs can be marked fixed via email from 3.4 onwards.
682 But bugs can be marked fixed via email from 3.4 onwards.
683 """
683 """
684
684
685 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
685 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
686 # in-email fields are specified as '@<fieldname> = <value>'. In
686 # in-email fields are specified as '@<fieldname> = <value>'. In
687 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
687 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
688 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
688 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
689 # compatibility, but rather than rely on this use the new format for
689 # compatibility, but rather than rely on this use the new format for
690 # 4.0 onwards.
690 # 4.0 onwards.
691
691
692 def __init__(self, ui):
692 def __init__(self, ui):
693 bzxmlrpc.__init__(self, ui)
693 bzxmlrpc.__init__(self, ui)
694
694
695 self.bzemail = self.ui.config('bugzilla', 'bzemail')
695 self.bzemail = self.ui.config('bugzilla', 'bzemail')
696 if not self.bzemail:
696 if not self.bzemail:
697 raise util.Abort(_("configuration 'bzemail' missing"))
697 raise util.Abort(_("configuration 'bzemail' missing"))
698 mail.validateconfig(self.ui)
698 mail.validateconfig(self.ui)
699
699
700 def makecommandline(self, fieldname, value):
700 def makecommandline(self, fieldname, value):
701 if self.bzvermajor >= 4:
701 if self.bzvermajor >= 4:
702 return "@%s %s" % (fieldname, str(value))
702 return "@%s %s" % (fieldname, str(value))
703 else:
703 else:
704 if fieldname == "id":
704 if fieldname == "id":
705 fieldname = "bug_id"
705 fieldname = "bug_id"
706 return "@%s = %s" % (fieldname, str(value))
706 return "@%s = %s" % (fieldname, str(value))
707
707
708 def send_bug_modify_email(self, bugid, commands, comment, committer):
708 def send_bug_modify_email(self, bugid, commands, comment, committer):
709 '''send modification message to Bugzilla bug via email.
709 '''send modification message to Bugzilla bug via email.
710
710
711 The message format is documented in the Bugzilla email_in.pl
711 The message format is documented in the Bugzilla email_in.pl
712 specification. commands is a list of command lines, comment is the
712 specification. commands is a list of command lines, comment is the
713 comment text.
713 comment text.
714
714
715 To stop users from crafting commit comments with
715 To stop users from crafting commit comments with
716 Bugzilla commands, specify the bug ID via the message body, rather
716 Bugzilla commands, specify the bug ID via the message body, rather
717 than the subject line, and leave a blank line after it.
717 than the subject line, and leave a blank line after it.
718 '''
718 '''
719 user = self.map_committer(committer)
719 user = self.map_committer(committer)
720 matches = self.bzproxy.User.get(dict(match=[user]))
720 matches = self.bzproxy.User.get(dict(match=[user]))
721 if not matches['users']:
721 if not matches['users']:
722 user = self.ui.config('bugzilla', 'user', 'bugs')
722 user = self.ui.config('bugzilla', 'user', 'bugs')
723 matches = self.bzproxy.User.get(dict(match=[user]))
723 matches = self.bzproxy.User.get(dict(match=[user]))
724 if not matches['users']:
724 if not matches['users']:
725 raise util.Abort(_("default bugzilla user %s email not found") %
725 raise util.Abort(_("default bugzilla user %s email not found") %
726 user)
726 user)
727 user = matches['users'][0]['email']
727 user = matches['users'][0]['email']
728 commands.append(self.makecommandline("id", bugid))
728 commands.append(self.makecommandline("id", bugid))
729
729
730 text = "\n".join(commands) + "\n\n" + comment
730 text = "\n".join(commands) + "\n\n" + comment
731
731
732 _charsets = mail._charsets(self.ui)
732 _charsets = mail._charsets(self.ui)
733 user = mail.addressencode(self.ui, user, _charsets)
733 user = mail.addressencode(self.ui, user, _charsets)
734 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
734 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
735 msg = mail.mimeencode(self.ui, text, _charsets)
735 msg = mail.mimeencode(self.ui, text, _charsets)
736 msg['From'] = user
736 msg['From'] = user
737 msg['To'] = bzemail
737 msg['To'] = bzemail
738 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
738 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
739 sendmail = mail.connect(self.ui)
739 sendmail = mail.connect(self.ui)
740 sendmail(user, bzemail, msg.as_string())
740 sendmail(user, bzemail, msg.as_string())
741
741
742 def updatebug(self, bugid, newstate, text, committer):
742 def updatebug(self, bugid, newstate, text, committer):
743 cmds = []
743 cmds = []
744 if 'hours' in newstate:
744 if 'hours' in newstate:
745 cmds.append(self.makecommandline("work_time", newstate['hours']))
745 cmds.append(self.makecommandline("work_time", newstate['hours']))
746 if 'fix' in newstate:
746 if 'fix' in newstate:
747 cmds.append(self.makecommandline("bug_status", self.fixstatus))
747 cmds.append(self.makecommandline("bug_status", self.fixstatus))
748 cmds.append(self.makecommandline("resolution", self.fixresolution))
748 cmds.append(self.makecommandline("resolution", self.fixresolution))
749 self.send_bug_modify_email(bugid, cmds, text, committer)
749 self.send_bug_modify_email(bugid, cmds, text, committer)
750
750
751 class bugzilla(object):
751 class bugzilla(object):
752 # supported versions of bugzilla. different versions have
752 # supported versions of bugzilla. different versions have
753 # different schemas.
753 # different schemas.
754 _versions = {
754 _versions = {
755 '2.16': bzmysql,
755 '2.16': bzmysql,
756 '2.18': bzmysql_2_18,
756 '2.18': bzmysql_2_18,
757 '3.0': bzmysql_3_0,
757 '3.0': bzmysql_3_0,
758 'xmlrpc': bzxmlrpc,
758 'xmlrpc': bzxmlrpc,
759 'xmlrpc+email': bzxmlrpcemail
759 'xmlrpc+email': bzxmlrpcemail
760 }
760 }
761
761
762 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
762 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
763 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
763 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
764 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
764 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
765
765
766 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
766 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
767 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
767 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
768 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
768 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
769 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
769 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
770
770
771 _bz = None
771 _bz = None
772
772
773 def __init__(self, ui, repo):
773 def __init__(self, ui, repo):
774 self.ui = ui
774 self.ui = ui
775 self.repo = repo
775 self.repo = repo
776
776
777 def bz(self):
777 def bz(self):
778 '''return object that knows how to talk to bugzilla version in
778 '''return object that knows how to talk to bugzilla version in
779 use.'''
779 use.'''
780
780
781 if bugzilla._bz is None:
781 if bugzilla._bz is None:
782 bzversion = self.ui.config('bugzilla', 'version')
782 bzversion = self.ui.config('bugzilla', 'version')
783 try:
783 try:
784 bzclass = bugzilla._versions[bzversion]
784 bzclass = bugzilla._versions[bzversion]
785 except KeyError:
785 except KeyError:
786 raise util.Abort(_('bugzilla version %s not supported') %
786 raise util.Abort(_('bugzilla version %s not supported') %
787 bzversion)
787 bzversion)
788 bugzilla._bz = bzclass(self.ui)
788 bugzilla._bz = bzclass(self.ui)
789 return bugzilla._bz
789 return bugzilla._bz
790
790
791 def __getattr__(self, key):
791 def __getattr__(self, key):
792 return getattr(self.bz(), key)
792 return getattr(self.bz(), key)
793
793
794 _bug_re = None
794 _bug_re = None
795 _fix_re = None
795 _fix_re = None
796 _split_re = None
796 _split_re = None
797
797
798 def find_bugs(self, ctx):
798 def find_bugs(self, ctx):
799 '''return bugs dictionary created from commit comment.
799 '''return bugs dictionary created from commit comment.
800
800
801 Extract bug info from changeset comments. Filter out any that are
801 Extract bug info from changeset comments. Filter out any that are
802 not known to Bugzilla, and any that already have a reference to
802 not known to Bugzilla, and any that already have a reference to
803 the given changeset in their comments.
803 the given changeset in their comments.
804 '''
804 '''
805 if bugzilla._bug_re is None:
805 if bugzilla._bug_re is None:
806 bugzilla._bug_re = re.compile(
806 bugzilla._bug_re = re.compile(
807 self.ui.config('bugzilla', 'regexp',
807 self.ui.config('bugzilla', 'regexp',
808 bugzilla._default_bug_re), re.IGNORECASE)
808 bugzilla._default_bug_re), re.IGNORECASE)
809 bugzilla._fix_re = re.compile(
809 bugzilla._fix_re = re.compile(
810 self.ui.config('bugzilla', 'fixregexp',
810 self.ui.config('bugzilla', 'fixregexp',
811 bugzilla._default_fix_re), re.IGNORECASE)
811 bugzilla._default_fix_re), re.IGNORECASE)
812 bugzilla._split_re = re.compile(r'\D+')
812 bugzilla._split_re = re.compile(r'\D+')
813 start = 0
813 start = 0
814 hours = 0.0
814 hours = 0.0
815 bugs = {}
815 bugs = {}
816 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
816 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
817 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
817 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
818 while True:
818 while True:
819 bugattribs = {}
819 bugattribs = {}
820 if not bugmatch and not fixmatch:
820 if not bugmatch and not fixmatch:
821 break
821 break
822 if not bugmatch:
822 if not bugmatch:
823 m = fixmatch
823 m = fixmatch
824 elif not fixmatch:
824 elif not fixmatch:
825 m = bugmatch
825 m = bugmatch
826 else:
826 else:
827 if bugmatch.start() < fixmatch.start():
827 if bugmatch.start() < fixmatch.start():
828 m = bugmatch
828 m = bugmatch
829 else:
829 else:
830 m = fixmatch
830 m = fixmatch
831 start = m.end()
831 start = m.end()
832 if m is bugmatch:
832 if m is bugmatch:
833 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
833 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
834 if 'fix' in bugattribs:
834 if 'fix' in bugattribs:
835 del bugattribs['fix']
835 del bugattribs['fix']
836 else:
836 else:
837 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
837 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
838 bugattribs['fix'] = None
838 bugattribs['fix'] = None
839
839
840 try:
840 try:
841 ids = m.group('ids')
841 ids = m.group('ids')
842 except IndexError:
842 except IndexError:
843 ids = m.group(1)
843 ids = m.group(1)
844 try:
844 try:
845 hours = float(m.group('hours'))
845 hours = float(m.group('hours'))
846 bugattribs['hours'] = hours
846 bugattribs['hours'] = hours
847 except IndexError:
847 except IndexError:
848 pass
848 pass
849 except TypeError:
849 except TypeError:
850 pass
850 pass
851 except ValueError:
851 except ValueError:
852 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
852 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
853
853
854 for id in bugzilla._split_re.split(ids):
854 for id in bugzilla._split_re.split(ids):
855 if not id:
855 if not id:
856 continue
856 continue
857 bugs[int(id)] = bugattribs
857 bugs[int(id)] = bugattribs
858 if bugs:
858 if bugs:
859 self.filter_real_bug_ids(bugs)
859 self.filter_real_bug_ids(bugs)
860 if bugs:
860 if bugs:
861 self.filter_cset_known_bug_ids(ctx.node(), bugs)
861 self.filter_cset_known_bug_ids(ctx.node(), bugs)
862 return bugs
862 return bugs
863
863
864 def update(self, bugid, newstate, ctx):
864 def update(self, bugid, newstate, ctx):
865 '''update bugzilla bug with reference to changeset.'''
865 '''update bugzilla bug with reference to changeset.'''
866
866
867 def webroot(root):
867 def webroot(root):
868 '''strip leading prefix of repo root and turn into
868 '''strip leading prefix of repo root and turn into
869 url-safe path.'''
869 url-safe path.'''
870 count = int(self.ui.config('bugzilla', 'strip', 0))
870 count = int(self.ui.config('bugzilla', 'strip', 0))
871 root = util.pconvert(root)
871 root = util.pconvert(root)
872 while count > 0:
872 while count > 0:
873 c = root.find('/')
873 c = root.find('/')
874 if c == -1:
874 if c == -1:
875 break
875 break
876 root = root[c + 1:]
876 root = root[c + 1:]
877 count -= 1
877 count -= 1
878 return root
878 return root
879
879
880 mapfile = self.ui.config('bugzilla', 'style')
880 mapfile = self.ui.config('bugzilla', 'style')
881 tmpl = self.ui.config('bugzilla', 'template')
881 tmpl = self.ui.config('bugzilla', 'template')
882 t = cmdutil.changeset_templater(self.ui, self.repo,
882 t = cmdutil.changeset_templater(self.ui, self.repo,
883 False, None, mapfile, False)
883 False, None, mapfile, False)
884 if not mapfile and not tmpl:
884 if not mapfile and not tmpl:
885 tmpl = _('changeset {node|short} in repo {root} refers '
885 tmpl = _('changeset {node|short} in repo {root} refers '
886 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
886 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
887 if tmpl:
887 if tmpl:
888 tmpl = templater.parsestring(tmpl, quoted=False)
888 tmpl = templater.parsestring(tmpl, quoted=False)
889 t.use_template(tmpl)
889 t.use_template(tmpl)
890 self.ui.pushbuffer()
890 self.ui.pushbuffer()
891 t.show(ctx, changes=ctx.changeset(),
891 t.show(ctx, changes=ctx.changeset(),
892 bug=str(bugid),
892 bug=str(bugid),
893 hgweb=self.ui.config('web', 'baseurl'),
893 hgweb=self.ui.config('web', 'baseurl'),
894 root=self.repo.root,
894 root=self.repo.root,
895 webroot=webroot(self.repo.root))
895 webroot=webroot(self.repo.root))
896 data = self.ui.popbuffer()
896 data = self.ui.popbuffer()
897 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
897 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
898
898
899 def hook(ui, repo, hooktype, node=None, **kwargs):
899 def hook(ui, repo, hooktype, node=None, **kwargs):
900 '''add comment to bugzilla for each changeset that refers to a
900 '''add comment to bugzilla for each changeset that refers to a
901 bugzilla bug id. only add a comment once per bug, so same change
901 bugzilla bug id. only add a comment once per bug, so same change
902 seen multiple times does not fill bug with duplicate data.'''
902 seen multiple times does not fill bug with duplicate data.'''
903 if node is None:
903 if node is None:
904 raise util.Abort(_('hook type %s does not pass a changeset id') %
904 raise util.Abort(_('hook type %s does not pass a changeset id') %
905 hooktype)
905 hooktype)
906 try:
906 try:
907 bz = bugzilla(ui, repo)
907 bz = bugzilla(ui, repo)
908 ctx = repo[node]
908 ctx = repo[node]
909 bugs = bz.find_bugs(ctx)
909 bugs = bz.find_bugs(ctx)
910 if bugs:
910 if bugs:
911 for bug in bugs:
911 for bug in bugs:
912 bz.update(bug, bugs[bug], ctx)
912 bz.update(bug, bugs[bug], ctx)
913 bz.notify(bugs, util.email(ctx.user()))
913 bz.notify(bugs, util.email(ctx.user()))
914 except Exception, e:
914 except Exception, e:
915 raise util.Abort(_('Bugzilla error: %s') % e)
915 raise util.Abort(_('Bugzilla error: %s') % e)
General Comments 0
You need to be logged in to leave comments. Login now