##// END OF EJS Templates
merge with stable
Matt Mackall -
r21850:3b97a93d merge default
parent child Browse files
Show More
@@ -1,923 +1,923
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)
@@ -1,426 +1,429
1 # Mercurial bookmark support code
1 # Mercurial bookmark support code
2 #
2 #
3 # Copyright 2008 David Soria Parra <dsp@php.net>
3 # Copyright 2008 David Soria Parra <dsp@php.net>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 from mercurial.i18n import _
8 from mercurial.i18n import _
9 from mercurial.node import hex, bin
9 from mercurial.node import hex, bin
10 from mercurial import encoding, error, util, obsolete
10 from mercurial import encoding, error, util, obsolete
11 import errno
11 import errno
12
12
13 class bmstore(dict):
13 class bmstore(dict):
14 """Storage for bookmarks.
14 """Storage for bookmarks.
15
15
16 This object should do all bookmark reads and writes, so that it's
16 This object should do all bookmark reads and writes, so that it's
17 fairly simple to replace the storage underlying bookmarks without
17 fairly simple to replace the storage underlying bookmarks without
18 having to clone the logic surrounding bookmarks.
18 having to clone the logic surrounding bookmarks.
19
19
20 This particular bmstore implementation stores bookmarks as
20 This particular bmstore implementation stores bookmarks as
21 {hash}\s{name}\n (the same format as localtags) in
21 {hash}\s{name}\n (the same format as localtags) in
22 .hg/bookmarks. The mapping is stored as {name: nodeid}.
22 .hg/bookmarks. The mapping is stored as {name: nodeid}.
23
23
24 This class does NOT handle the "current" bookmark state at this
24 This class does NOT handle the "current" bookmark state at this
25 time.
25 time.
26 """
26 """
27
27
28 def __init__(self, repo):
28 def __init__(self, repo):
29 dict.__init__(self)
29 dict.__init__(self)
30 self._repo = repo
30 self._repo = repo
31 try:
31 try:
32 for line in repo.vfs('bookmarks'):
32 for line in repo.vfs('bookmarks'):
33 line = line.strip()
33 line = line.strip()
34 if not line:
34 if not line:
35 continue
35 continue
36 if ' ' not in line:
36 if ' ' not in line:
37 repo.ui.warn(_('malformed line in .hg/bookmarks: %r\n')
37 repo.ui.warn(_('malformed line in .hg/bookmarks: %r\n')
38 % line)
38 % line)
39 continue
39 continue
40 sha, refspec = line.split(' ', 1)
40 sha, refspec = line.split(' ', 1)
41 refspec = encoding.tolocal(refspec)
41 refspec = encoding.tolocal(refspec)
42 try:
42 try:
43 self[refspec] = repo.changelog.lookup(sha)
43 self[refspec] = repo.changelog.lookup(sha)
44 except LookupError:
44 except LookupError:
45 pass
45 pass
46 except IOError, inst:
46 except IOError, inst:
47 if inst.errno != errno.ENOENT:
47 if inst.errno != errno.ENOENT:
48 raise
48 raise
49
49
50 def write(self):
50 def write(self):
51 '''Write bookmarks
51 '''Write bookmarks
52
52
53 Write the given bookmark => hash dictionary to the .hg/bookmarks file
53 Write the given bookmark => hash dictionary to the .hg/bookmarks file
54 in a format equal to those of localtags.
54 in a format equal to those of localtags.
55
55
56 We also store a backup of the previous state in undo.bookmarks that
56 We also store a backup of the previous state in undo.bookmarks that
57 can be copied back on rollback.
57 can be copied back on rollback.
58 '''
58 '''
59 repo = self._repo
59 repo = self._repo
60 if repo._bookmarkcurrent not in self:
60 if repo._bookmarkcurrent not in self:
61 unsetcurrent(repo)
61 unsetcurrent(repo)
62
62
63 wlock = repo.wlock()
63 wlock = repo.wlock()
64 try:
64 try:
65
65
66 file = repo.vfs('bookmarks', 'w', atomictemp=True)
66 file = repo.vfs('bookmarks', 'w', atomictemp=True)
67 for name, node in self.iteritems():
67 for name, node in self.iteritems():
68 file.write("%s %s\n" % (hex(node), encoding.fromlocal(name)))
68 file.write("%s %s\n" % (hex(node), encoding.fromlocal(name)))
69 file.close()
69 file.close()
70
70
71 # touch 00changelog.i so hgweb reloads bookmarks (no lock needed)
71 # touch 00changelog.i so hgweb reloads bookmarks (no lock needed)
72 try:
72 try:
73 repo.svfs.utime('00changelog.i', None)
73 repo.svfs.utime('00changelog.i', None)
74 except OSError:
74 except OSError:
75 pass
75 pass
76
76
77 finally:
77 finally:
78 wlock.release()
78 wlock.release()
79
79
80 def readcurrent(repo):
80 def readcurrent(repo):
81 '''Get the current bookmark
81 '''Get the current bookmark
82
82
83 If we use gittish branches we have a current bookmark that
83 If we use gittish branches we have a current bookmark that
84 we are on. This function returns the name of the bookmark. It
84 we are on. This function returns the name of the bookmark. It
85 is stored in .hg/bookmarks.current
85 is stored in .hg/bookmarks.current
86 '''
86 '''
87 mark = None
87 mark = None
88 try:
88 try:
89 file = repo.opener('bookmarks.current')
89 file = repo.opener('bookmarks.current')
90 except IOError, inst:
90 except IOError, inst:
91 if inst.errno != errno.ENOENT:
91 if inst.errno != errno.ENOENT:
92 raise
92 raise
93 return None
93 return None
94 try:
94 try:
95 # No readline() in osutil.posixfile, reading everything is cheap
95 # No readline() in osutil.posixfile, reading everything is cheap
96 mark = encoding.tolocal((file.readlines() or [''])[0])
96 mark = encoding.tolocal((file.readlines() or [''])[0])
97 if mark == '' or mark not in repo._bookmarks:
97 if mark == '' or mark not in repo._bookmarks:
98 mark = None
98 mark = None
99 finally:
99 finally:
100 file.close()
100 file.close()
101 return mark
101 return mark
102
102
103 def setcurrent(repo, mark):
103 def setcurrent(repo, mark):
104 '''Set the name of the bookmark that we are currently on
104 '''Set the name of the bookmark that we are currently on
105
105
106 Set the name of the bookmark that we are on (hg update <bookmark>).
106 Set the name of the bookmark that we are on (hg update <bookmark>).
107 The name is recorded in .hg/bookmarks.current
107 The name is recorded in .hg/bookmarks.current
108 '''
108 '''
109 if mark not in repo._bookmarks:
109 if mark not in repo._bookmarks:
110 raise AssertionError('bookmark %s does not exist!' % mark)
110 raise AssertionError('bookmark %s does not exist!' % mark)
111
111
112 current = repo._bookmarkcurrent
112 current = repo._bookmarkcurrent
113 if current == mark:
113 if current == mark:
114 return
114 return
115
115
116 wlock = repo.wlock()
116 wlock = repo.wlock()
117 try:
117 try:
118 file = repo.opener('bookmarks.current', 'w', atomictemp=True)
118 file = repo.opener('bookmarks.current', 'w', atomictemp=True)
119 file.write(encoding.fromlocal(mark))
119 file.write(encoding.fromlocal(mark))
120 file.close()
120 file.close()
121 finally:
121 finally:
122 wlock.release()
122 wlock.release()
123 repo._bookmarkcurrent = mark
123 repo._bookmarkcurrent = mark
124
124
125 def unsetcurrent(repo):
125 def unsetcurrent(repo):
126 wlock = repo.wlock()
126 wlock = repo.wlock()
127 try:
127 try:
128 try:
128 try:
129 repo.vfs.unlink('bookmarks.current')
129 repo.vfs.unlink('bookmarks.current')
130 repo._bookmarkcurrent = None
130 repo._bookmarkcurrent = None
131 except OSError, inst:
131 except OSError, inst:
132 if inst.errno != errno.ENOENT:
132 if inst.errno != errno.ENOENT:
133 raise
133 raise
134 finally:
134 finally:
135 wlock.release()
135 wlock.release()
136
136
137 def iscurrent(repo, mark=None, parents=None):
137 def iscurrent(repo, mark=None, parents=None):
138 '''Tell whether the current bookmark is also active
138 '''Tell whether the current bookmark is also active
139
139
140 I.e., the bookmark listed in .hg/bookmarks.current also points to a
140 I.e., the bookmark listed in .hg/bookmarks.current also points to a
141 parent of the working directory.
141 parent of the working directory.
142 '''
142 '''
143 if not mark:
143 if not mark:
144 mark = repo._bookmarkcurrent
144 mark = repo._bookmarkcurrent
145 if not parents:
145 if not parents:
146 parents = [p.node() for p in repo[None].parents()]
146 parents = [p.node() for p in repo[None].parents()]
147 marks = repo._bookmarks
147 marks = repo._bookmarks
148 return (mark in marks and marks[mark] in parents)
148 return (mark in marks and marks[mark] in parents)
149
149
150 def updatecurrentbookmark(repo, oldnode, curbranch):
150 def updatecurrentbookmark(repo, oldnode, curbranch):
151 try:
151 try:
152 return update(repo, oldnode, repo.branchtip(curbranch))
152 return update(repo, oldnode, repo.branchtip(curbranch))
153 except error.RepoLookupError:
153 except error.RepoLookupError:
154 if curbranch == "default": # no default branch!
154 if curbranch == "default": # no default branch!
155 return update(repo, oldnode, repo.lookup("tip"))
155 return update(repo, oldnode, repo.lookup("tip"))
156 else:
156 else:
157 raise util.Abort(_("branch %s not found") % curbranch)
157 raise util.Abort(_("branch %s not found") % curbranch)
158
158
159 def deletedivergent(repo, deletefrom, bm):
159 def deletedivergent(repo, deletefrom, bm):
160 '''Delete divergent versions of bm on nodes in deletefrom.
160 '''Delete divergent versions of bm on nodes in deletefrom.
161
161
162 Return True if at least one bookmark was deleted, False otherwise.'''
162 Return True if at least one bookmark was deleted, False otherwise.'''
163 deleted = False
163 deleted = False
164 marks = repo._bookmarks
164 marks = repo._bookmarks
165 divergent = [b for b in marks if b.split('@', 1)[0] == bm.split('@', 1)[0]]
165 divergent = [b for b in marks if b.split('@', 1)[0] == bm.split('@', 1)[0]]
166 for mark in divergent:
166 for mark in divergent:
167 if mark == '@' or '@' not in mark:
168 # can't be divergent by definition
169 continue
167 if mark and marks[mark] in deletefrom:
170 if mark and marks[mark] in deletefrom:
168 if mark != bm:
171 if mark != bm:
169 del marks[mark]
172 del marks[mark]
170 deleted = True
173 deleted = True
171 return deleted
174 return deleted
172
175
173 def calculateupdate(ui, repo, checkout):
176 def calculateupdate(ui, repo, checkout):
174 '''Return a tuple (targetrev, movemarkfrom) indicating the rev to
177 '''Return a tuple (targetrev, movemarkfrom) indicating the rev to
175 check out and where to move the active bookmark from, if needed.'''
178 check out and where to move the active bookmark from, if needed.'''
176 movemarkfrom = None
179 movemarkfrom = None
177 if checkout is None:
180 if checkout is None:
178 curmark = repo._bookmarkcurrent
181 curmark = repo._bookmarkcurrent
179 if iscurrent(repo):
182 if iscurrent(repo):
180 movemarkfrom = repo['.'].node()
183 movemarkfrom = repo['.'].node()
181 elif curmark:
184 elif curmark:
182 ui.status(_("updating to active bookmark %s\n") % curmark)
185 ui.status(_("updating to active bookmark %s\n") % curmark)
183 checkout = curmark
186 checkout = curmark
184 return (checkout, movemarkfrom)
187 return (checkout, movemarkfrom)
185
188
186 def update(repo, parents, node):
189 def update(repo, parents, node):
187 deletefrom = parents
190 deletefrom = parents
188 marks = repo._bookmarks
191 marks = repo._bookmarks
189 update = False
192 update = False
190 cur = repo._bookmarkcurrent
193 cur = repo._bookmarkcurrent
191 if not cur:
194 if not cur:
192 return False
195 return False
193
196
194 if marks[cur] in parents:
197 if marks[cur] in parents:
195 new = repo[node]
198 new = repo[node]
196 divs = [repo[b] for b in marks
199 divs = [repo[b] for b in marks
197 if b.split('@', 1)[0] == cur.split('@', 1)[0]]
200 if b.split('@', 1)[0] == cur.split('@', 1)[0]]
198 anc = repo.changelog.ancestors([new.rev()])
201 anc = repo.changelog.ancestors([new.rev()])
199 deletefrom = [b.node() for b in divs if b.rev() in anc or b == new]
202 deletefrom = [b.node() for b in divs if b.rev() in anc or b == new]
200 if validdest(repo, repo[marks[cur]], new):
203 if validdest(repo, repo[marks[cur]], new):
201 marks[cur] = new.node()
204 marks[cur] = new.node()
202 update = True
205 update = True
203
206
204 if deletedivergent(repo, deletefrom, cur):
207 if deletedivergent(repo, deletefrom, cur):
205 update = True
208 update = True
206
209
207 if update:
210 if update:
208 marks.write()
211 marks.write()
209 return update
212 return update
210
213
211 def listbookmarks(repo):
214 def listbookmarks(repo):
212 # We may try to list bookmarks on a repo type that does not
215 # We may try to list bookmarks on a repo type that does not
213 # support it (e.g., statichttprepository).
216 # support it (e.g., statichttprepository).
214 marks = getattr(repo, '_bookmarks', {})
217 marks = getattr(repo, '_bookmarks', {})
215
218
216 d = {}
219 d = {}
217 hasnode = repo.changelog.hasnode
220 hasnode = repo.changelog.hasnode
218 for k, v in marks.iteritems():
221 for k, v in marks.iteritems():
219 # don't expose local divergent bookmarks
222 # don't expose local divergent bookmarks
220 if hasnode(v) and ('@' not in k or k.endswith('@')):
223 if hasnode(v) and ('@' not in k or k.endswith('@')):
221 d[k] = hex(v)
224 d[k] = hex(v)
222 return d
225 return d
223
226
224 def pushbookmark(repo, key, old, new):
227 def pushbookmark(repo, key, old, new):
225 w = repo.wlock()
228 w = repo.wlock()
226 try:
229 try:
227 marks = repo._bookmarks
230 marks = repo._bookmarks
228 if hex(marks.get(key, '')) != old:
231 if hex(marks.get(key, '')) != old:
229 return False
232 return False
230 if new == '':
233 if new == '':
231 del marks[key]
234 del marks[key]
232 else:
235 else:
233 if new not in repo:
236 if new not in repo:
234 return False
237 return False
235 marks[key] = repo[new].node()
238 marks[key] = repo[new].node()
236 marks.write()
239 marks.write()
237 return True
240 return True
238 finally:
241 finally:
239 w.release()
242 w.release()
240
243
241 def compare(repo, srcmarks, dstmarks,
244 def compare(repo, srcmarks, dstmarks,
242 srchex=None, dsthex=None, targets=None):
245 srchex=None, dsthex=None, targets=None):
243 '''Compare bookmarks between srcmarks and dstmarks
246 '''Compare bookmarks between srcmarks and dstmarks
244
247
245 This returns tuple "(addsrc, adddst, advsrc, advdst, diverge,
248 This returns tuple "(addsrc, adddst, advsrc, advdst, diverge,
246 differ, invalid)", each are list of bookmarks below:
249 differ, invalid)", each are list of bookmarks below:
247
250
248 :addsrc: added on src side (removed on dst side, perhaps)
251 :addsrc: added on src side (removed on dst side, perhaps)
249 :adddst: added on dst side (removed on src side, perhaps)
252 :adddst: added on dst side (removed on src side, perhaps)
250 :advsrc: advanced on src side
253 :advsrc: advanced on src side
251 :advdst: advanced on dst side
254 :advdst: advanced on dst side
252 :diverge: diverge
255 :diverge: diverge
253 :differ: changed, but changeset referred on src is unknown on dst
256 :differ: changed, but changeset referred on src is unknown on dst
254 :invalid: unknown on both side
257 :invalid: unknown on both side
255
258
256 Each elements of lists in result tuple is tuple "(bookmark name,
259 Each elements of lists in result tuple is tuple "(bookmark name,
257 changeset ID on source side, changeset ID on destination
260 changeset ID on source side, changeset ID on destination
258 side)". Each changeset IDs are 40 hexadecimal digit string or
261 side)". Each changeset IDs are 40 hexadecimal digit string or
259 None.
262 None.
260
263
261 Changeset IDs of tuples in "addsrc", "adddst", "differ" or
264 Changeset IDs of tuples in "addsrc", "adddst", "differ" or
262 "invalid" list may be unknown for repo.
265 "invalid" list may be unknown for repo.
263
266
264 This function expects that "srcmarks" and "dstmarks" return
267 This function expects that "srcmarks" and "dstmarks" return
265 changeset ID in 40 hexadecimal digit string for specified
268 changeset ID in 40 hexadecimal digit string for specified
266 bookmark. If not so (e.g. bmstore "repo._bookmarks" returning
269 bookmark. If not so (e.g. bmstore "repo._bookmarks" returning
267 binary value), "srchex" or "dsthex" should be specified to convert
270 binary value), "srchex" or "dsthex" should be specified to convert
268 into such form.
271 into such form.
269
272
270 If "targets" is specified, only bookmarks listed in it are
273 If "targets" is specified, only bookmarks listed in it are
271 examined.
274 examined.
272 '''
275 '''
273 if not srchex:
276 if not srchex:
274 srchex = lambda x: x
277 srchex = lambda x: x
275 if not dsthex:
278 if not dsthex:
276 dsthex = lambda x: x
279 dsthex = lambda x: x
277
280
278 if targets:
281 if targets:
279 bset = set(targets)
282 bset = set(targets)
280 else:
283 else:
281 srcmarkset = set(srcmarks)
284 srcmarkset = set(srcmarks)
282 dstmarkset = set(dstmarks)
285 dstmarkset = set(dstmarks)
283 bset = srcmarkset ^ dstmarkset
286 bset = srcmarkset ^ dstmarkset
284 for b in srcmarkset & dstmarkset:
287 for b in srcmarkset & dstmarkset:
285 if srchex(srcmarks[b]) != dsthex(dstmarks[b]):
288 if srchex(srcmarks[b]) != dsthex(dstmarks[b]):
286 bset.add(b)
289 bset.add(b)
287
290
288 results = ([], [], [], [], [], [], [])
291 results = ([], [], [], [], [], [], [])
289 addsrc = results[0].append
292 addsrc = results[0].append
290 adddst = results[1].append
293 adddst = results[1].append
291 advsrc = results[2].append
294 advsrc = results[2].append
292 advdst = results[3].append
295 advdst = results[3].append
293 diverge = results[4].append
296 diverge = results[4].append
294 differ = results[5].append
297 differ = results[5].append
295 invalid = results[6].append
298 invalid = results[6].append
296
299
297 for b in sorted(bset):
300 for b in sorted(bset):
298 if b not in srcmarks:
301 if b not in srcmarks:
299 if b in dstmarks:
302 if b in dstmarks:
300 adddst((b, None, dsthex(dstmarks[b])))
303 adddst((b, None, dsthex(dstmarks[b])))
301 else:
304 else:
302 invalid((b, None, None))
305 invalid((b, None, None))
303 elif b not in dstmarks:
306 elif b not in dstmarks:
304 addsrc((b, srchex(srcmarks[b]), None))
307 addsrc((b, srchex(srcmarks[b]), None))
305 else:
308 else:
306 scid = srchex(srcmarks[b])
309 scid = srchex(srcmarks[b])
307 dcid = dsthex(dstmarks[b])
310 dcid = dsthex(dstmarks[b])
308 if scid in repo and dcid in repo:
311 if scid in repo and dcid in repo:
309 sctx = repo[scid]
312 sctx = repo[scid]
310 dctx = repo[dcid]
313 dctx = repo[dcid]
311 if sctx.rev() < dctx.rev():
314 if sctx.rev() < dctx.rev():
312 if validdest(repo, sctx, dctx):
315 if validdest(repo, sctx, dctx):
313 advdst((b, scid, dcid))
316 advdst((b, scid, dcid))
314 else:
317 else:
315 diverge((b, scid, dcid))
318 diverge((b, scid, dcid))
316 else:
319 else:
317 if validdest(repo, dctx, sctx):
320 if validdest(repo, dctx, sctx):
318 advsrc((b, scid, dcid))
321 advsrc((b, scid, dcid))
319 else:
322 else:
320 diverge((b, scid, dcid))
323 diverge((b, scid, dcid))
321 else:
324 else:
322 # it is too expensive to examine in detail, in this case
325 # it is too expensive to examine in detail, in this case
323 differ((b, scid, dcid))
326 differ((b, scid, dcid))
324
327
325 return results
328 return results
326
329
327 def _diverge(ui, b, path, localmarks):
330 def _diverge(ui, b, path, localmarks):
328 if b == '@':
331 if b == '@':
329 b = ''
332 b = ''
330 # find a unique @ suffix
333 # find a unique @ suffix
331 for x in range(1, 100):
334 for x in range(1, 100):
332 n = '%s@%d' % (b, x)
335 n = '%s@%d' % (b, x)
333 if n not in localmarks:
336 if n not in localmarks:
334 break
337 break
335 # try to use an @pathalias suffix
338 # try to use an @pathalias suffix
336 # if an @pathalias already exists, we overwrite (update) it
339 # if an @pathalias already exists, we overwrite (update) it
337 for p, u in ui.configitems("paths"):
340 for p, u in ui.configitems("paths"):
338 if path == u:
341 if path == u:
339 n = '%s@%s' % (b, p)
342 n = '%s@%s' % (b, p)
340 return n
343 return n
341
344
342 def updatefromremote(ui, repo, remotemarks, path):
345 def updatefromremote(ui, repo, remotemarks, path):
343 ui.debug("checking for updated bookmarks\n")
346 ui.debug("checking for updated bookmarks\n")
344 localmarks = repo._bookmarks
347 localmarks = repo._bookmarks
345 (addsrc, adddst, advsrc, advdst, diverge, differ, invalid
348 (addsrc, adddst, advsrc, advdst, diverge, differ, invalid
346 ) = compare(repo, remotemarks, localmarks, dsthex=hex)
349 ) = compare(repo, remotemarks, localmarks, dsthex=hex)
347
350
348 changed = []
351 changed = []
349 for b, scid, dcid in addsrc:
352 for b, scid, dcid in addsrc:
350 if scid in repo: # add remote bookmarks for changes we already have
353 if scid in repo: # add remote bookmarks for changes we already have
351 changed.append((b, bin(scid), ui.status,
354 changed.append((b, bin(scid), ui.status,
352 _("adding remote bookmark %s\n") % (b)))
355 _("adding remote bookmark %s\n") % (b)))
353 for b, scid, dcid in advsrc:
356 for b, scid, dcid in advsrc:
354 changed.append((b, bin(scid), ui.status,
357 changed.append((b, bin(scid), ui.status,
355 _("updating bookmark %s\n") % (b)))
358 _("updating bookmark %s\n") % (b)))
356 for b, scid, dcid in diverge:
359 for b, scid, dcid in diverge:
357 db = _diverge(ui, b, path, localmarks)
360 db = _diverge(ui, b, path, localmarks)
358 changed.append((db, bin(scid), ui.warn,
361 changed.append((db, bin(scid), ui.warn,
359 _("divergent bookmark %s stored as %s\n") % (b, db)))
362 _("divergent bookmark %s stored as %s\n") % (b, db)))
360 if changed:
363 if changed:
361 for b, node, writer, msg in sorted(changed):
364 for b, node, writer, msg in sorted(changed):
362 localmarks[b] = node
365 localmarks[b] = node
363 writer(msg)
366 writer(msg)
364 localmarks.write()
367 localmarks.write()
365
368
366 def pushtoremote(ui, repo, remote, targets):
369 def pushtoremote(ui, repo, remote, targets):
367 (addsrc, adddst, advsrc, advdst, diverge, differ, invalid
370 (addsrc, adddst, advsrc, advdst, diverge, differ, invalid
368 ) = compare(repo, repo._bookmarks, remote.listkeys('bookmarks'),
371 ) = compare(repo, repo._bookmarks, remote.listkeys('bookmarks'),
369 srchex=hex, targets=targets)
372 srchex=hex, targets=targets)
370 if invalid:
373 if invalid:
371 b, scid, dcid = invalid[0]
374 b, scid, dcid = invalid[0]
372 ui.warn(_('bookmark %s does not exist on the local '
375 ui.warn(_('bookmark %s does not exist on the local '
373 'or remote repository!\n') % b)
376 'or remote repository!\n') % b)
374 return 2
377 return 2
375
378
376 def push(b, old, new):
379 def push(b, old, new):
377 r = remote.pushkey('bookmarks', b, old, new)
380 r = remote.pushkey('bookmarks', b, old, new)
378 if not r:
381 if not r:
379 ui.warn(_('updating bookmark %s failed!\n') % b)
382 ui.warn(_('updating bookmark %s failed!\n') % b)
380 return 1
383 return 1
381 return 0
384 return 0
382 failed = 0
385 failed = 0
383 for b, scid, dcid in sorted(addsrc + advsrc + advdst + diverge + differ):
386 for b, scid, dcid in sorted(addsrc + advsrc + advdst + diverge + differ):
384 ui.status(_("exporting bookmark %s\n") % b)
387 ui.status(_("exporting bookmark %s\n") % b)
385 if dcid is None:
388 if dcid is None:
386 dcid = ''
389 dcid = ''
387 failed += push(b, dcid, scid)
390 failed += push(b, dcid, scid)
388 for b, scid, dcid in adddst:
391 for b, scid, dcid in adddst:
389 # treat as "deleted locally"
392 # treat as "deleted locally"
390 ui.status(_("deleting remote bookmark %s\n") % b)
393 ui.status(_("deleting remote bookmark %s\n") % b)
391 failed += push(b, dcid, '')
394 failed += push(b, dcid, '')
392
395
393 if failed:
396 if failed:
394 return 1
397 return 1
395
398
396 def diff(ui, dst, src):
399 def diff(ui, dst, src):
397 ui.status(_("searching for changed bookmarks\n"))
400 ui.status(_("searching for changed bookmarks\n"))
398
401
399 smarks = src.listkeys('bookmarks')
402 smarks = src.listkeys('bookmarks')
400 dmarks = dst.listkeys('bookmarks')
403 dmarks = dst.listkeys('bookmarks')
401
404
402 diff = sorted(set(smarks) - set(dmarks))
405 diff = sorted(set(smarks) - set(dmarks))
403 for k in diff:
406 for k in diff:
404 mark = ui.debugflag and smarks[k] or smarks[k][:12]
407 mark = ui.debugflag and smarks[k] or smarks[k][:12]
405 ui.write(" %-25s %s\n" % (k, mark))
408 ui.write(" %-25s %s\n" % (k, mark))
406
409
407 if len(diff) <= 0:
410 if len(diff) <= 0:
408 ui.status(_("no changed bookmarks found\n"))
411 ui.status(_("no changed bookmarks found\n"))
409 return 1
412 return 1
410 return 0
413 return 0
411
414
412 def validdest(repo, old, new):
415 def validdest(repo, old, new):
413 """Is the new bookmark destination a valid update from the old one"""
416 """Is the new bookmark destination a valid update from the old one"""
414 repo = repo.unfiltered()
417 repo = repo.unfiltered()
415 if old == new:
418 if old == new:
416 # Old == new -> nothing to update.
419 # Old == new -> nothing to update.
417 return False
420 return False
418 elif not old:
421 elif not old:
419 # old is nullrev, anything is valid.
422 # old is nullrev, anything is valid.
420 # (new != nullrev has been excluded by the previous check)
423 # (new != nullrev has been excluded by the previous check)
421 return True
424 return True
422 elif repo.obsstore:
425 elif repo.obsstore:
423 return new.node() in obsolete.foreground(repo, [old.node()])
426 return new.node() in obsolete.foreground(repo, [old.node()])
424 else:
427 else:
425 # still an independent clause as it is lazyer (and therefore faster)
428 # still an independent clause as it is lazyer (and therefore faster)
426 return old.descendant(new)
429 return old.descendant(new)
General Comments 0
You need to be logged in to leave comments. Login now