##// END OF EJS Templates
merge with stable
Matt Mackall -
r21850:3b97a93d merge default
parent child Browse files
Show More
@@ -1,923 +1,923
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 # Copyright 2011-4 Jim Hague <jim.hague@acm.org>
5 5 #
6 6 # This software may be used and distributed according to the terms of the
7 7 # GNU General Public License version 2 or any later version.
8 8
9 9 '''hooks for integrating with the Bugzilla bug tracker
10 10
11 11 This hook extension adds comments on bugs in Bugzilla when changesets
12 12 that refer to bugs by Bugzilla ID are seen. The comment is formatted using
13 13 the Mercurial template mechanism.
14 14
15 15 The bug references can optionally include an update for Bugzilla of the
16 16 hours spent working on the bug. Bugs can also be marked fixed.
17 17
18 18 Three basic modes of access to Bugzilla are provided:
19 19
20 20 1. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
21 21
22 22 2. Check data via the Bugzilla XMLRPC interface and submit bug change
23 23 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
24 24
25 25 3. Writing directly to the Bugzilla database. Only Bugzilla installations
26 26 using MySQL are supported. Requires Python MySQLdb.
27 27
28 28 Writing directly to the database is susceptible to schema changes, and
29 29 relies on a Bugzilla contrib script to send out bug change
30 30 notification emails. This script runs as the user running Mercurial,
31 31 must be run on the host with the Bugzilla install, and requires
32 32 permission to read Bugzilla configuration details and the necessary
33 33 MySQL user and password to have full access rights to the Bugzilla
34 34 database. For these reasons this access mode is now considered
35 35 deprecated, and will not be updated for new Bugzilla versions going
36 36 forward. Only adding comments is supported in this access mode.
37 37
38 38 Access via XMLRPC needs a Bugzilla username and password to be specified
39 39 in the configuration. Comments are added under that username. Since the
40 40 configuration must be readable by all Mercurial users, it is recommended
41 41 that the rights of that user are restricted in Bugzilla to the minimum
42 42 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
43 43
44 44 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
45 45 email to the Bugzilla email interface to submit comments to bugs.
46 46 The From: address in the email is set to the email address of the Mercurial
47 47 user, so the comment appears to come from the Mercurial user. In the event
48 48 that the Mercurial user email is not recognized by Bugzilla as a Bugzilla
49 49 user, the email associated with the Bugzilla username used to log into
50 50 Bugzilla is used instead as the source of the comment. Marking bugs fixed
51 51 works on all supported Bugzilla versions.
52 52
53 53 Configuration items common to all access modes:
54 54
55 55 bugzilla.version
56 56 The access type to use. Values recognized are:
57 57
58 58 :``xmlrpc``: Bugzilla XMLRPC interface.
59 59 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
60 60 :``3.0``: MySQL access, Bugzilla 3.0 and later.
61 61 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
62 62 including 3.0.
63 63 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
64 64 including 2.18.
65 65
66 66 bugzilla.regexp
67 67 Regular expression to match bug IDs for update in changeset commit message.
68 68 It must contain one "()" named group ``<ids>`` containing the bug
69 69 IDs separated by non-digit characters. It may also contain
70 70 a named group ``<hours>`` with a floating-point number giving the
71 71 hours worked on the bug. If no named groups are present, the first
72 72 "()" group is assumed to contain the bug IDs, and work time is not
73 73 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
74 74 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
75 75 variations thereof, followed by an hours number prefixed by ``h`` or
76 76 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
77 77
78 78 bugzilla.fixregexp
79 79 Regular expression to match bug IDs for marking fixed in changeset
80 80 commit message. This must contain a "()" named group ``<ids>` containing
81 81 the bug IDs separated by non-digit characters. It may also contain
82 82 a named group ``<hours>`` with a floating-point number giving the
83 83 hours worked on the bug. If no named groups are present, the first
84 84 "()" group is assumed to contain the bug IDs, and work time is not
85 85 updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``,
86 86 ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and
87 87 variations thereof, followed by an hours number prefixed by ``h`` or
88 88 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
89 89
90 90 bugzilla.fixstatus
91 91 The status to set a bug to when marking fixed. Default ``RESOLVED``.
92 92
93 93 bugzilla.fixresolution
94 94 The resolution to set a bug to when marking fixed. Default ``FIXED``.
95 95
96 96 bugzilla.style
97 97 The style file to use when formatting comments.
98 98
99 99 bugzilla.template
100 100 Template to use when formatting comments. Overrides style if
101 101 specified. In addition to the usual Mercurial keywords, the
102 102 extension specifies:
103 103
104 104 :``{bug}``: The Bugzilla bug ID.
105 105 :``{root}``: The full pathname of the Mercurial repository.
106 106 :``{webroot}``: Stripped pathname of the Mercurial repository.
107 107 :``{hgweb}``: Base URL for browsing Mercurial repositories.
108 108
109 109 Default ``changeset {node|short} in repo {root} refers to bug
110 110 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
111 111
112 112 bugzilla.strip
113 113 The number of path separator characters to strip from the front of
114 114 the Mercurial repository path (``{root}`` in templates) to produce
115 115 ``{webroot}``. For example, a repository with ``{root}``
116 116 ``/var/local/my-project`` with a strip of 2 gives a value for
117 117 ``{webroot}`` of ``my-project``. Default 0.
118 118
119 119 web.baseurl
120 120 Base URL for browsing Mercurial repositories. Referenced from
121 121 templates as ``{hgweb}``.
122 122
123 123 Configuration items common to XMLRPC+email and MySQL access modes:
124 124
125 125 bugzilla.usermap
126 126 Path of file containing Mercurial committer email to Bugzilla user email
127 127 mappings. If specified, the file should contain one mapping per
128 128 line::
129 129
130 130 committer = Bugzilla user
131 131
132 132 See also the ``[usermap]`` section.
133 133
134 134 The ``[usermap]`` section is used to specify mappings of Mercurial
135 135 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
136 136 Contains entries of the form ``committer = Bugzilla user``.
137 137
138 138 XMLRPC access mode configuration:
139 139
140 140 bugzilla.bzurl
141 141 The base URL for the Bugzilla installation.
142 142 Default ``http://localhost/bugzilla``.
143 143
144 144 bugzilla.user
145 145 The username to use to log into Bugzilla via XMLRPC. Default
146 146 ``bugs``.
147 147
148 148 bugzilla.password
149 149 The password for Bugzilla login.
150 150
151 151 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
152 152 and also:
153 153
154 154 bugzilla.bzemail
155 155 The Bugzilla email address.
156 156
157 157 In addition, the Mercurial email settings must be configured. See the
158 158 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
159 159
160 160 MySQL access mode configuration:
161 161
162 162 bugzilla.host
163 163 Hostname of the MySQL server holding the Bugzilla database.
164 164 Default ``localhost``.
165 165
166 166 bugzilla.db
167 167 Name of the Bugzilla database in MySQL. Default ``bugs``.
168 168
169 169 bugzilla.user
170 170 Username to use to access MySQL server. Default ``bugs``.
171 171
172 172 bugzilla.password
173 173 Password to use to access MySQL server.
174 174
175 175 bugzilla.timeout
176 176 Database connection timeout (seconds). Default 5.
177 177
178 178 bugzilla.bzuser
179 179 Fallback Bugzilla user name to record comments with, if changeset
180 180 committer cannot be found as a Bugzilla user.
181 181
182 182 bugzilla.bzdir
183 183 Bugzilla install directory. Used by default notify. Default
184 184 ``/var/www/html/bugzilla``.
185 185
186 186 bugzilla.notify
187 187 The command to run to get Bugzilla to send bug change notification
188 188 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
189 189 id) and ``user`` (committer bugzilla email). Default depends on
190 190 version; from 2.18 it is "cd %(bzdir)s && perl -T
191 191 contrib/sendbugmail.pl %(id)s %(user)s".
192 192
193 193 Activating the extension::
194 194
195 195 [extensions]
196 196 bugzilla =
197 197
198 198 [hooks]
199 199 # run bugzilla hook on every change pulled or pushed in here
200 200 incoming.bugzilla = python:hgext.bugzilla.hook
201 201
202 202 Example configurations:
203 203
204 204 XMLRPC example configuration. This uses the Bugzilla at
205 205 ``http://my-project.org/bugzilla``, logging in as user
206 206 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
207 207 collection of Mercurial repositories in ``/var/local/hg/repos/``,
208 208 with a web interface at ``http://my-project.org/hg``. ::
209 209
210 210 [bugzilla]
211 211 bzurl=http://my-project.org/bugzilla
212 212 user=bugmail@my-project.org
213 213 password=plugh
214 214 version=xmlrpc
215 215 template=Changeset {node|short} in {root|basename}.
216 216 {hgweb}/{webroot}/rev/{node|short}\\n
217 217 {desc}\\n
218 218 strip=5
219 219
220 220 [web]
221 221 baseurl=http://my-project.org/hg
222 222
223 223 XMLRPC+email example configuration. This uses the Bugzilla at
224 224 ``http://my-project.org/bugzilla``, logging in as user
225 225 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
226 226 collection of Mercurial repositories in ``/var/local/hg/repos/``,
227 227 with a web interface at ``http://my-project.org/hg``. Bug comments
228 228 are sent to the Bugzilla email address
229 229 ``bugzilla@my-project.org``. ::
230 230
231 231 [bugzilla]
232 232 bzurl=http://my-project.org/bugzilla
233 233 user=bugmail@my-project.org
234 234 password=plugh
235 version=xmlrpc
235 version=xmlrpc+email
236 236 bzemail=bugzilla@my-project.org
237 237 template=Changeset {node|short} in {root|basename}.
238 238 {hgweb}/{webroot}/rev/{node|short}\\n
239 239 {desc}\\n
240 240 strip=5
241 241
242 242 [web]
243 243 baseurl=http://my-project.org/hg
244 244
245 245 [usermap]
246 246 user@emaildomain.com=user.name@bugzilladomain.com
247 247
248 248 MySQL example configuration. This has a local Bugzilla 3.2 installation
249 249 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
250 250 the Bugzilla database name is ``bugs`` and MySQL is
251 251 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
252 252 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
253 253 with a web interface at ``http://my-project.org/hg``. ::
254 254
255 255 [bugzilla]
256 256 host=localhost
257 257 password=XYZZY
258 258 version=3.0
259 259 bzuser=unknown@domain.com
260 260 bzdir=/opt/bugzilla-3.2
261 261 template=Changeset {node|short} in {root|basename}.
262 262 {hgweb}/{webroot}/rev/{node|short}\\n
263 263 {desc}\\n
264 264 strip=5
265 265
266 266 [web]
267 267 baseurl=http://my-project.org/hg
268 268
269 269 [usermap]
270 270 user@emaildomain.com=user.name@bugzilladomain.com
271 271
272 272 All the above add a comment to the Bugzilla bug record of the form::
273 273
274 274 Changeset 3b16791d6642 in repository-name.
275 275 http://my-project.org/hg/repository-name/rev/3b16791d6642
276 276
277 277 Changeset commit comment. Bug 1234.
278 278 '''
279 279
280 280 from mercurial.i18n import _
281 281 from mercurial.node import short
282 282 from mercurial import cmdutil, mail, templater, util
283 283 import re, time, urlparse, xmlrpclib
284 284
285 285 testedwith = 'internal'
286 286
287 287 class bzaccess(object):
288 288 '''Base class for access to Bugzilla.'''
289 289
290 290 def __init__(self, ui):
291 291 self.ui = ui
292 292 usermap = self.ui.config('bugzilla', 'usermap')
293 293 if usermap:
294 294 self.ui.readconfig(usermap, sections=['usermap'])
295 295
296 296 def map_committer(self, user):
297 297 '''map name of committer to Bugzilla user name.'''
298 298 for committer, bzuser in self.ui.configitems('usermap'):
299 299 if committer.lower() == user.lower():
300 300 return bzuser
301 301 return user
302 302
303 303 # Methods to be implemented by access classes.
304 304 #
305 305 # 'bugs' is a dict keyed on bug id, where values are a dict holding
306 306 # updates to bug state. Recognized dict keys are:
307 307 #
308 308 # 'hours': Value, float containing work hours to be updated.
309 309 # 'fix': If key present, bug is to be marked fixed. Value ignored.
310 310
311 311 def filter_real_bug_ids(self, bugs):
312 312 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
313 313 pass
314 314
315 315 def filter_cset_known_bug_ids(self, node, bugs):
316 316 '''remove bug IDs where node occurs in comment text from bugs.'''
317 317 pass
318 318
319 319 def updatebug(self, bugid, newstate, text, committer):
320 320 '''update the specified bug. Add comment text and set new states.
321 321
322 322 If possible add the comment as being from the committer of
323 323 the changeset. Otherwise use the default Bugzilla user.
324 324 '''
325 325 pass
326 326
327 327 def notify(self, bugs, committer):
328 328 '''Force sending of Bugzilla notification emails.
329 329
330 330 Only required if the access method does not trigger notification
331 331 emails automatically.
332 332 '''
333 333 pass
334 334
335 335 # Bugzilla via direct access to MySQL database.
336 336 class bzmysql(bzaccess):
337 337 '''Support for direct MySQL access to Bugzilla.
338 338
339 339 The earliest Bugzilla version this is tested with is version 2.16.
340 340
341 341 If your Bugzilla is version 3.4 or above, you are strongly
342 342 recommended to use the XMLRPC access method instead.
343 343 '''
344 344
345 345 @staticmethod
346 346 def sql_buglist(ids):
347 347 '''return SQL-friendly list of bug ids'''
348 348 return '(' + ','.join(map(str, ids)) + ')'
349 349
350 350 _MySQLdb = None
351 351
352 352 def __init__(self, ui):
353 353 try:
354 354 import MySQLdb as mysql
355 355 bzmysql._MySQLdb = mysql
356 356 except ImportError, err:
357 357 raise util.Abort(_('python mysql support not available: %s') % err)
358 358
359 359 bzaccess.__init__(self, ui)
360 360
361 361 host = self.ui.config('bugzilla', 'host', 'localhost')
362 362 user = self.ui.config('bugzilla', 'user', 'bugs')
363 363 passwd = self.ui.config('bugzilla', 'password')
364 364 db = self.ui.config('bugzilla', 'db', 'bugs')
365 365 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
366 366 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
367 367 (host, db, user, '*' * len(passwd)))
368 368 self.conn = bzmysql._MySQLdb.connect(host=host,
369 369 user=user, passwd=passwd,
370 370 db=db,
371 371 connect_timeout=timeout)
372 372 self.cursor = self.conn.cursor()
373 373 self.longdesc_id = self.get_longdesc_id()
374 374 self.user_ids = {}
375 375 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
376 376
377 377 def run(self, *args, **kwargs):
378 378 '''run a query.'''
379 379 self.ui.note(_('query: %s %s\n') % (args, kwargs))
380 380 try:
381 381 self.cursor.execute(*args, **kwargs)
382 382 except bzmysql._MySQLdb.MySQLError:
383 383 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
384 384 raise
385 385
386 386 def get_longdesc_id(self):
387 387 '''get identity of longdesc field'''
388 388 self.run('select fieldid from fielddefs where name = "longdesc"')
389 389 ids = self.cursor.fetchall()
390 390 if len(ids) != 1:
391 391 raise util.Abort(_('unknown database schema'))
392 392 return ids[0][0]
393 393
394 394 def filter_real_bug_ids(self, bugs):
395 395 '''filter not-existing bugs from set.'''
396 396 self.run('select bug_id from bugs where bug_id in %s' %
397 397 bzmysql.sql_buglist(bugs.keys()))
398 398 existing = [id for (id,) in self.cursor.fetchall()]
399 399 for id in bugs.keys():
400 400 if id not in existing:
401 401 self.ui.status(_('bug %d does not exist\n') % id)
402 402 del bugs[id]
403 403
404 404 def filter_cset_known_bug_ids(self, node, bugs):
405 405 '''filter bug ids that already refer to this changeset from set.'''
406 406 self.run('''select bug_id from longdescs where
407 407 bug_id in %s and thetext like "%%%s%%"''' %
408 408 (bzmysql.sql_buglist(bugs.keys()), short(node)))
409 409 for (id,) in self.cursor.fetchall():
410 410 self.ui.status(_('bug %d already knows about changeset %s\n') %
411 411 (id, short(node)))
412 412 del bugs[id]
413 413
414 414 def notify(self, bugs, committer):
415 415 '''tell bugzilla to send mail.'''
416 416 self.ui.status(_('telling bugzilla to send mail:\n'))
417 417 (user, userid) = self.get_bugzilla_user(committer)
418 418 for id in bugs.keys():
419 419 self.ui.status(_(' bug %s\n') % id)
420 420 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
421 421 bzdir = self.ui.config('bugzilla', 'bzdir',
422 422 '/var/www/html/bugzilla')
423 423 try:
424 424 # Backwards-compatible with old notify string, which
425 425 # took one string. This will throw with a new format
426 426 # string.
427 427 cmd = cmdfmt % id
428 428 except TypeError:
429 429 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
430 430 self.ui.note(_('running notify command %s\n') % cmd)
431 431 fp = util.popen('(%s) 2>&1' % cmd)
432 432 out = fp.read()
433 433 ret = fp.close()
434 434 if ret:
435 435 self.ui.warn(out)
436 436 raise util.Abort(_('bugzilla notify command %s') %
437 437 util.explainexit(ret)[0])
438 438 self.ui.status(_('done\n'))
439 439
440 440 def get_user_id(self, user):
441 441 '''look up numeric bugzilla user id.'''
442 442 try:
443 443 return self.user_ids[user]
444 444 except KeyError:
445 445 try:
446 446 userid = int(user)
447 447 except ValueError:
448 448 self.ui.note(_('looking up user %s\n') % user)
449 449 self.run('''select userid from profiles
450 450 where login_name like %s''', user)
451 451 all = self.cursor.fetchall()
452 452 if len(all) != 1:
453 453 raise KeyError(user)
454 454 userid = int(all[0][0])
455 455 self.user_ids[user] = userid
456 456 return userid
457 457
458 458 def get_bugzilla_user(self, committer):
459 459 '''See if committer is a registered bugzilla user. Return
460 460 bugzilla username and userid if so. If not, return default
461 461 bugzilla username and userid.'''
462 462 user = self.map_committer(committer)
463 463 try:
464 464 userid = self.get_user_id(user)
465 465 except KeyError:
466 466 try:
467 467 defaultuser = self.ui.config('bugzilla', 'bzuser')
468 468 if not defaultuser:
469 469 raise util.Abort(_('cannot find bugzilla user id for %s') %
470 470 user)
471 471 userid = self.get_user_id(defaultuser)
472 472 user = defaultuser
473 473 except KeyError:
474 474 raise util.Abort(_('cannot find bugzilla user id for %s or %s')
475 475 % (user, defaultuser))
476 476 return (user, userid)
477 477
478 478 def updatebug(self, bugid, newstate, text, committer):
479 479 '''update bug state with comment text.
480 480
481 481 Try adding comment as committer of changeset, otherwise as
482 482 default bugzilla user.'''
483 483 if len(newstate) > 0:
484 484 self.ui.warn(_("Bugzilla/MySQL cannot update bug state\n"))
485 485
486 486 (user, userid) = self.get_bugzilla_user(committer)
487 487 now = time.strftime('%Y-%m-%d %H:%M:%S')
488 488 self.run('''insert into longdescs
489 489 (bug_id, who, bug_when, thetext)
490 490 values (%s, %s, %s, %s)''',
491 491 (bugid, userid, now, text))
492 492 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
493 493 values (%s, %s, %s, %s)''',
494 494 (bugid, userid, now, self.longdesc_id))
495 495 self.conn.commit()
496 496
497 497 class bzmysql_2_18(bzmysql):
498 498 '''support for bugzilla 2.18 series.'''
499 499
500 500 def __init__(self, ui):
501 501 bzmysql.__init__(self, ui)
502 502 self.default_notify = \
503 503 "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
504 504
505 505 class bzmysql_3_0(bzmysql_2_18):
506 506 '''support for bugzilla 3.0 series.'''
507 507
508 508 def __init__(self, ui):
509 509 bzmysql_2_18.__init__(self, ui)
510 510
511 511 def get_longdesc_id(self):
512 512 '''get identity of longdesc field'''
513 513 self.run('select id from fielddefs where name = "longdesc"')
514 514 ids = self.cursor.fetchall()
515 515 if len(ids) != 1:
516 516 raise util.Abort(_('unknown database schema'))
517 517 return ids[0][0]
518 518
519 519 # Bugzilla via XMLRPC interface.
520 520
521 521 class cookietransportrequest(object):
522 522 """A Transport request method that retains cookies over its lifetime.
523 523
524 524 The regular xmlrpclib transports ignore cookies. Which causes
525 525 a bit of a problem when you need a cookie-based login, as with
526 526 the Bugzilla XMLRPC interface prior to 4.4.3.
527 527
528 528 So this is a helper for defining a Transport which looks for
529 529 cookies being set in responses and saves them to add to all future
530 530 requests.
531 531 """
532 532
533 533 # Inspiration drawn from
534 534 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
535 535 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
536 536
537 537 cookies = []
538 538 def send_cookies(self, connection):
539 539 if self.cookies:
540 540 for cookie in self.cookies:
541 541 connection.putheader("Cookie", cookie)
542 542
543 543 def request(self, host, handler, request_body, verbose=0):
544 544 self.verbose = verbose
545 545 self.accept_gzip_encoding = False
546 546
547 547 # issue XML-RPC request
548 548 h = self.make_connection(host)
549 549 if verbose:
550 550 h.set_debuglevel(1)
551 551
552 552 self.send_request(h, handler, request_body)
553 553 self.send_host(h, host)
554 554 self.send_cookies(h)
555 555 self.send_user_agent(h)
556 556 self.send_content(h, request_body)
557 557
558 558 # Deal with differences between Python 2.4-2.6 and 2.7.
559 559 # In the former h is a HTTP(S). In the latter it's a
560 560 # HTTP(S)Connection. Luckily, the 2.4-2.6 implementation of
561 561 # HTTP(S) has an underlying HTTP(S)Connection, so extract
562 562 # that and use it.
563 563 try:
564 564 response = h.getresponse()
565 565 except AttributeError:
566 566 response = h._conn.getresponse()
567 567
568 568 # Add any cookie definitions to our list.
569 569 for header in response.msg.getallmatchingheaders("Set-Cookie"):
570 570 val = header.split(": ", 1)[1]
571 571 cookie = val.split(";", 1)[0]
572 572 self.cookies.append(cookie)
573 573
574 574 if response.status != 200:
575 575 raise xmlrpclib.ProtocolError(host + handler, response.status,
576 576 response.reason, response.msg.headers)
577 577
578 578 payload = response.read()
579 579 parser, unmarshaller = self.getparser()
580 580 parser.feed(payload)
581 581 parser.close()
582 582
583 583 return unmarshaller.close()
584 584
585 585 # The explicit calls to the underlying xmlrpclib __init__() methods are
586 586 # necessary. The xmlrpclib.Transport classes are old-style classes, and
587 587 # it turns out their __init__() doesn't get called when doing multiple
588 588 # inheritance with a new-style class.
589 589 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
590 590 def __init__(self, use_datetime=0):
591 591 if util.safehasattr(xmlrpclib.Transport, "__init__"):
592 592 xmlrpclib.Transport.__init__(self, use_datetime)
593 593
594 594 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
595 595 def __init__(self, use_datetime=0):
596 596 if util.safehasattr(xmlrpclib.Transport, "__init__"):
597 597 xmlrpclib.SafeTransport.__init__(self, use_datetime)
598 598
599 599 class bzxmlrpc(bzaccess):
600 600 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
601 601
602 602 Requires a minimum Bugzilla version 3.4.
603 603 """
604 604
605 605 def __init__(self, ui):
606 606 bzaccess.__init__(self, ui)
607 607
608 608 bzweb = self.ui.config('bugzilla', 'bzurl',
609 609 'http://localhost/bugzilla/')
610 610 bzweb = bzweb.rstrip("/") + "/xmlrpc.cgi"
611 611
612 612 user = self.ui.config('bugzilla', 'user', 'bugs')
613 613 passwd = self.ui.config('bugzilla', 'password')
614 614
615 615 self.fixstatus = self.ui.config('bugzilla', 'fixstatus', 'RESOLVED')
616 616 self.fixresolution = self.ui.config('bugzilla', 'fixresolution',
617 617 'FIXED')
618 618
619 619 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
620 620 ver = self.bzproxy.Bugzilla.version()['version'].split('.')
621 621 self.bzvermajor = int(ver[0])
622 622 self.bzverminor = int(ver[1])
623 623 login = self.bzproxy.User.login({'login': user, 'password': passwd,
624 624 'restrict_login': True})
625 625 self.bztoken = login.get('token', '')
626 626
627 627 def transport(self, uri):
628 628 if urlparse.urlparse(uri, "http")[0] == "https":
629 629 return cookiesafetransport()
630 630 else:
631 631 return cookietransport()
632 632
633 633 def get_bug_comments(self, id):
634 634 """Return a string with all comment text for a bug."""
635 635 c = self.bzproxy.Bug.comments({'ids': [id],
636 636 'include_fields': ['text'],
637 637 'token': self.bztoken})
638 638 return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
639 639
640 640 def filter_real_bug_ids(self, bugs):
641 641 probe = self.bzproxy.Bug.get({'ids': sorted(bugs.keys()),
642 642 'include_fields': [],
643 643 'permissive': True,
644 644 'token': self.bztoken,
645 645 })
646 646 for badbug in probe['faults']:
647 647 id = badbug['id']
648 648 self.ui.status(_('bug %d does not exist\n') % id)
649 649 del bugs[id]
650 650
651 651 def filter_cset_known_bug_ids(self, node, bugs):
652 652 for id in sorted(bugs.keys()):
653 653 if self.get_bug_comments(id).find(short(node)) != -1:
654 654 self.ui.status(_('bug %d already knows about changeset %s\n') %
655 655 (id, short(node)))
656 656 del bugs[id]
657 657
658 658 def updatebug(self, bugid, newstate, text, committer):
659 659 args = {}
660 660 if 'hours' in newstate:
661 661 args['work_time'] = newstate['hours']
662 662
663 663 if self.bzvermajor >= 4:
664 664 args['ids'] = [bugid]
665 665 args['comment'] = {'body' : text}
666 666 if 'fix' in newstate:
667 667 args['status'] = self.fixstatus
668 668 args['resolution'] = self.fixresolution
669 669 args['token'] = self.bztoken
670 670 self.bzproxy.Bug.update(args)
671 671 else:
672 672 if 'fix' in newstate:
673 673 self.ui.warn(_("Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
674 674 "to mark bugs fixed\n"))
675 675 args['id'] = bugid
676 676 args['comment'] = text
677 677 self.bzproxy.Bug.add_comment(args)
678 678
679 679 class bzxmlrpcemail(bzxmlrpc):
680 680 """Read data from Bugzilla via XMLRPC, send updates via email.
681 681
682 682 Advantages of sending updates via email:
683 683 1. Comments can be added as any user, not just logged in user.
684 684 2. Bug statuses or other fields not accessible via XMLRPC can
685 685 potentially be updated.
686 686
687 687 There is no XMLRPC function to change bug status before Bugzilla
688 688 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
689 689 But bugs can be marked fixed via email from 3.4 onwards.
690 690 """
691 691
692 692 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
693 693 # in-email fields are specified as '@<fieldname> = <value>'. In
694 694 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
695 695 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
696 696 # compatibility, but rather than rely on this use the new format for
697 697 # 4.0 onwards.
698 698
699 699 def __init__(self, ui):
700 700 bzxmlrpc.__init__(self, ui)
701 701
702 702 self.bzemail = self.ui.config('bugzilla', 'bzemail')
703 703 if not self.bzemail:
704 704 raise util.Abort(_("configuration 'bzemail' missing"))
705 705 mail.validateconfig(self.ui)
706 706
707 707 def makecommandline(self, fieldname, value):
708 708 if self.bzvermajor >= 4:
709 709 return "@%s %s" % (fieldname, str(value))
710 710 else:
711 711 if fieldname == "id":
712 712 fieldname = "bug_id"
713 713 return "@%s = %s" % (fieldname, str(value))
714 714
715 715 def send_bug_modify_email(self, bugid, commands, comment, committer):
716 716 '''send modification message to Bugzilla bug via email.
717 717
718 718 The message format is documented in the Bugzilla email_in.pl
719 719 specification. commands is a list of command lines, comment is the
720 720 comment text.
721 721
722 722 To stop users from crafting commit comments with
723 723 Bugzilla commands, specify the bug ID via the message body, rather
724 724 than the subject line, and leave a blank line after it.
725 725 '''
726 726 user = self.map_committer(committer)
727 727 matches = self.bzproxy.User.get({'match': [user],
728 728 'token': self.bztoken})
729 729 if not matches['users']:
730 730 user = self.ui.config('bugzilla', 'user', 'bugs')
731 731 matches = self.bzproxy.User.get({'match': [user],
732 732 'token': self.bztoken})
733 733 if not matches['users']:
734 734 raise util.Abort(_("default bugzilla user %s email not found") %
735 735 user)
736 736 user = matches['users'][0]['email']
737 737 commands.append(self.makecommandline("id", bugid))
738 738
739 739 text = "\n".join(commands) + "\n\n" + comment
740 740
741 741 _charsets = mail._charsets(self.ui)
742 742 user = mail.addressencode(self.ui, user, _charsets)
743 743 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
744 744 msg = mail.mimeencode(self.ui, text, _charsets)
745 745 msg['From'] = user
746 746 msg['To'] = bzemail
747 747 msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
748 748 sendmail = mail.connect(self.ui)
749 749 sendmail(user, bzemail, msg.as_string())
750 750
751 751 def updatebug(self, bugid, newstate, text, committer):
752 752 cmds = []
753 753 if 'hours' in newstate:
754 754 cmds.append(self.makecommandline("work_time", newstate['hours']))
755 755 if 'fix' in newstate:
756 756 cmds.append(self.makecommandline("bug_status", self.fixstatus))
757 757 cmds.append(self.makecommandline("resolution", self.fixresolution))
758 758 self.send_bug_modify_email(bugid, cmds, text, committer)
759 759
760 760 class bugzilla(object):
761 761 # supported versions of bugzilla. different versions have
762 762 # different schemas.
763 763 _versions = {
764 764 '2.16': bzmysql,
765 765 '2.18': bzmysql_2_18,
766 766 '3.0': bzmysql_3_0,
767 767 'xmlrpc': bzxmlrpc,
768 768 'xmlrpc+email': bzxmlrpcemail
769 769 }
770 770
771 771 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
772 772 r'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
773 773 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
774 774
775 775 _default_fix_re = (r'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
776 776 r'(?:nos?\.?|num(?:ber)?s?)?\s*'
777 777 r'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
778 778 r'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?')
779 779
780 780 _bz = None
781 781
782 782 def __init__(self, ui, repo):
783 783 self.ui = ui
784 784 self.repo = repo
785 785
786 786 def bz(self):
787 787 '''return object that knows how to talk to bugzilla version in
788 788 use.'''
789 789
790 790 if bugzilla._bz is None:
791 791 bzversion = self.ui.config('bugzilla', 'version')
792 792 try:
793 793 bzclass = bugzilla._versions[bzversion]
794 794 except KeyError:
795 795 raise util.Abort(_('bugzilla version %s not supported') %
796 796 bzversion)
797 797 bugzilla._bz = bzclass(self.ui)
798 798 return bugzilla._bz
799 799
800 800 def __getattr__(self, key):
801 801 return getattr(self.bz(), key)
802 802
803 803 _bug_re = None
804 804 _fix_re = None
805 805 _split_re = None
806 806
807 807 def find_bugs(self, ctx):
808 808 '''return bugs dictionary created from commit comment.
809 809
810 810 Extract bug info from changeset comments. Filter out any that are
811 811 not known to Bugzilla, and any that already have a reference to
812 812 the given changeset in their comments.
813 813 '''
814 814 if bugzilla._bug_re is None:
815 815 bugzilla._bug_re = re.compile(
816 816 self.ui.config('bugzilla', 'regexp',
817 817 bugzilla._default_bug_re), re.IGNORECASE)
818 818 bugzilla._fix_re = re.compile(
819 819 self.ui.config('bugzilla', 'fixregexp',
820 820 bugzilla._default_fix_re), re.IGNORECASE)
821 821 bugzilla._split_re = re.compile(r'\D+')
822 822 start = 0
823 823 hours = 0.0
824 824 bugs = {}
825 825 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
826 826 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
827 827 while True:
828 828 bugattribs = {}
829 829 if not bugmatch and not fixmatch:
830 830 break
831 831 if not bugmatch:
832 832 m = fixmatch
833 833 elif not fixmatch:
834 834 m = bugmatch
835 835 else:
836 836 if bugmatch.start() < fixmatch.start():
837 837 m = bugmatch
838 838 else:
839 839 m = fixmatch
840 840 start = m.end()
841 841 if m is bugmatch:
842 842 bugmatch = bugzilla._bug_re.search(ctx.description(), start)
843 843 if 'fix' in bugattribs:
844 844 del bugattribs['fix']
845 845 else:
846 846 fixmatch = bugzilla._fix_re.search(ctx.description(), start)
847 847 bugattribs['fix'] = None
848 848
849 849 try:
850 850 ids = m.group('ids')
851 851 except IndexError:
852 852 ids = m.group(1)
853 853 try:
854 854 hours = float(m.group('hours'))
855 855 bugattribs['hours'] = hours
856 856 except IndexError:
857 857 pass
858 858 except TypeError:
859 859 pass
860 860 except ValueError:
861 861 self.ui.status(_("%s: invalid hours\n") % m.group('hours'))
862 862
863 863 for id in bugzilla._split_re.split(ids):
864 864 if not id:
865 865 continue
866 866 bugs[int(id)] = bugattribs
867 867 if bugs:
868 868 self.filter_real_bug_ids(bugs)
869 869 if bugs:
870 870 self.filter_cset_known_bug_ids(ctx.node(), bugs)
871 871 return bugs
872 872
873 873 def update(self, bugid, newstate, ctx):
874 874 '''update bugzilla bug with reference to changeset.'''
875 875
876 876 def webroot(root):
877 877 '''strip leading prefix of repo root and turn into
878 878 url-safe path.'''
879 879 count = int(self.ui.config('bugzilla', 'strip', 0))
880 880 root = util.pconvert(root)
881 881 while count > 0:
882 882 c = root.find('/')
883 883 if c == -1:
884 884 break
885 885 root = root[c + 1:]
886 886 count -= 1
887 887 return root
888 888
889 889 mapfile = self.ui.config('bugzilla', 'style')
890 890 tmpl = self.ui.config('bugzilla', 'template')
891 891 if not mapfile and not tmpl:
892 892 tmpl = _('changeset {node|short} in repo {root} refers '
893 893 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
894 894 if tmpl:
895 895 tmpl = templater.parsestring(tmpl, quoted=False)
896 896 t = cmdutil.changeset_templater(self.ui, self.repo,
897 897 False, None, tmpl, mapfile, False)
898 898 self.ui.pushbuffer()
899 899 t.show(ctx, changes=ctx.changeset(),
900 900 bug=str(bugid),
901 901 hgweb=self.ui.config('web', 'baseurl'),
902 902 root=self.repo.root,
903 903 webroot=webroot(self.repo.root))
904 904 data = self.ui.popbuffer()
905 905 self.updatebug(bugid, newstate, data, util.email(ctx.user()))
906 906
907 907 def hook(ui, repo, hooktype, node=None, **kwargs):
908 908 '''add comment to bugzilla for each changeset that refers to a
909 909 bugzilla bug id. only add a comment once per bug, so same change
910 910 seen multiple times does not fill bug with duplicate data.'''
911 911 if node is None:
912 912 raise util.Abort(_('hook type %s does not pass a changeset id') %
913 913 hooktype)
914 914 try:
915 915 bz = bugzilla(ui, repo)
916 916 ctx = repo[node]
917 917 bugs = bz.find_bugs(ctx)
918 918 if bugs:
919 919 for bug in bugs:
920 920 bz.update(bug, bugs[bug], ctx)
921 921 bz.notify(bugs, util.email(ctx.user()))
922 922 except Exception, e:
923 923 raise util.Abort(_('Bugzilla error: %s') % e)
@@ -1,426 +1,429
1 1 # Mercurial bookmark support code
2 2 #
3 3 # Copyright 2008 David Soria Parra <dsp@php.net>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 from mercurial.i18n import _
9 9 from mercurial.node import hex, bin
10 10 from mercurial import encoding, error, util, obsolete
11 11 import errno
12 12
13 13 class bmstore(dict):
14 14 """Storage for bookmarks.
15 15
16 16 This object should do all bookmark reads and writes, so that it's
17 17 fairly simple to replace the storage underlying bookmarks without
18 18 having to clone the logic surrounding bookmarks.
19 19
20 20 This particular bmstore implementation stores bookmarks as
21 21 {hash}\s{name}\n (the same format as localtags) in
22 22 .hg/bookmarks. The mapping is stored as {name: nodeid}.
23 23
24 24 This class does NOT handle the "current" bookmark state at this
25 25 time.
26 26 """
27 27
28 28 def __init__(self, repo):
29 29 dict.__init__(self)
30 30 self._repo = repo
31 31 try:
32 32 for line in repo.vfs('bookmarks'):
33 33 line = line.strip()
34 34 if not line:
35 35 continue
36 36 if ' ' not in line:
37 37 repo.ui.warn(_('malformed line in .hg/bookmarks: %r\n')
38 38 % line)
39 39 continue
40 40 sha, refspec = line.split(' ', 1)
41 41 refspec = encoding.tolocal(refspec)
42 42 try:
43 43 self[refspec] = repo.changelog.lookup(sha)
44 44 except LookupError:
45 45 pass
46 46 except IOError, inst:
47 47 if inst.errno != errno.ENOENT:
48 48 raise
49 49
50 50 def write(self):
51 51 '''Write bookmarks
52 52
53 53 Write the given bookmark => hash dictionary to the .hg/bookmarks file
54 54 in a format equal to those of localtags.
55 55
56 56 We also store a backup of the previous state in undo.bookmarks that
57 57 can be copied back on rollback.
58 58 '''
59 59 repo = self._repo
60 60 if repo._bookmarkcurrent not in self:
61 61 unsetcurrent(repo)
62 62
63 63 wlock = repo.wlock()
64 64 try:
65 65
66 66 file = repo.vfs('bookmarks', 'w', atomictemp=True)
67 67 for name, node in self.iteritems():
68 68 file.write("%s %s\n" % (hex(node), encoding.fromlocal(name)))
69 69 file.close()
70 70
71 71 # touch 00changelog.i so hgweb reloads bookmarks (no lock needed)
72 72 try:
73 73 repo.svfs.utime('00changelog.i', None)
74 74 except OSError:
75 75 pass
76 76
77 77 finally:
78 78 wlock.release()
79 79
80 80 def readcurrent(repo):
81 81 '''Get the current bookmark
82 82
83 83 If we use gittish branches we have a current bookmark that
84 84 we are on. This function returns the name of the bookmark. It
85 85 is stored in .hg/bookmarks.current
86 86 '''
87 87 mark = None
88 88 try:
89 89 file = repo.opener('bookmarks.current')
90 90 except IOError, inst:
91 91 if inst.errno != errno.ENOENT:
92 92 raise
93 93 return None
94 94 try:
95 95 # No readline() in osutil.posixfile, reading everything is cheap
96 96 mark = encoding.tolocal((file.readlines() or [''])[0])
97 97 if mark == '' or mark not in repo._bookmarks:
98 98 mark = None
99 99 finally:
100 100 file.close()
101 101 return mark
102 102
103 103 def setcurrent(repo, mark):
104 104 '''Set the name of the bookmark that we are currently on
105 105
106 106 Set the name of the bookmark that we are on (hg update <bookmark>).
107 107 The name is recorded in .hg/bookmarks.current
108 108 '''
109 109 if mark not in repo._bookmarks:
110 110 raise AssertionError('bookmark %s does not exist!' % mark)
111 111
112 112 current = repo._bookmarkcurrent
113 113 if current == mark:
114 114 return
115 115
116 116 wlock = repo.wlock()
117 117 try:
118 118 file = repo.opener('bookmarks.current', 'w', atomictemp=True)
119 119 file.write(encoding.fromlocal(mark))
120 120 file.close()
121 121 finally:
122 122 wlock.release()
123 123 repo._bookmarkcurrent = mark
124 124
125 125 def unsetcurrent(repo):
126 126 wlock = repo.wlock()
127 127 try:
128 128 try:
129 129 repo.vfs.unlink('bookmarks.current')
130 130 repo._bookmarkcurrent = None
131 131 except OSError, inst:
132 132 if inst.errno != errno.ENOENT:
133 133 raise
134 134 finally:
135 135 wlock.release()
136 136
137 137 def iscurrent(repo, mark=None, parents=None):
138 138 '''Tell whether the current bookmark is also active
139 139
140 140 I.e., the bookmark listed in .hg/bookmarks.current also points to a
141 141 parent of the working directory.
142 142 '''
143 143 if not mark:
144 144 mark = repo._bookmarkcurrent
145 145 if not parents:
146 146 parents = [p.node() for p in repo[None].parents()]
147 147 marks = repo._bookmarks
148 148 return (mark in marks and marks[mark] in parents)
149 149
150 150 def updatecurrentbookmark(repo, oldnode, curbranch):
151 151 try:
152 152 return update(repo, oldnode, repo.branchtip(curbranch))
153 153 except error.RepoLookupError:
154 154 if curbranch == "default": # no default branch!
155 155 return update(repo, oldnode, repo.lookup("tip"))
156 156 else:
157 157 raise util.Abort(_("branch %s not found") % curbranch)
158 158
159 159 def deletedivergent(repo, deletefrom, bm):
160 160 '''Delete divergent versions of bm on nodes in deletefrom.
161 161
162 162 Return True if at least one bookmark was deleted, False otherwise.'''
163 163 deleted = False
164 164 marks = repo._bookmarks
165 165 divergent = [b for b in marks if b.split('@', 1)[0] == bm.split('@', 1)[0]]
166 166 for mark in divergent:
167 if mark == '@' or '@' not in mark:
168 # can't be divergent by definition
169 continue
167 170 if mark and marks[mark] in deletefrom:
168 171 if mark != bm:
169 172 del marks[mark]
170 173 deleted = True
171 174 return deleted
172 175
173 176 def calculateupdate(ui, repo, checkout):
174 177 '''Return a tuple (targetrev, movemarkfrom) indicating the rev to
175 178 check out and where to move the active bookmark from, if needed.'''
176 179 movemarkfrom = None
177 180 if checkout is None:
178 181 curmark = repo._bookmarkcurrent
179 182 if iscurrent(repo):
180 183 movemarkfrom = repo['.'].node()
181 184 elif curmark:
182 185 ui.status(_("updating to active bookmark %s\n") % curmark)
183 186 checkout = curmark
184 187 return (checkout, movemarkfrom)
185 188
186 189 def update(repo, parents, node):
187 190 deletefrom = parents
188 191 marks = repo._bookmarks
189 192 update = False
190 193 cur = repo._bookmarkcurrent
191 194 if not cur:
192 195 return False
193 196
194 197 if marks[cur] in parents:
195 198 new = repo[node]
196 199 divs = [repo[b] for b in marks
197 200 if b.split('@', 1)[0] == cur.split('@', 1)[0]]
198 201 anc = repo.changelog.ancestors([new.rev()])
199 202 deletefrom = [b.node() for b in divs if b.rev() in anc or b == new]
200 203 if validdest(repo, repo[marks[cur]], new):
201 204 marks[cur] = new.node()
202 205 update = True
203 206
204 207 if deletedivergent(repo, deletefrom, cur):
205 208 update = True
206 209
207 210 if update:
208 211 marks.write()
209 212 return update
210 213
211 214 def listbookmarks(repo):
212 215 # We may try to list bookmarks on a repo type that does not
213 216 # support it (e.g., statichttprepository).
214 217 marks = getattr(repo, '_bookmarks', {})
215 218
216 219 d = {}
217 220 hasnode = repo.changelog.hasnode
218 221 for k, v in marks.iteritems():
219 222 # don't expose local divergent bookmarks
220 223 if hasnode(v) and ('@' not in k or k.endswith('@')):
221 224 d[k] = hex(v)
222 225 return d
223 226
224 227 def pushbookmark(repo, key, old, new):
225 228 w = repo.wlock()
226 229 try:
227 230 marks = repo._bookmarks
228 231 if hex(marks.get(key, '')) != old:
229 232 return False
230 233 if new == '':
231 234 del marks[key]
232 235 else:
233 236 if new not in repo:
234 237 return False
235 238 marks[key] = repo[new].node()
236 239 marks.write()
237 240 return True
238 241 finally:
239 242 w.release()
240 243
241 244 def compare(repo, srcmarks, dstmarks,
242 245 srchex=None, dsthex=None, targets=None):
243 246 '''Compare bookmarks between srcmarks and dstmarks
244 247
245 248 This returns tuple "(addsrc, adddst, advsrc, advdst, diverge,
246 249 differ, invalid)", each are list of bookmarks below:
247 250
248 251 :addsrc: added on src side (removed on dst side, perhaps)
249 252 :adddst: added on dst side (removed on src side, perhaps)
250 253 :advsrc: advanced on src side
251 254 :advdst: advanced on dst side
252 255 :diverge: diverge
253 256 :differ: changed, but changeset referred on src is unknown on dst
254 257 :invalid: unknown on both side
255 258
256 259 Each elements of lists in result tuple is tuple "(bookmark name,
257 260 changeset ID on source side, changeset ID on destination
258 261 side)". Each changeset IDs are 40 hexadecimal digit string or
259 262 None.
260 263
261 264 Changeset IDs of tuples in "addsrc", "adddst", "differ" or
262 265 "invalid" list may be unknown for repo.
263 266
264 267 This function expects that "srcmarks" and "dstmarks" return
265 268 changeset ID in 40 hexadecimal digit string for specified
266 269 bookmark. If not so (e.g. bmstore "repo._bookmarks" returning
267 270 binary value), "srchex" or "dsthex" should be specified to convert
268 271 into such form.
269 272
270 273 If "targets" is specified, only bookmarks listed in it are
271 274 examined.
272 275 '''
273 276 if not srchex:
274 277 srchex = lambda x: x
275 278 if not dsthex:
276 279 dsthex = lambda x: x
277 280
278 281 if targets:
279 282 bset = set(targets)
280 283 else:
281 284 srcmarkset = set(srcmarks)
282 285 dstmarkset = set(dstmarks)
283 286 bset = srcmarkset ^ dstmarkset
284 287 for b in srcmarkset & dstmarkset:
285 288 if srchex(srcmarks[b]) != dsthex(dstmarks[b]):
286 289 bset.add(b)
287 290
288 291 results = ([], [], [], [], [], [], [])
289 292 addsrc = results[0].append
290 293 adddst = results[1].append
291 294 advsrc = results[2].append
292 295 advdst = results[3].append
293 296 diverge = results[4].append
294 297 differ = results[5].append
295 298 invalid = results[6].append
296 299
297 300 for b in sorted(bset):
298 301 if b not in srcmarks:
299 302 if b in dstmarks:
300 303 adddst((b, None, dsthex(dstmarks[b])))
301 304 else:
302 305 invalid((b, None, None))
303 306 elif b not in dstmarks:
304 307 addsrc((b, srchex(srcmarks[b]), None))
305 308 else:
306 309 scid = srchex(srcmarks[b])
307 310 dcid = dsthex(dstmarks[b])
308 311 if scid in repo and dcid in repo:
309 312 sctx = repo[scid]
310 313 dctx = repo[dcid]
311 314 if sctx.rev() < dctx.rev():
312 315 if validdest(repo, sctx, dctx):
313 316 advdst((b, scid, dcid))
314 317 else:
315 318 diverge((b, scid, dcid))
316 319 else:
317 320 if validdest(repo, dctx, sctx):
318 321 advsrc((b, scid, dcid))
319 322 else:
320 323 diverge((b, scid, dcid))
321 324 else:
322 325 # it is too expensive to examine in detail, in this case
323 326 differ((b, scid, dcid))
324 327
325 328 return results
326 329
327 330 def _diverge(ui, b, path, localmarks):
328 331 if b == '@':
329 332 b = ''
330 333 # find a unique @ suffix
331 334 for x in range(1, 100):
332 335 n = '%s@%d' % (b, x)
333 336 if n not in localmarks:
334 337 break
335 338 # try to use an @pathalias suffix
336 339 # if an @pathalias already exists, we overwrite (update) it
337 340 for p, u in ui.configitems("paths"):
338 341 if path == u:
339 342 n = '%s@%s' % (b, p)
340 343 return n
341 344
342 345 def updatefromremote(ui, repo, remotemarks, path):
343 346 ui.debug("checking for updated bookmarks\n")
344 347 localmarks = repo._bookmarks
345 348 (addsrc, adddst, advsrc, advdst, diverge, differ, invalid
346 349 ) = compare(repo, remotemarks, localmarks, dsthex=hex)
347 350
348 351 changed = []
349 352 for b, scid, dcid in addsrc:
350 353 if scid in repo: # add remote bookmarks for changes we already have
351 354 changed.append((b, bin(scid), ui.status,
352 355 _("adding remote bookmark %s\n") % (b)))
353 356 for b, scid, dcid in advsrc:
354 357 changed.append((b, bin(scid), ui.status,
355 358 _("updating bookmark %s\n") % (b)))
356 359 for b, scid, dcid in diverge:
357 360 db = _diverge(ui, b, path, localmarks)
358 361 changed.append((db, bin(scid), ui.warn,
359 362 _("divergent bookmark %s stored as %s\n") % (b, db)))
360 363 if changed:
361 364 for b, node, writer, msg in sorted(changed):
362 365 localmarks[b] = node
363 366 writer(msg)
364 367 localmarks.write()
365 368
366 369 def pushtoremote(ui, repo, remote, targets):
367 370 (addsrc, adddst, advsrc, advdst, diverge, differ, invalid
368 371 ) = compare(repo, repo._bookmarks, remote.listkeys('bookmarks'),
369 372 srchex=hex, targets=targets)
370 373 if invalid:
371 374 b, scid, dcid = invalid[0]
372 375 ui.warn(_('bookmark %s does not exist on the local '
373 376 'or remote repository!\n') % b)
374 377 return 2
375 378
376 379 def push(b, old, new):
377 380 r = remote.pushkey('bookmarks', b, old, new)
378 381 if not r:
379 382 ui.warn(_('updating bookmark %s failed!\n') % b)
380 383 return 1
381 384 return 0
382 385 failed = 0
383 386 for b, scid, dcid in sorted(addsrc + advsrc + advdst + diverge + differ):
384 387 ui.status(_("exporting bookmark %s\n") % b)
385 388 if dcid is None:
386 389 dcid = ''
387 390 failed += push(b, dcid, scid)
388 391 for b, scid, dcid in adddst:
389 392 # treat as "deleted locally"
390 393 ui.status(_("deleting remote bookmark %s\n") % b)
391 394 failed += push(b, dcid, '')
392 395
393 396 if failed:
394 397 return 1
395 398
396 399 def diff(ui, dst, src):
397 400 ui.status(_("searching for changed bookmarks\n"))
398 401
399 402 smarks = src.listkeys('bookmarks')
400 403 dmarks = dst.listkeys('bookmarks')
401 404
402 405 diff = sorted(set(smarks) - set(dmarks))
403 406 for k in diff:
404 407 mark = ui.debugflag and smarks[k] or smarks[k][:12]
405 408 ui.write(" %-25s %s\n" % (k, mark))
406 409
407 410 if len(diff) <= 0:
408 411 ui.status(_("no changed bookmarks found\n"))
409 412 return 1
410 413 return 0
411 414
412 415 def validdest(repo, old, new):
413 416 """Is the new bookmark destination a valid update from the old one"""
414 417 repo = repo.unfiltered()
415 418 if old == new:
416 419 # Old == new -> nothing to update.
417 420 return False
418 421 elif not old:
419 422 # old is nullrev, anything is valid.
420 423 # (new != nullrev has been excluded by the previous check)
421 424 return True
422 425 elif repo.obsstore:
423 426 return new.node() in obsolete.foreground(repo, [old.node()])
424 427 else:
425 428 # still an independent clause as it is lazyer (and therefore faster)
426 429 return old.descendant(new)
General Comments 0
You need to be logged in to leave comments. Login now