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