##// END OF EJS Templates
bugzilla: correct config documentation error...
Jim Hague -
r21842:fd2527d9 stable
parent child Browse files
Show More
@@ -1,923 +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-4 Jim Hague <jim.hague@acm.org>
4 # Copyright 2011-4 Jim Hague <jim.hague@acm.org>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 '''hooks for integrating with the Bugzilla bug tracker
9 '''hooks for integrating with the Bugzilla bug tracker
10
10
11 This hook extension adds comments on bugs in Bugzilla when changesets
11 This hook extension adds comments on bugs in Bugzilla when changesets
12 that refer to bugs by Bugzilla ID are seen. The comment is formatted using
12 that refer to bugs by Bugzilla ID are seen. The comment is formatted using
13 the Mercurial template mechanism.
13 the Mercurial template mechanism.
14
14
15 The bug references can optionally include an update for Bugzilla of the
15 The bug references can optionally include an update for Bugzilla of the
16 hours spent working on the bug. Bugs can also be marked fixed.
16 hours spent working on the bug. Bugs can also be marked fixed.
17
17
18 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+email
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 prior to 4.4.3.
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 login = self.bzproxy.User.login({'login': user, 'password': passwd,
623 login = self.bzproxy.User.login({'login': user, 'password': passwd,
624 'restrict_login': True})
624 'restrict_login': True})
625 self.bztoken = login.get('token', '')
625 self.bztoken = login.get('token', '')
626
626
627 def transport(self, uri):
627 def transport(self, uri):
628 if urlparse.urlparse(uri, "http")[0] == "https":
628 if urlparse.urlparse(uri, "http")[0] == "https":
629 return cookiesafetransport()
629 return cookiesafetransport()
630 else:
630 else:
631 return cookietransport()
631 return cookietransport()
632
632
633 def get_bug_comments(self, id):
633 def get_bug_comments(self, id):
634 """Return a string with all comment text for a bug."""
634 """Return a string with all comment text for a bug."""
635 c = self.bzproxy.Bug.comments({'ids': [id],
635 c = self.bzproxy.Bug.comments({'ids': [id],
636 'include_fields': ['text'],
636 'include_fields': ['text'],
637 'token': self.bztoken})
637 'token': self.bztoken})
638 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']])
639
639
640 def filter_real_bug_ids(self, bugs):
640 def filter_real_bug_ids(self, bugs):
641 probe = self.bzproxy.Bug.get({'ids': sorted(bugs.keys()),
641 probe = self.bzproxy.Bug.get({'ids': sorted(bugs.keys()),
642 'include_fields': [],
642 'include_fields': [],
643 'permissive': True,
643 'permissive': True,
644 'token': self.bztoken,
644 'token': self.bztoken,
645 })
645 })
646 for badbug in probe['faults']:
646 for badbug in probe['faults']:
647 id = badbug['id']
647 id = badbug['id']
648 self.ui.status(_('bug %d does not exist\n') % id)
648 self.ui.status(_('bug %d does not exist\n') % id)
649 del bugs[id]
649 del bugs[id]
650
650
651 def filter_cset_known_bug_ids(self, node, bugs):
651 def filter_cset_known_bug_ids(self, node, bugs):
652 for id in sorted(bugs.keys()):
652 for id in sorted(bugs.keys()):
653 if self.get_bug_comments(id).find(short(node)) != -1:
653 if self.get_bug_comments(id).find(short(node)) != -1:
654 self.ui.status(_('bug %d already knows about changeset %s\n') %
654 self.ui.status(_('bug %d already knows about changeset %s\n') %
655 (id, short(node)))
655 (id, short(node)))
656 del bugs[id]
656 del bugs[id]
657
657
658 def updatebug(self, bugid, newstate, text, committer):
658 def updatebug(self, bugid, newstate, text, committer):
659 args = {}
659 args = {}
660 if 'hours' in newstate:
660 if 'hours' in newstate:
661 args['work_time'] = newstate['hours']
661 args['work_time'] = newstate['hours']
662
662
663 if self.bzvermajor >= 4:
663 if self.bzvermajor >= 4:
664 args['ids'] = [bugid]
664 args['ids'] = [bugid]
665 args['comment'] = {'body' : text}
665 args['comment'] = {'body' : text}
666 if 'fix' in newstate:
666 if 'fix' in newstate:
667 args['status'] = self.fixstatus
667 args['status'] = self.fixstatus
668 args['resolution'] = self.fixresolution
668 args['resolution'] = self.fixresolution
669 args['token'] = self.bztoken
669 args['token'] = self.bztoken
670 self.bzproxy.Bug.update(args)
670 self.bzproxy.Bug.update(args)
671 else:
671 else:
672 if 'fix' in newstate:
672 if 'fix' in newstate:
673 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
673 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
674 "to mark bugs fixed\n"))
674 "to mark bugs fixed\n"))
675 args['id'] = bugid
675 args['id'] = bugid
676 args['comment'] = text
676 args['comment'] = text
677 self.bzproxy.Bug.add_comment(args)
677 self.bzproxy.Bug.add_comment(args)
678
678
679 class bzxmlrpcemail(bzxmlrpc):
679 class bzxmlrpcemail(bzxmlrpc):
680 """Read data from Bugzilla via XMLRPC, send updates via email.
680 """Read data from Bugzilla via XMLRPC, send updates via email.
681
681
682 Advantages of sending updates via email:
682 Advantages of sending updates via email:
683 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.
684 2. Bug statuses or other fields not accessible via XMLRPC can
684 2. Bug statuses or other fields not accessible via XMLRPC can
685 potentially be updated.
685 potentially be updated.
686
686
687 There is no XMLRPC function to change bug status before Bugzilla
687 There is no XMLRPC function to change bug status before Bugzilla
688 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.
689 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.
690 """
690 """
691
691
692 # 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,
693 # in-email fields are specified as '@<fieldname> = <value>'. In
693 # in-email fields are specified as '@<fieldname> = <value>'. In
694 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
694 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
695 # 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
696 # 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
697 # 4.0 onwards.
697 # 4.0 onwards.
698
698
699 def __init__(self, ui):
699 def __init__(self, ui):
700 bzxmlrpc.__init__(self, ui)
700 bzxmlrpc.__init__(self, ui)
701
701
702 self.bzemail = self.ui.config('bugzilla', 'bzemail')
702 self.bzemail = self.ui.config('bugzilla', 'bzemail')
703 if not self.bzemail:
703 if not self.bzemail:
704 raise util.Abort(_("configuration 'bzemail' missing"))
704 raise util.Abort(_("configuration 'bzemail' missing"))
705 mail.validateconfig(self.ui)
705 mail.validateconfig(self.ui)
706
706
707 def makecommandline(self, fieldname, value):
707 def makecommandline(self, fieldname, value):
708 if self.bzvermajor >= 4:
708 if self.bzvermajor >= 4:
709 return "@%s %s" % (fieldname, str(value))
709 return "@%s %s" % (fieldname, str(value))
710 else:
710 else:
711 if fieldname == "id":
711 if fieldname == "id":
712 fieldname = "bug_id"
712 fieldname = "bug_id"
713 return "@%s = %s" % (fieldname, str(value))
713 return "@%s = %s" % (fieldname, str(value))
714
714
715 def send_bug_modify_email(self, bugid, commands, comment, committer):
715 def send_bug_modify_email(self, bugid, commands, comment, committer):
716 '''send modification message to Bugzilla bug via email.
716 '''send modification message to Bugzilla bug via email.
717
717
718 The message format is documented in the Bugzilla email_in.pl
718 The message format is documented in the Bugzilla email_in.pl
719 specification. commands is a list of command lines, comment is the
719 specification. commands is a list of command lines, comment is the
720 comment text.
720 comment text.
721
721
722 To stop users from crafting commit comments with
722 To stop users from crafting commit comments with
723 Bugzilla commands, specify the bug ID via the message body, rather
723 Bugzilla commands, specify the bug ID via the message body, rather
724 than the subject line, and leave a blank line after it.
724 than the subject line, and leave a blank line after it.
725 '''
725 '''
726 user = self.map_committer(committer)
726 user = self.map_committer(committer)
727 matches = self.bzproxy.User.get({'match': [user],
727 matches = self.bzproxy.User.get({'match': [user],
728 'token': self.bztoken})
728 'token': self.bztoken})
729 if not matches['users']:
729 if not matches['users']:
730 user = self.ui.config('bugzilla', 'user', 'bugs')
730 user = self.ui.config('bugzilla', 'user', 'bugs')
731 matches = self.bzproxy.User.get({'match': [user],
731 matches = self.bzproxy.User.get({'match': [user],
732 'token': self.bztoken})
732 'token': self.bztoken})
733 if not matches['users']:
733 if not matches['users']:
734 raise util.Abort(_("default bugzilla user %s email not found") %
734 raise util.Abort(_("default bugzilla user %s email not found") %
735 user)
735 user)
736 user = matches['users'][0]['email']
736 user = matches['users'][0]['email']
737 commands.append(self.makecommandline("id", bugid))
737 commands.append(self.makecommandline("id", bugid))
738
738
739 text = "\n".join(commands) + "\n\n" + comment
739 text = "\n".join(commands) + "\n\n" + comment
740
740
741 _charsets = mail._charsets(self.ui)
741 _charsets = mail._charsets(self.ui)
742 user = mail.addressencode(self.ui, user, _charsets)
742 user = mail.addressencode(self.ui, user, _charsets)
743 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
743 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
744 msg = mail.mimeencode(self.ui, text, _charsets)
744 msg = mail.mimeencode(self.ui, text, _charsets)
745 msg['From'] = user
745 msg['From'] = user
746 msg['To'] = bzemail
746 msg['To'] = bzemail
747 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
747 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
748 sendmail = mail.connect(self.ui)
748 sendmail = mail.connect(self.ui)
749 sendmail(user, bzemail, msg.as_string())
749 sendmail(user, bzemail, msg.as_string())
750
750
751 def updatebug(self, bugid, newstate, text, committer):
751 def updatebug(self, bugid, newstate, text, committer):
752 cmds = []
752 cmds = []
753 if 'hours' in newstate:
753 if 'hours' in newstate:
754 cmds.append(self.makecommandline("work_time", newstate['hours']))
754 cmds.append(self.makecommandline("work_time", newstate['hours']))
755 if 'fix' in newstate:
755 if 'fix' in newstate:
756 cmds.append(self.makecommandline("bug_status", self.fixstatus))
756 cmds.append(self.makecommandline("bug_status", self.fixstatus))
757 cmds.append(self.makecommandline("resolution", self.fixresolution))
757 cmds.append(self.makecommandline("resolution", self.fixresolution))
758 self.send_bug_modify_email(bugid, cmds, text, committer)
758 self.send_bug_modify_email(bugid, cmds, text, committer)
759
759
760 class bugzilla(object):
760 class bugzilla(object):
761 # supported versions of bugzilla. different versions have
761 # supported versions of bugzilla. different versions have
762 # different schemas.
762 # different schemas.
763 _versions = {
763 _versions = {
764 '2.16': bzmysql,
764 '2.16': bzmysql,
765 '2.18': bzmysql_2_18,
765 '2.18': bzmysql_2_18,
766 '3.0': bzmysql_3_0,
766 '3.0': bzmysql_3_0,
767 'xmlrpc': bzxmlrpc,
767 'xmlrpc': bzxmlrpc,
768 'xmlrpc+email': bzxmlrpcemail
768 'xmlrpc+email': bzxmlrpcemail
769 }
769 }
770
770
771 _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*'
772 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
772 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
773 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
773 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
774
774
775 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
775 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
776 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
776 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
777 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
777 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
778 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
778 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
779
779
780 _bz = None
780 _bz = None
781
781
782 def __init__(self, ui, repo):
782 def __init__(self, ui, repo):
783 self.ui = ui
783 self.ui = ui
784 self.repo = repo
784 self.repo = repo
785
785
786 def bz(self):
786 def bz(self):
787 '''return object that knows how to talk to bugzilla version in
787 '''return object that knows how to talk to bugzilla version in
788 use.'''
788 use.'''
789
789
790 if bugzilla._bz is None:
790 if bugzilla._bz is None:
791 bzversion = self.ui.config('bugzilla', 'version')
791 bzversion = self.ui.config('bugzilla', 'version')
792 try:
792 try:
793 bzclass = bugzilla._versions[bzversion]
793 bzclass = bugzilla._versions[bzversion]
794 except KeyError:
794 except KeyError:
795 raise util.Abort(_('bugzilla version %s not supported') %
795 raise util.Abort(_('bugzilla version %s not supported') %
796 bzversion)
796 bzversion)
797 bugzilla._bz = bzclass(self.ui)
797 bugzilla._bz = bzclass(self.ui)
798 return bugzilla._bz
798 return bugzilla._bz
799
799
800 def __getattr__(self, key):
800 def __getattr__(self, key):
801 return getattr(self.bz(), key)
801 return getattr(self.bz(), key)
802
802
803 _bug_re = None
803 _bug_re = None
804 _fix_re = None
804 _fix_re = None
805 _split_re = None
805 _split_re = None
806
806
807 def find_bugs(self, ctx):
807 def find_bugs(self, ctx):
808 '''return bugs dictionary created from commit comment.
808 '''return bugs dictionary created from commit comment.
809
809
810 Extract bug info from changeset comments. Filter out any that are
810 Extract bug info from changeset comments. Filter out any that are
811 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
812 the given changeset in their comments.
812 the given changeset in their comments.
813 '''
813 '''
814 if bugzilla._bug_re is None:
814 if bugzilla._bug_re is None:
815 bugzilla._bug_re = re.compile(
815 bugzilla._bug_re = re.compile(
816 self.ui.config('bugzilla', 'regexp',
816 self.ui.config('bugzilla', 'regexp',
817 bugzilla._default_bug_re), re.IGNORECASE)
817 bugzilla._default_bug_re), re.IGNORECASE)
818 bugzilla._fix_re = re.compile(
818 bugzilla._fix_re = re.compile(
819 self.ui.config('bugzilla', 'fixregexp',
819 self.ui.config('bugzilla', 'fixregexp',
820 bugzilla._default_fix_re), re.IGNORECASE)
820 bugzilla._default_fix_re), re.IGNORECASE)
821 bugzilla._split_re = re.compile(r'\D+')
821 bugzilla._split_re = re.compile(r'\D+')
822 start = 0
822 start = 0
823 hours = 0.0
823 hours = 0.0
824 bugs = {}
824 bugs = {}
825 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
825 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
826 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
826 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
827 while True:
827 while True:
828 bugattribs = {}
828 bugattribs = {}
829 if not bugmatch and not fixmatch:
829 if not bugmatch and not fixmatch:
830 break
830 break
831 if not bugmatch:
831 if not bugmatch:
832 m = fixmatch
832 m = fixmatch
833 elif not fixmatch:
833 elif not fixmatch:
834 m = bugmatch
834 m = bugmatch
835 else:
835 else:
836 if bugmatch.start() < fixmatch.start():
836 if bugmatch.start() < fixmatch.start():
837 m = bugmatch
837 m = bugmatch
838 else:
838 else:
839 m = fixmatch
839 m = fixmatch
840 start = m.end()
840 start = m.end()
841 if m is bugmatch:
841 if m is bugmatch:
842 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
842 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
843 if 'fix' in bugattribs:
843 if 'fix' in bugattribs:
844 del bugattribs['fix']
844 del bugattribs['fix']
845 else:
845 else:
846 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
846 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
847 bugattribs['fix'] = None
847 bugattribs['fix'] = None
848
848
849 try:
849 try:
850 ids = m.group('ids')
850 ids = m.group('ids')
851 except IndexError:
851 except IndexError:
852 ids = m.group(1)
852 ids = m.group(1)
853 try:
853 try:
854 hours = float(m.group('hours'))
854 hours = float(m.group('hours'))
855 bugattribs['hours'] = hours
855 bugattribs['hours'] = hours
856 except IndexError:
856 except IndexError:
857 pass
857 pass
858 except TypeError:
858 except TypeError:
859 pass
859 pass
860 except ValueError:
860 except ValueError:
861 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
861 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
862
862
863 for id in bugzilla._split_re.split(ids):
863 for id in bugzilla._split_re.split(ids):
864 if not id:
864 if not id:
865 continue
865 continue
866 bugs[int(id)] = bugattribs
866 bugs[int(id)] = bugattribs
867 if bugs:
867 if bugs:
868 self.filter_real_bug_ids(bugs)
868 self.filter_real_bug_ids(bugs)
869 if bugs:
869 if bugs:
870 self.filter_cset_known_bug_ids(ctx.node(), bugs)
870 self.filter_cset_known_bug_ids(ctx.node(), bugs)
871 return bugs
871 return bugs
872
872
873 def update(self, bugid, newstate, ctx):
873 def update(self, bugid, newstate, ctx):
874 '''update bugzilla bug with reference to changeset.'''
874 '''update bugzilla bug with reference to changeset.'''
875
875
876 def webroot(root):
876 def webroot(root):
877 '''strip leading prefix of repo root and turn into
877 '''strip leading prefix of repo root and turn into
878 url-safe path.'''
878 url-safe path.'''
879 count = int(self.ui.config('bugzilla', 'strip', 0))
879 count = int(self.ui.config('bugzilla', 'strip', 0))
880 root = util.pconvert(root)
880 root = util.pconvert(root)
881 while count > 0:
881 while count > 0:
882 c = root.find('/')
882 c = root.find('/')
883 if c == -1:
883 if c == -1:
884 break
884 break
885 root = root[c + 1:]
885 root = root[c + 1:]
886 count -= 1
886 count -= 1
887 return root
887 return root
888
888
889 mapfile = self.ui.config('bugzilla', 'style')
889 mapfile = self.ui.config('bugzilla', 'style')
890 tmpl = self.ui.config('bugzilla', 'template')
890 tmpl = self.ui.config('bugzilla', 'template')
891 if not mapfile and not tmpl:
891 if not mapfile and not tmpl:
892 tmpl = _('changeset {node|short} in repo {root} refers '
892 tmpl = _('changeset {node|short} in repo {root} refers '
893 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
893 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
894 if tmpl:
894 if tmpl:
895 tmpl = templater.parsestring(tmpl, quoted=False)
895 tmpl = templater.parsestring(tmpl, quoted=False)
896 t = cmdutil.changeset_templater(self.ui, self.repo,
896 t = cmdutil.changeset_templater(self.ui, self.repo,
897 False, None, tmpl, mapfile, False)
897 False, None, tmpl, mapfile, False)
898 self.ui.pushbuffer()
898 self.ui.pushbuffer()
899 t.show(ctx, changes=ctx.changeset(),
899 t.show(ctx, changes=ctx.changeset(),
900 bug=str(bugid),
900 bug=str(bugid),
901 hgweb=self.ui.config('web', 'baseurl'),
901 hgweb=self.ui.config('web', 'baseurl'),
902 root=self.repo.root,
902 root=self.repo.root,
903 webroot=webroot(self.repo.root))
903 webroot=webroot(self.repo.root))
904 data = self.ui.popbuffer()
904 data = self.ui.popbuffer()
905 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
905 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
906
906
907 def hook(ui, repo, hooktype, node=None, **kwargs):
907 def hook(ui, repo, hooktype, node=None, **kwargs):
908 '''add comment to bugzilla for each changeset that refers to a
908 '''add comment to bugzilla for each changeset that refers to a
909 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
910 seen multiple times does not fill bug with duplicate data.'''
910 seen multiple times does not fill bug with duplicate data.'''
911 if node is None:
911 if node is None:
912 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') %
913 hooktype)
913 hooktype)
914 try:
914 try:
915 bz = bugzilla(ui, repo)
915 bz = bugzilla(ui, repo)
916 ctx = repo[node]
916 ctx = repo[node]
917 bugs = bz.find_bugs(ctx)
917 bugs = bz.find_bugs(ctx)
918 if bugs:
918 if bugs:
919 for bug in bugs:
919 for bug in bugs:
920 bz.update(bug, bugs[bug], ctx)
920 bz.update(bug, bugs[bug], ctx)
921 bz.notify(bugs, util.email(ctx.user()))
921 bz.notify(bugs, util.email(ctx.user()))
922 except Exception, e:
922 except Exception, e:
923 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