##// END OF EJS Templates
py3: define and use json.loads polyfill...
Gregory Szorc -
r43697:579672b3 stable
parent child Browse files
Show More
@@ -1,1215 +1,1215 b''
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 Four basic modes of access to Bugzilla are provided:
19 19
20 20 1. Access via the Bugzilla REST-API. Requires bugzilla 5.0 or later.
21 21
22 22 2. Access via the Bugzilla XMLRPC interface. Requires Bugzilla 3.4 or later.
23 23
24 24 3. Check data via the Bugzilla XMLRPC interface and submit bug change
25 25 via email to Bugzilla email interface. Requires Bugzilla 3.4 or later.
26 26
27 27 4. Writing directly to the Bugzilla database. Only Bugzilla installations
28 28 using MySQL are supported. Requires Python MySQLdb.
29 29
30 30 Writing directly to the database is susceptible to schema changes, and
31 31 relies on a Bugzilla contrib script to send out bug change
32 32 notification emails. This script runs as the user running Mercurial,
33 33 must be run on the host with the Bugzilla install, and requires
34 34 permission to read Bugzilla configuration details and the necessary
35 35 MySQL user and password to have full access rights to the Bugzilla
36 36 database. For these reasons this access mode is now considered
37 37 deprecated, and will not be updated for new Bugzilla versions going
38 38 forward. Only adding comments is supported in this access mode.
39 39
40 40 Access via XMLRPC needs a Bugzilla username and password to be specified
41 41 in the configuration. Comments are added under that username. Since the
42 42 configuration must be readable by all Mercurial users, it is recommended
43 43 that the rights of that user are restricted in Bugzilla to the minimum
44 44 necessary to add comments. Marking bugs fixed requires Bugzilla 4.0 and later.
45 45
46 46 Access via XMLRPC/email uses XMLRPC to query Bugzilla, but sends
47 47 email to the Bugzilla email interface to submit comments to bugs.
48 48 The From: address in the email is set to the email address of the Mercurial
49 49 user, so the comment appears to come from the Mercurial user. In the event
50 50 that the Mercurial user email is not recognized by Bugzilla as a Bugzilla
51 51 user, the email associated with the Bugzilla username used to log into
52 52 Bugzilla is used instead as the source of the comment. Marking bugs fixed
53 53 works on all supported Bugzilla versions.
54 54
55 55 Access via the REST-API needs either a Bugzilla username and password
56 56 or an apikey specified in the configuration. Comments are made under
57 57 the given username or the user associated with the apikey in Bugzilla.
58 58
59 59 Configuration items common to all access modes:
60 60
61 61 bugzilla.version
62 62 The access type to use. Values recognized are:
63 63
64 64 :``restapi``: Bugzilla REST-API, Bugzilla 5.0 and later.
65 65 :``xmlrpc``: Bugzilla XMLRPC interface.
66 66 :``xmlrpc+email``: Bugzilla XMLRPC and email interfaces.
67 67 :``3.0``: MySQL access, Bugzilla 3.0 and later.
68 68 :``2.18``: MySQL access, Bugzilla 2.18 and up to but not
69 69 including 3.0.
70 70 :``2.16``: MySQL access, Bugzilla 2.16 and up to but not
71 71 including 2.18.
72 72
73 73 bugzilla.regexp
74 74 Regular expression to match bug IDs for update in changeset commit message.
75 75 It must contain one "()" named group ``<ids>`` containing the bug
76 76 IDs separated by non-digit characters. It may also contain
77 77 a named group ``<hours>`` with a floating-point number giving the
78 78 hours worked on the bug. If no named groups are present, the first
79 79 "()" group is assumed to contain the bug IDs, and work time is not
80 80 updated. The default expression matches ``Bug 1234``, ``Bug no. 1234``,
81 81 ``Bug number 1234``, ``Bugs 1234,5678``, ``Bug 1234 and 5678`` and
82 82 variations thereof, followed by an hours number prefixed by ``h`` or
83 83 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
84 84
85 85 bugzilla.fixregexp
86 86 Regular expression to match bug IDs for marking fixed in changeset
87 87 commit message. This must contain a "()" named group ``<ids>` containing
88 88 the bug IDs separated by non-digit characters. It may also contain
89 89 a named group ``<hours>`` with a floating-point number giving the
90 90 hours worked on the bug. If no named groups are present, the first
91 91 "()" group is assumed to contain the bug IDs, and work time is not
92 92 updated. The default expression matches ``Fixes 1234``, ``Fixes bug 1234``,
93 93 ``Fixes bugs 1234,5678``, ``Fixes 1234 and 5678`` and
94 94 variations thereof, followed by an hours number prefixed by ``h`` or
95 95 ``hours``, e.g. ``hours 1.5``. Matching is case insensitive.
96 96
97 97 bugzilla.fixstatus
98 98 The status to set a bug to when marking fixed. Default ``RESOLVED``.
99 99
100 100 bugzilla.fixresolution
101 101 The resolution to set a bug to when marking fixed. Default ``FIXED``.
102 102
103 103 bugzilla.style
104 104 The style file to use when formatting comments.
105 105
106 106 bugzilla.template
107 107 Template to use when formatting comments. Overrides style if
108 108 specified. In addition to the usual Mercurial keywords, the
109 109 extension specifies:
110 110
111 111 :``{bug}``: The Bugzilla bug ID.
112 112 :``{root}``: The full pathname of the Mercurial repository.
113 113 :``{webroot}``: Stripped pathname of the Mercurial repository.
114 114 :``{hgweb}``: Base URL for browsing Mercurial repositories.
115 115
116 116 Default ``changeset {node|short} in repo {root} refers to bug
117 117 {bug}.\\ndetails:\\n\\t{desc|tabindent}``
118 118
119 119 bugzilla.strip
120 120 The number of path separator characters to strip from the front of
121 121 the Mercurial repository path (``{root}`` in templates) to produce
122 122 ``{webroot}``. For example, a repository with ``{root}``
123 123 ``/var/local/my-project`` with a strip of 2 gives a value for
124 124 ``{webroot}`` of ``my-project``. Default 0.
125 125
126 126 web.baseurl
127 127 Base URL for browsing Mercurial repositories. Referenced from
128 128 templates as ``{hgweb}``.
129 129
130 130 Configuration items common to XMLRPC+email and MySQL access modes:
131 131
132 132 bugzilla.usermap
133 133 Path of file containing Mercurial committer email to Bugzilla user email
134 134 mappings. If specified, the file should contain one mapping per
135 135 line::
136 136
137 137 committer = Bugzilla user
138 138
139 139 See also the ``[usermap]`` section.
140 140
141 141 The ``[usermap]`` section is used to specify mappings of Mercurial
142 142 committer email to Bugzilla user email. See also ``bugzilla.usermap``.
143 143 Contains entries of the form ``committer = Bugzilla user``.
144 144
145 145 XMLRPC and REST-API access mode configuration:
146 146
147 147 bugzilla.bzurl
148 148 The base URL for the Bugzilla installation.
149 149 Default ``http://localhost/bugzilla``.
150 150
151 151 bugzilla.user
152 152 The username to use to log into Bugzilla via XMLRPC. Default
153 153 ``bugs``.
154 154
155 155 bugzilla.password
156 156 The password for Bugzilla login.
157 157
158 158 REST-API access mode uses the options listed above as well as:
159 159
160 160 bugzilla.apikey
161 161 An apikey generated on the Bugzilla instance for api access.
162 162 Using an apikey removes the need to store the user and password
163 163 options.
164 164
165 165 XMLRPC+email access mode uses the XMLRPC access mode configuration items,
166 166 and also:
167 167
168 168 bugzilla.bzemail
169 169 The Bugzilla email address.
170 170
171 171 In addition, the Mercurial email settings must be configured. See the
172 172 documentation in hgrc(5), sections ``[email]`` and ``[smtp]``.
173 173
174 174 MySQL access mode configuration:
175 175
176 176 bugzilla.host
177 177 Hostname of the MySQL server holding the Bugzilla database.
178 178 Default ``localhost``.
179 179
180 180 bugzilla.db
181 181 Name of the Bugzilla database in MySQL. Default ``bugs``.
182 182
183 183 bugzilla.user
184 184 Username to use to access MySQL server. Default ``bugs``.
185 185
186 186 bugzilla.password
187 187 Password to use to access MySQL server.
188 188
189 189 bugzilla.timeout
190 190 Database connection timeout (seconds). Default 5.
191 191
192 192 bugzilla.bzuser
193 193 Fallback Bugzilla user name to record comments with, if changeset
194 194 committer cannot be found as a Bugzilla user.
195 195
196 196 bugzilla.bzdir
197 197 Bugzilla install directory. Used by default notify. Default
198 198 ``/var/www/html/bugzilla``.
199 199
200 200 bugzilla.notify
201 201 The command to run to get Bugzilla to send bug change notification
202 202 emails. Substitutes from a map with 3 keys, ``bzdir``, ``id`` (bug
203 203 id) and ``user`` (committer bugzilla email). Default depends on
204 204 version; from 2.18 it is "cd %(bzdir)s && perl -T
205 205 contrib/sendbugmail.pl %(id)s %(user)s".
206 206
207 207 Activating the extension::
208 208
209 209 [extensions]
210 210 bugzilla =
211 211
212 212 [hooks]
213 213 # run bugzilla hook on every change pulled or pushed in here
214 214 incoming.bugzilla = python:hgext.bugzilla.hook
215 215
216 216 Example configurations:
217 217
218 218 XMLRPC example configuration. This uses the Bugzilla at
219 219 ``http://my-project.org/bugzilla``, logging in as user
220 220 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
221 221 collection of Mercurial repositories in ``/var/local/hg/repos/``,
222 222 with a web interface at ``http://my-project.org/hg``. ::
223 223
224 224 [bugzilla]
225 225 bzurl=http://my-project.org/bugzilla
226 226 user=bugmail@my-project.org
227 227 password=plugh
228 228 version=xmlrpc
229 229 template=Changeset {node|short} in {root|basename}.
230 230 {hgweb}/{webroot}/rev/{node|short}\\n
231 231 {desc}\\n
232 232 strip=5
233 233
234 234 [web]
235 235 baseurl=http://my-project.org/hg
236 236
237 237 XMLRPC+email example configuration. This uses the Bugzilla at
238 238 ``http://my-project.org/bugzilla``, logging in as user
239 239 ``bugmail@my-project.org`` with password ``plugh``. It is used with a
240 240 collection of Mercurial repositories in ``/var/local/hg/repos/``,
241 241 with a web interface at ``http://my-project.org/hg``. Bug comments
242 242 are sent to the Bugzilla email address
243 243 ``bugzilla@my-project.org``. ::
244 244
245 245 [bugzilla]
246 246 bzurl=http://my-project.org/bugzilla
247 247 user=bugmail@my-project.org
248 248 password=plugh
249 249 version=xmlrpc+email
250 250 bzemail=bugzilla@my-project.org
251 251 template=Changeset {node|short} in {root|basename}.
252 252 {hgweb}/{webroot}/rev/{node|short}\\n
253 253 {desc}\\n
254 254 strip=5
255 255
256 256 [web]
257 257 baseurl=http://my-project.org/hg
258 258
259 259 [usermap]
260 260 user@emaildomain.com=user.name@bugzilladomain.com
261 261
262 262 MySQL example configuration. This has a local Bugzilla 3.2 installation
263 263 in ``/opt/bugzilla-3.2``. The MySQL database is on ``localhost``,
264 264 the Bugzilla database name is ``bugs`` and MySQL is
265 265 accessed with MySQL username ``bugs`` password ``XYZZY``. It is used
266 266 with a collection of Mercurial repositories in ``/var/local/hg/repos/``,
267 267 with a web interface at ``http://my-project.org/hg``. ::
268 268
269 269 [bugzilla]
270 270 host=localhost
271 271 password=XYZZY
272 272 version=3.0
273 273 bzuser=unknown@domain.com
274 274 bzdir=/opt/bugzilla-3.2
275 275 template=Changeset {node|short} in {root|basename}.
276 276 {hgweb}/{webroot}/rev/{node|short}\\n
277 277 {desc}\\n
278 278 strip=5
279 279
280 280 [web]
281 281 baseurl=http://my-project.org/hg
282 282
283 283 [usermap]
284 284 user@emaildomain.com=user.name@bugzilladomain.com
285 285
286 286 All the above add a comment to the Bugzilla bug record of the form::
287 287
288 288 Changeset 3b16791d6642 in repository-name.
289 289 http://my-project.org/hg/repository-name/rev/3b16791d6642
290 290
291 291 Changeset commit comment. Bug 1234.
292 292 '''
293 293
294 294 from __future__ import absolute_import
295 295
296 296 import json
297 297 import re
298 298 import time
299 299
300 300 from mercurial.i18n import _
301 301 from mercurial.node import short
302 302 from mercurial import (
303 303 error,
304 304 logcmdutil,
305 305 mail,
306 306 pycompat,
307 307 registrar,
308 308 url,
309 309 util,
310 310 )
311 311 from mercurial.utils import (
312 312 procutil,
313 313 stringutil,
314 314 )
315 315
316 316 xmlrpclib = util.xmlrpclib
317 317
318 318 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
319 319 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
320 320 # be specifying the version(s) of Mercurial they are tested with, or
321 321 # leave the attribute unspecified.
322 322 testedwith = b'ships-with-hg-core'
323 323
324 324 configtable = {}
325 325 configitem = registrar.configitem(configtable)
326 326
327 327 configitem(
328 328 b'bugzilla', b'apikey', default=b'',
329 329 )
330 330 configitem(
331 331 b'bugzilla', b'bzdir', default=b'/var/www/html/bugzilla',
332 332 )
333 333 configitem(
334 334 b'bugzilla', b'bzemail', default=None,
335 335 )
336 336 configitem(
337 337 b'bugzilla', b'bzurl', default=b'http://localhost/bugzilla/',
338 338 )
339 339 configitem(
340 340 b'bugzilla', b'bzuser', default=None,
341 341 )
342 342 configitem(
343 343 b'bugzilla', b'db', default=b'bugs',
344 344 )
345 345 configitem(
346 346 b'bugzilla',
347 347 b'fixregexp',
348 348 default=(
349 349 br'fix(?:es)?\s*(?:bugs?\s*)?,?\s*'
350 350 br'(?:nos?\.?|num(?:ber)?s?)?\s*'
351 351 br'(?P<ids>(?:#?\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
352 352 br'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?'
353 353 ),
354 354 )
355 355 configitem(
356 356 b'bugzilla', b'fixresolution', default=b'FIXED',
357 357 )
358 358 configitem(
359 359 b'bugzilla', b'fixstatus', default=b'RESOLVED',
360 360 )
361 361 configitem(
362 362 b'bugzilla', b'host', default=b'localhost',
363 363 )
364 364 configitem(
365 365 b'bugzilla', b'notify', default=configitem.dynamicdefault,
366 366 )
367 367 configitem(
368 368 b'bugzilla', b'password', default=None,
369 369 )
370 370 configitem(
371 371 b'bugzilla',
372 372 b'regexp',
373 373 default=(
374 374 br'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
375 375 br'(?P<ids>(?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)'
376 376 br'\.?\s*(?:h(?:ours?)?\s*(?P<hours>\d*(?:\.\d+)?))?'
377 377 ),
378 378 )
379 379 configitem(
380 380 b'bugzilla', b'strip', default=0,
381 381 )
382 382 configitem(
383 383 b'bugzilla', b'style', default=None,
384 384 )
385 385 configitem(
386 386 b'bugzilla', b'template', default=None,
387 387 )
388 388 configitem(
389 389 b'bugzilla', b'timeout', default=5,
390 390 )
391 391 configitem(
392 392 b'bugzilla', b'user', default=b'bugs',
393 393 )
394 394 configitem(
395 395 b'bugzilla', b'usermap', default=None,
396 396 )
397 397 configitem(
398 398 b'bugzilla', b'version', default=None,
399 399 )
400 400
401 401
402 402 class bzaccess(object):
403 403 '''Base class for access to Bugzilla.'''
404 404
405 405 def __init__(self, ui):
406 406 self.ui = ui
407 407 usermap = self.ui.config(b'bugzilla', b'usermap')
408 408 if usermap:
409 409 self.ui.readconfig(usermap, sections=[b'usermap'])
410 410
411 411 def map_committer(self, user):
412 412 '''map name of committer to Bugzilla user name.'''
413 413 for committer, bzuser in self.ui.configitems(b'usermap'):
414 414 if committer.lower() == user.lower():
415 415 return bzuser
416 416 return user
417 417
418 418 # Methods to be implemented by access classes.
419 419 #
420 420 # 'bugs' is a dict keyed on bug id, where values are a dict holding
421 421 # updates to bug state. Recognized dict keys are:
422 422 #
423 423 # 'hours': Value, float containing work hours to be updated.
424 424 # 'fix': If key present, bug is to be marked fixed. Value ignored.
425 425
426 426 def filter_real_bug_ids(self, bugs):
427 427 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
428 428
429 429 def filter_cset_known_bug_ids(self, node, bugs):
430 430 '''remove bug IDs where node occurs in comment text from bugs.'''
431 431
432 432 def updatebug(self, bugid, newstate, text, committer):
433 433 '''update the specified bug. Add comment text and set new states.
434 434
435 435 If possible add the comment as being from the committer of
436 436 the changeset. Otherwise use the default Bugzilla user.
437 437 '''
438 438
439 439 def notify(self, bugs, committer):
440 440 '''Force sending of Bugzilla notification emails.
441 441
442 442 Only required if the access method does not trigger notification
443 443 emails automatically.
444 444 '''
445 445
446 446
447 447 # Bugzilla via direct access to MySQL database.
448 448 class bzmysql(bzaccess):
449 449 '''Support for direct MySQL access to Bugzilla.
450 450
451 451 The earliest Bugzilla version this is tested with is version 2.16.
452 452
453 453 If your Bugzilla is version 3.4 or above, you are strongly
454 454 recommended to use the XMLRPC access method instead.
455 455 '''
456 456
457 457 @staticmethod
458 458 def sql_buglist(ids):
459 459 '''return SQL-friendly list of bug ids'''
460 460 return b'(' + b','.join(map(str, ids)) + b')'
461 461
462 462 _MySQLdb = None
463 463
464 464 def __init__(self, ui):
465 465 try:
466 466 import MySQLdb as mysql
467 467
468 468 bzmysql._MySQLdb = mysql
469 469 except ImportError as err:
470 470 raise error.Abort(
471 471 _(b'python mysql support not available: %s') % err
472 472 )
473 473
474 474 bzaccess.__init__(self, ui)
475 475
476 476 host = self.ui.config(b'bugzilla', b'host')
477 477 user = self.ui.config(b'bugzilla', b'user')
478 478 passwd = self.ui.config(b'bugzilla', b'password')
479 479 db = self.ui.config(b'bugzilla', b'db')
480 480 timeout = int(self.ui.config(b'bugzilla', b'timeout'))
481 481 self.ui.note(
482 482 _(b'connecting to %s:%s as %s, password %s\n')
483 483 % (host, db, user, b'*' * len(passwd))
484 484 )
485 485 self.conn = bzmysql._MySQLdb.connect(
486 486 host=host, user=user, passwd=passwd, db=db, connect_timeout=timeout
487 487 )
488 488 self.cursor = self.conn.cursor()
489 489 self.longdesc_id = self.get_longdesc_id()
490 490 self.user_ids = {}
491 491 self.default_notify = b"cd %(bzdir)s && ./processmail %(id)s %(user)s"
492 492
493 493 def run(self, *args, **kwargs):
494 494 '''run a query.'''
495 495 self.ui.note(_(b'query: %s %s\n') % (args, kwargs))
496 496 try:
497 497 self.cursor.execute(*args, **kwargs)
498 498 except bzmysql._MySQLdb.MySQLError:
499 499 self.ui.note(_(b'failed query: %s %s\n') % (args, kwargs))
500 500 raise
501 501
502 502 def get_longdesc_id(self):
503 503 '''get identity of longdesc field'''
504 504 self.run(b'select fieldid from fielddefs where name = "longdesc"')
505 505 ids = self.cursor.fetchall()
506 506 if len(ids) != 1:
507 507 raise error.Abort(_(b'unknown database schema'))
508 508 return ids[0][0]
509 509
510 510 def filter_real_bug_ids(self, bugs):
511 511 '''filter not-existing bugs from set.'''
512 512 self.run(
513 513 b'select bug_id from bugs where bug_id in %s'
514 514 % bzmysql.sql_buglist(bugs.keys())
515 515 )
516 516 existing = [id for (id,) in self.cursor.fetchall()]
517 517 for id in bugs.keys():
518 518 if id not in existing:
519 519 self.ui.status(_(b'bug %d does not exist\n') % id)
520 520 del bugs[id]
521 521
522 522 def filter_cset_known_bug_ids(self, node, bugs):
523 523 '''filter bug ids that already refer to this changeset from set.'''
524 524 self.run(
525 525 '''select bug_id from longdescs where
526 526 bug_id in %s and thetext like "%%%s%%"'''
527 527 % (bzmysql.sql_buglist(bugs.keys()), short(node))
528 528 )
529 529 for (id,) in self.cursor.fetchall():
530 530 self.ui.status(
531 531 _(b'bug %d already knows about changeset %s\n')
532 532 % (id, short(node))
533 533 )
534 534 del bugs[id]
535 535
536 536 def notify(self, bugs, committer):
537 537 '''tell bugzilla to send mail.'''
538 538 self.ui.status(_(b'telling bugzilla to send mail:\n'))
539 539 (user, userid) = self.get_bugzilla_user(committer)
540 540 for id in bugs.keys():
541 541 self.ui.status(_(b' bug %s\n') % id)
542 542 cmdfmt = self.ui.config(b'bugzilla', b'notify', self.default_notify)
543 543 bzdir = self.ui.config(b'bugzilla', b'bzdir')
544 544 try:
545 545 # Backwards-compatible with old notify string, which
546 546 # took one string. This will throw with a new format
547 547 # string.
548 548 cmd = cmdfmt % id
549 549 except TypeError:
550 550 cmd = cmdfmt % {b'bzdir': bzdir, b'id': id, b'user': user}
551 551 self.ui.note(_(b'running notify command %s\n') % cmd)
552 552 fp = procutil.popen(b'(%s) 2>&1' % cmd, b'rb')
553 553 out = util.fromnativeeol(fp.read())
554 554 ret = fp.close()
555 555 if ret:
556 556 self.ui.warn(out)
557 557 raise error.Abort(
558 558 _(b'bugzilla notify command %s') % procutil.explainexit(ret)
559 559 )
560 560 self.ui.status(_(b'done\n'))
561 561
562 562 def get_user_id(self, user):
563 563 '''look up numeric bugzilla user id.'''
564 564 try:
565 565 return self.user_ids[user]
566 566 except KeyError:
567 567 try:
568 568 userid = int(user)
569 569 except ValueError:
570 570 self.ui.note(_(b'looking up user %s\n') % user)
571 571 self.run(
572 572 '''select userid from profiles
573 573 where login_name like %s''',
574 574 user,
575 575 )
576 576 all = self.cursor.fetchall()
577 577 if len(all) != 1:
578 578 raise KeyError(user)
579 579 userid = int(all[0][0])
580 580 self.user_ids[user] = userid
581 581 return userid
582 582
583 583 def get_bugzilla_user(self, committer):
584 584 '''See if committer is a registered bugzilla user. Return
585 585 bugzilla username and userid if so. If not, return default
586 586 bugzilla username and userid.'''
587 587 user = self.map_committer(committer)
588 588 try:
589 589 userid = self.get_user_id(user)
590 590 except KeyError:
591 591 try:
592 592 defaultuser = self.ui.config(b'bugzilla', b'bzuser')
593 593 if not defaultuser:
594 594 raise error.Abort(
595 595 _(b'cannot find bugzilla user id for %s') % user
596 596 )
597 597 userid = self.get_user_id(defaultuser)
598 598 user = defaultuser
599 599 except KeyError:
600 600 raise error.Abort(
601 601 _(b'cannot find bugzilla user id for %s or %s')
602 602 % (user, defaultuser)
603 603 )
604 604 return (user, userid)
605 605
606 606 def updatebug(self, bugid, newstate, text, committer):
607 607 '''update bug state with comment text.
608 608
609 609 Try adding comment as committer of changeset, otherwise as
610 610 default bugzilla user.'''
611 611 if len(newstate) > 0:
612 612 self.ui.warn(_(b"Bugzilla/MySQL cannot update bug state\n"))
613 613
614 614 (user, userid) = self.get_bugzilla_user(committer)
615 615 now = time.strftime(r'%Y-%m-%d %H:%M:%S')
616 616 self.run(
617 617 '''insert into longdescs
618 618 (bug_id, who, bug_when, thetext)
619 619 values (%s, %s, %s, %s)''',
620 620 (bugid, userid, now, text),
621 621 )
622 622 self.run(
623 623 '''insert into bugs_activity (bug_id, who, bug_when, fieldid)
624 624 values (%s, %s, %s, %s)''',
625 625 (bugid, userid, now, self.longdesc_id),
626 626 )
627 627 self.conn.commit()
628 628
629 629
630 630 class bzmysql_2_18(bzmysql):
631 631 '''support for bugzilla 2.18 series.'''
632 632
633 633 def __init__(self, ui):
634 634 bzmysql.__init__(self, ui)
635 635 self.default_notify = (
636 636 b"cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
637 637 )
638 638
639 639
640 640 class bzmysql_3_0(bzmysql_2_18):
641 641 '''support for bugzilla 3.0 series.'''
642 642
643 643 def __init__(self, ui):
644 644 bzmysql_2_18.__init__(self, ui)
645 645
646 646 def get_longdesc_id(self):
647 647 '''get identity of longdesc field'''
648 648 self.run(b'select id from fielddefs where name = "longdesc"')
649 649 ids = self.cursor.fetchall()
650 650 if len(ids) != 1:
651 651 raise error.Abort(_(b'unknown database schema'))
652 652 return ids[0][0]
653 653
654 654
655 655 # Bugzilla via XMLRPC interface.
656 656
657 657
658 658 class cookietransportrequest(object):
659 659 """A Transport request method that retains cookies over its lifetime.
660 660
661 661 The regular xmlrpclib transports ignore cookies. Which causes
662 662 a bit of a problem when you need a cookie-based login, as with
663 663 the Bugzilla XMLRPC interface prior to 4.4.3.
664 664
665 665 So this is a helper for defining a Transport which looks for
666 666 cookies being set in responses and saves them to add to all future
667 667 requests.
668 668 """
669 669
670 670 # Inspiration drawn from
671 671 # http://blog.godson.in/2010/09/how-to-make-python-xmlrpclib-client.html
672 672 # http://www.itkovian.net/base/transport-class-for-pythons-xml-rpc-lib/
673 673
674 674 cookies = []
675 675
676 676 def send_cookies(self, connection):
677 677 if self.cookies:
678 678 for cookie in self.cookies:
679 679 connection.putheader(b"Cookie", cookie)
680 680
681 681 def request(self, host, handler, request_body, verbose=0):
682 682 self.verbose = verbose
683 683 self.accept_gzip_encoding = False
684 684
685 685 # issue XML-RPC request
686 686 h = self.make_connection(host)
687 687 if verbose:
688 688 h.set_debuglevel(1)
689 689
690 690 self.send_request(h, handler, request_body)
691 691 self.send_host(h, host)
692 692 self.send_cookies(h)
693 693 self.send_user_agent(h)
694 694 self.send_content(h, request_body)
695 695
696 696 # Deal with differences between Python 2.6 and 2.7.
697 697 # In the former h is a HTTP(S). In the latter it's a
698 698 # HTTP(S)Connection. Luckily, the 2.6 implementation of
699 699 # HTTP(S) has an underlying HTTP(S)Connection, so extract
700 700 # that and use it.
701 701 try:
702 702 response = h.getresponse()
703 703 except AttributeError:
704 704 response = h._conn.getresponse()
705 705
706 706 # Add any cookie definitions to our list.
707 707 for header in response.msg.getallmatchingheaders(b"Set-Cookie"):
708 708 val = header.split(b": ", 1)[1]
709 709 cookie = val.split(b";", 1)[0]
710 710 self.cookies.append(cookie)
711 711
712 712 if response.status != 200:
713 713 raise xmlrpclib.ProtocolError(
714 714 host + handler,
715 715 response.status,
716 716 response.reason,
717 717 response.msg.headers,
718 718 )
719 719
720 720 payload = response.read()
721 721 parser, unmarshaller = self.getparser()
722 722 parser.feed(payload)
723 723 parser.close()
724 724
725 725 return unmarshaller.close()
726 726
727 727
728 728 # The explicit calls to the underlying xmlrpclib __init__() methods are
729 729 # necessary. The xmlrpclib.Transport classes are old-style classes, and
730 730 # it turns out their __init__() doesn't get called when doing multiple
731 731 # inheritance with a new-style class.
732 732 class cookietransport(cookietransportrequest, xmlrpclib.Transport):
733 733 def __init__(self, use_datetime=0):
734 734 if util.safehasattr(xmlrpclib.Transport, "__init__"):
735 735 xmlrpclib.Transport.__init__(self, use_datetime)
736 736
737 737
738 738 class cookiesafetransport(cookietransportrequest, xmlrpclib.SafeTransport):
739 739 def __init__(self, use_datetime=0):
740 740 if util.safehasattr(xmlrpclib.Transport, "__init__"):
741 741 xmlrpclib.SafeTransport.__init__(self, use_datetime)
742 742
743 743
744 744 class bzxmlrpc(bzaccess):
745 745 """Support for access to Bugzilla via the Bugzilla XMLRPC API.
746 746
747 747 Requires a minimum Bugzilla version 3.4.
748 748 """
749 749
750 750 def __init__(self, ui):
751 751 bzaccess.__init__(self, ui)
752 752
753 753 bzweb = self.ui.config(b'bugzilla', b'bzurl')
754 754 bzweb = bzweb.rstrip(b"/") + b"/xmlrpc.cgi"
755 755
756 756 user = self.ui.config(b'bugzilla', b'user')
757 757 passwd = self.ui.config(b'bugzilla', b'password')
758 758
759 759 self.fixstatus = self.ui.config(b'bugzilla', b'fixstatus')
760 760 self.fixresolution = self.ui.config(b'bugzilla', b'fixresolution')
761 761
762 762 self.bzproxy = xmlrpclib.ServerProxy(bzweb, self.transport(bzweb))
763 763 ver = self.bzproxy.Bugzilla.version()[b'version'].split(b'.')
764 764 self.bzvermajor = int(ver[0])
765 765 self.bzverminor = int(ver[1])
766 766 login = self.bzproxy.User.login(
767 767 {b'login': user, b'password': passwd, b'restrict_login': True}
768 768 )
769 769 self.bztoken = login.get(b'token', b'')
770 770
771 771 def transport(self, uri):
772 772 if util.urlreq.urlparse(uri, b"http")[0] == b"https":
773 773 return cookiesafetransport()
774 774 else:
775 775 return cookietransport()
776 776
777 777 def get_bug_comments(self, id):
778 778 """Return a string with all comment text for a bug."""
779 779 c = self.bzproxy.Bug.comments(
780 780 {b'ids': [id], b'include_fields': [b'text'], b'token': self.bztoken}
781 781 )
782 782 return b''.join(
783 783 [t[b'text'] for t in c[b'bugs'][b'%d' % id][b'comments']]
784 784 )
785 785
786 786 def filter_real_bug_ids(self, bugs):
787 787 probe = self.bzproxy.Bug.get(
788 788 {
789 789 b'ids': sorted(bugs.keys()),
790 790 b'include_fields': [],
791 791 b'permissive': True,
792 792 b'token': self.bztoken,
793 793 }
794 794 )
795 795 for badbug in probe[b'faults']:
796 796 id = badbug[b'id']
797 797 self.ui.status(_(b'bug %d does not exist\n') % id)
798 798 del bugs[id]
799 799
800 800 def filter_cset_known_bug_ids(self, node, bugs):
801 801 for id in sorted(bugs.keys()):
802 802 if self.get_bug_comments(id).find(short(node)) != -1:
803 803 self.ui.status(
804 804 _(b'bug %d already knows about changeset %s\n')
805 805 % (id, short(node))
806 806 )
807 807 del bugs[id]
808 808
809 809 def updatebug(self, bugid, newstate, text, committer):
810 810 args = {}
811 811 if b'hours' in newstate:
812 812 args[b'work_time'] = newstate[b'hours']
813 813
814 814 if self.bzvermajor >= 4:
815 815 args[b'ids'] = [bugid]
816 816 args[b'comment'] = {b'body': text}
817 817 if b'fix' in newstate:
818 818 args[b'status'] = self.fixstatus
819 819 args[b'resolution'] = self.fixresolution
820 820 args[b'token'] = self.bztoken
821 821 self.bzproxy.Bug.update(args)
822 822 else:
823 823 if b'fix' in newstate:
824 824 self.ui.warn(
825 825 _(
826 826 b"Bugzilla/XMLRPC needs Bugzilla 4.0 or later "
827 827 b"to mark bugs fixed\n"
828 828 )
829 829 )
830 830 args[b'id'] = bugid
831 831 args[b'comment'] = text
832 832 self.bzproxy.Bug.add_comment(args)
833 833
834 834
835 835 class bzxmlrpcemail(bzxmlrpc):
836 836 """Read data from Bugzilla via XMLRPC, send updates via email.
837 837
838 838 Advantages of sending updates via email:
839 839 1. Comments can be added as any user, not just logged in user.
840 840 2. Bug statuses or other fields not accessible via XMLRPC can
841 841 potentially be updated.
842 842
843 843 There is no XMLRPC function to change bug status before Bugzilla
844 844 4.0, so bugs cannot be marked fixed via XMLRPC before Bugzilla 4.0.
845 845 But bugs can be marked fixed via email from 3.4 onwards.
846 846 """
847 847
848 848 # The email interface changes subtly between 3.4 and 3.6. In 3.4,
849 849 # in-email fields are specified as '@<fieldname> = <value>'. In
850 850 # 3.6 this becomes '@<fieldname> <value>'. And fieldname @bug_id
851 851 # in 3.4 becomes @id in 3.6. 3.6 and 4.0 both maintain backwards
852 852 # compatibility, but rather than rely on this use the new format for
853 853 # 4.0 onwards.
854 854
855 855 def __init__(self, ui):
856 856 bzxmlrpc.__init__(self, ui)
857 857
858 858 self.bzemail = self.ui.config(b'bugzilla', b'bzemail')
859 859 if not self.bzemail:
860 860 raise error.Abort(_(b"configuration 'bzemail' missing"))
861 861 mail.validateconfig(self.ui)
862 862
863 863 def makecommandline(self, fieldname, value):
864 864 if self.bzvermajor >= 4:
865 865 return b"@%s %s" % (fieldname, pycompat.bytestr(value))
866 866 else:
867 867 if fieldname == b"id":
868 868 fieldname = b"bug_id"
869 869 return b"@%s = %s" % (fieldname, pycompat.bytestr(value))
870 870
871 871 def send_bug_modify_email(self, bugid, commands, comment, committer):
872 872 '''send modification message to Bugzilla bug via email.
873 873
874 874 The message format is documented in the Bugzilla email_in.pl
875 875 specification. commands is a list of command lines, comment is the
876 876 comment text.
877 877
878 878 To stop users from crafting commit comments with
879 879 Bugzilla commands, specify the bug ID via the message body, rather
880 880 than the subject line, and leave a blank line after it.
881 881 '''
882 882 user = self.map_committer(committer)
883 883 matches = self.bzproxy.User.get(
884 884 {b'match': [user], b'token': self.bztoken}
885 885 )
886 886 if not matches[b'users']:
887 887 user = self.ui.config(b'bugzilla', b'user')
888 888 matches = self.bzproxy.User.get(
889 889 {b'match': [user], b'token': self.bztoken}
890 890 )
891 891 if not matches[b'users']:
892 892 raise error.Abort(
893 893 _(b"default bugzilla user %s email not found") % user
894 894 )
895 895 user = matches[b'users'][0][b'email']
896 896 commands.append(self.makecommandline(b"id", bugid))
897 897
898 898 text = b"\n".join(commands) + b"\n\n" + comment
899 899
900 900 _charsets = mail._charsets(self.ui)
901 901 user = mail.addressencode(self.ui, user, _charsets)
902 902 bzemail = mail.addressencode(self.ui, self.bzemail, _charsets)
903 903 msg = mail.mimeencode(self.ui, text, _charsets)
904 904 msg[b'From'] = user
905 905 msg[b'To'] = bzemail
906 906 msg[b'Subject'] = mail.headencode(
907 907 self.ui, b"Bug modification", _charsets
908 908 )
909 909 sendmail = mail.connect(self.ui)
910 910 sendmail(user, bzemail, msg.as_string())
911 911
912 912 def updatebug(self, bugid, newstate, text, committer):
913 913 cmds = []
914 914 if b'hours' in newstate:
915 915 cmds.append(self.makecommandline(b"work_time", newstate[b'hours']))
916 916 if b'fix' in newstate:
917 917 cmds.append(self.makecommandline(b"bug_status", self.fixstatus))
918 918 cmds.append(self.makecommandline(b"resolution", self.fixresolution))
919 919 self.send_bug_modify_email(bugid, cmds, text, committer)
920 920
921 921
922 922 class NotFound(LookupError):
923 923 pass
924 924
925 925
926 926 class bzrestapi(bzaccess):
927 927 """Read and write bugzilla data using the REST API available since
928 928 Bugzilla 5.0.
929 929 """
930 930
931 931 def __init__(self, ui):
932 932 bzaccess.__init__(self, ui)
933 933 bz = self.ui.config(b'bugzilla', b'bzurl')
934 934 self.bzroot = b'/'.join([bz, b'rest'])
935 935 self.apikey = self.ui.config(b'bugzilla', b'apikey')
936 936 self.user = self.ui.config(b'bugzilla', b'user')
937 937 self.passwd = self.ui.config(b'bugzilla', b'password')
938 938 self.fixstatus = self.ui.config(b'bugzilla', b'fixstatus')
939 939 self.fixresolution = self.ui.config(b'bugzilla', b'fixresolution')
940 940
941 941 def apiurl(self, targets, include_fields=None):
942 942 url = b'/'.join([self.bzroot] + [pycompat.bytestr(t) for t in targets])
943 943 qv = {}
944 944 if self.apikey:
945 945 qv[b'api_key'] = self.apikey
946 946 elif self.user and self.passwd:
947 947 qv[b'login'] = self.user
948 948 qv[b'password'] = self.passwd
949 949 if include_fields:
950 950 qv[b'include_fields'] = include_fields
951 951 if qv:
952 952 url = b'%s?%s' % (url, util.urlreq.urlencode(qv))
953 953 return url
954 954
955 955 def _fetch(self, burl):
956 956 try:
957 957 resp = url.open(self.ui, burl)
958 return json.loads(resp.read())
958 return pycompat.json_loads(resp.read())
959 959 except util.urlerr.httperror as inst:
960 960 if inst.code == 401:
961 961 raise error.Abort(_(b'authorization failed'))
962 962 if inst.code == 404:
963 963 raise NotFound()
964 964 else:
965 965 raise
966 966
967 967 def _submit(self, burl, data, method=b'POST'):
968 968 data = json.dumps(data)
969 969 if method == b'PUT':
970 970
971 971 class putrequest(util.urlreq.request):
972 972 def get_method(self):
973 973 return b'PUT'
974 974
975 975 request_type = putrequest
976 976 else:
977 977 request_type = util.urlreq.request
978 978 req = request_type(burl, data, {b'Content-Type': b'application/json'})
979 979 try:
980 980 resp = url.opener(self.ui).open(req)
981 return json.loads(resp.read())
981 return pycompat.json_loads(resp.read())
982 982 except util.urlerr.httperror as inst:
983 983 if inst.code == 401:
984 984 raise error.Abort(_(b'authorization failed'))
985 985 if inst.code == 404:
986 986 raise NotFound()
987 987 else:
988 988 raise
989 989
990 990 def filter_real_bug_ids(self, bugs):
991 991 '''remove bug IDs that do not exist in Bugzilla from bugs.'''
992 992 badbugs = set()
993 993 for bugid in bugs:
994 994 burl = self.apiurl((b'bug', bugid), include_fields=b'status')
995 995 try:
996 996 self._fetch(burl)
997 997 except NotFound:
998 998 badbugs.add(bugid)
999 999 for bugid in badbugs:
1000 1000 del bugs[bugid]
1001 1001
1002 1002 def filter_cset_known_bug_ids(self, node, bugs):
1003 1003 '''remove bug IDs where node occurs in comment text from bugs.'''
1004 1004 sn = short(node)
1005 1005 for bugid in bugs.keys():
1006 1006 burl = self.apiurl(
1007 1007 (b'bug', bugid, b'comment'), include_fields=b'text'
1008 1008 )
1009 1009 result = self._fetch(burl)
1010 1010 comments = result[b'bugs'][pycompat.bytestr(bugid)][b'comments']
1011 1011 if any(sn in c[b'text'] for c in comments):
1012 1012 self.ui.status(
1013 1013 _(b'bug %d already knows about changeset %s\n')
1014 1014 % (bugid, sn)
1015 1015 )
1016 1016 del bugs[bugid]
1017 1017
1018 1018 def updatebug(self, bugid, newstate, text, committer):
1019 1019 '''update the specified bug. Add comment text and set new states.
1020 1020
1021 1021 If possible add the comment as being from the committer of
1022 1022 the changeset. Otherwise use the default Bugzilla user.
1023 1023 '''
1024 1024 bugmod = {}
1025 1025 if b'hours' in newstate:
1026 1026 bugmod[b'work_time'] = newstate[b'hours']
1027 1027 if b'fix' in newstate:
1028 1028 bugmod[b'status'] = self.fixstatus
1029 1029 bugmod[b'resolution'] = self.fixresolution
1030 1030 if bugmod:
1031 1031 # if we have to change the bugs state do it here
1032 1032 bugmod[b'comment'] = {
1033 1033 b'comment': text,
1034 1034 b'is_private': False,
1035 1035 b'is_markdown': False,
1036 1036 }
1037 1037 burl = self.apiurl((b'bug', bugid))
1038 1038 self._submit(burl, bugmod, method=b'PUT')
1039 1039 self.ui.debug(b'updated bug %s\n' % bugid)
1040 1040 else:
1041 1041 burl = self.apiurl((b'bug', bugid, b'comment'))
1042 1042 self._submit(
1043 1043 burl,
1044 1044 {
1045 1045 b'comment': text,
1046 1046 b'is_private': False,
1047 1047 b'is_markdown': False,
1048 1048 },
1049 1049 )
1050 1050 self.ui.debug(b'added comment to bug %s\n' % bugid)
1051 1051
1052 1052 def notify(self, bugs, committer):
1053 1053 '''Force sending of Bugzilla notification emails.
1054 1054
1055 1055 Only required if the access method does not trigger notification
1056 1056 emails automatically.
1057 1057 '''
1058 1058 pass
1059 1059
1060 1060
1061 1061 class bugzilla(object):
1062 1062 # supported versions of bugzilla. different versions have
1063 1063 # different schemas.
1064 1064 _versions = {
1065 1065 b'2.16': bzmysql,
1066 1066 b'2.18': bzmysql_2_18,
1067 1067 b'3.0': bzmysql_3_0,
1068 1068 b'xmlrpc': bzxmlrpc,
1069 1069 b'xmlrpc+email': bzxmlrpcemail,
1070 1070 b'restapi': bzrestapi,
1071 1071 }
1072 1072
1073 1073 def __init__(self, ui, repo):
1074 1074 self.ui = ui
1075 1075 self.repo = repo
1076 1076
1077 1077 bzversion = self.ui.config(b'bugzilla', b'version')
1078 1078 try:
1079 1079 bzclass = bugzilla._versions[bzversion]
1080 1080 except KeyError:
1081 1081 raise error.Abort(
1082 1082 _(b'bugzilla version %s not supported') % bzversion
1083 1083 )
1084 1084 self.bzdriver = bzclass(self.ui)
1085 1085
1086 1086 self.bug_re = re.compile(
1087 1087 self.ui.config(b'bugzilla', b'regexp'), re.IGNORECASE
1088 1088 )
1089 1089 self.fix_re = re.compile(
1090 1090 self.ui.config(b'bugzilla', b'fixregexp'), re.IGNORECASE
1091 1091 )
1092 1092 self.split_re = re.compile(br'\D+')
1093 1093
1094 1094 def find_bugs(self, ctx):
1095 1095 '''return bugs dictionary created from commit comment.
1096 1096
1097 1097 Extract bug info from changeset comments. Filter out any that are
1098 1098 not known to Bugzilla, and any that already have a reference to
1099 1099 the given changeset in their comments.
1100 1100 '''
1101 1101 start = 0
1102 1102 hours = 0.0
1103 1103 bugs = {}
1104 1104 bugmatch = self.bug_re.search(ctx.description(), start)
1105 1105 fixmatch = self.fix_re.search(ctx.description(), start)
1106 1106 while True:
1107 1107 bugattribs = {}
1108 1108 if not bugmatch and not fixmatch:
1109 1109 break
1110 1110 if not bugmatch:
1111 1111 m = fixmatch
1112 1112 elif not fixmatch:
1113 1113 m = bugmatch
1114 1114 else:
1115 1115 if bugmatch.start() < fixmatch.start():
1116 1116 m = bugmatch
1117 1117 else:
1118 1118 m = fixmatch
1119 1119 start = m.end()
1120 1120 if m is bugmatch:
1121 1121 bugmatch = self.bug_re.search(ctx.description(), start)
1122 1122 if b'fix' in bugattribs:
1123 1123 del bugattribs[b'fix']
1124 1124 else:
1125 1125 fixmatch = self.fix_re.search(ctx.description(), start)
1126 1126 bugattribs[b'fix'] = None
1127 1127
1128 1128 try:
1129 1129 ids = m.group(b'ids')
1130 1130 except IndexError:
1131 1131 ids = m.group(1)
1132 1132 try:
1133 1133 hours = float(m.group(b'hours'))
1134 1134 bugattribs[b'hours'] = hours
1135 1135 except IndexError:
1136 1136 pass
1137 1137 except TypeError:
1138 1138 pass
1139 1139 except ValueError:
1140 1140 self.ui.status(_(b"%s: invalid hours\n") % m.group(b'hours'))
1141 1141
1142 1142 for id in self.split_re.split(ids):
1143 1143 if not id:
1144 1144 continue
1145 1145 bugs[int(id)] = bugattribs
1146 1146 if bugs:
1147 1147 self.bzdriver.filter_real_bug_ids(bugs)
1148 1148 if bugs:
1149 1149 self.bzdriver.filter_cset_known_bug_ids(ctx.node(), bugs)
1150 1150 return bugs
1151 1151
1152 1152 def update(self, bugid, newstate, ctx):
1153 1153 '''update bugzilla bug with reference to changeset.'''
1154 1154
1155 1155 def webroot(root):
1156 1156 '''strip leading prefix of repo root and turn into
1157 1157 url-safe path.'''
1158 1158 count = int(self.ui.config(b'bugzilla', b'strip'))
1159 1159 root = util.pconvert(root)
1160 1160 while count > 0:
1161 1161 c = root.find(b'/')
1162 1162 if c == -1:
1163 1163 break
1164 1164 root = root[c + 1 :]
1165 1165 count -= 1
1166 1166 return root
1167 1167
1168 1168 mapfile = None
1169 1169 tmpl = self.ui.config(b'bugzilla', b'template')
1170 1170 if not tmpl:
1171 1171 mapfile = self.ui.config(b'bugzilla', b'style')
1172 1172 if not mapfile and not tmpl:
1173 1173 tmpl = _(
1174 1174 b'changeset {node|short} in repo {root} refers '
1175 1175 b'to bug {bug}.\ndetails:\n\t{desc|tabindent}'
1176 1176 )
1177 1177 spec = logcmdutil.templatespec(tmpl, mapfile)
1178 1178 t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
1179 1179 self.ui.pushbuffer()
1180 1180 t.show(
1181 1181 ctx,
1182 1182 changes=ctx.changeset(),
1183 1183 bug=pycompat.bytestr(bugid),
1184 1184 hgweb=self.ui.config(b'web', b'baseurl'),
1185 1185 root=self.repo.root,
1186 1186 webroot=webroot(self.repo.root),
1187 1187 )
1188 1188 data = self.ui.popbuffer()
1189 1189 self.bzdriver.updatebug(
1190 1190 bugid, newstate, data, stringutil.email(ctx.user())
1191 1191 )
1192 1192
1193 1193 def notify(self, bugs, committer):
1194 1194 '''ensure Bugzilla users are notified of bug change.'''
1195 1195 self.bzdriver.notify(bugs, committer)
1196 1196
1197 1197
1198 1198 def hook(ui, repo, hooktype, node=None, **kwargs):
1199 1199 '''add comment to bugzilla for each changeset that refers to a
1200 1200 bugzilla bug id. only add a comment once per bug, so same change
1201 1201 seen multiple times does not fill bug with duplicate data.'''
1202 1202 if node is None:
1203 1203 raise error.Abort(
1204 1204 _(b'hook type %s does not pass a changeset id') % hooktype
1205 1205 )
1206 1206 try:
1207 1207 bz = bugzilla(ui, repo)
1208 1208 ctx = repo[node]
1209 1209 bugs = bz.find_bugs(ctx)
1210 1210 if bugs:
1211 1211 for bug in bugs:
1212 1212 bz.update(bug, bugs[bug], ctx)
1213 1213 bz.notify(bugs, stringutil.email(ctx.user()))
1214 1214 except Exception as e:
1215 1215 raise error.Abort(_(b'Bugzilla error: %s') % e)
@@ -1,883 +1,882 b''
1 1 # fix - rewrite file content in changesets and working copy
2 2 #
3 3 # Copyright 2018 Google LLC.
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 """rewrite file content in changesets or working copy (EXPERIMENTAL)
8 8
9 9 Provides a command that runs configured tools on the contents of modified files,
10 10 writing back any fixes to the working copy or replacing changesets.
11 11
12 12 Here is an example configuration that causes :hg:`fix` to apply automatic
13 13 formatting fixes to modified lines in C++ code::
14 14
15 15 [fix]
16 16 clang-format:command=clang-format --assume-filename={rootpath}
17 17 clang-format:linerange=--lines={first}:{last}
18 18 clang-format:pattern=set:**.cpp or **.hpp
19 19
20 20 The :command suboption forms the first part of the shell command that will be
21 21 used to fix a file. The content of the file is passed on standard input, and the
22 22 fixed file content is expected on standard output. Any output on standard error
23 23 will be displayed as a warning. If the exit status is not zero, the file will
24 24 not be affected. A placeholder warning is displayed if there is a non-zero exit
25 25 status but no standard error output. Some values may be substituted into the
26 26 command::
27 27
28 28 {rootpath} The path of the file being fixed, relative to the repo root
29 29 {basename} The name of the file being fixed, without the directory path
30 30
31 31 If the :linerange suboption is set, the tool will only be run if there are
32 32 changed lines in a file. The value of this suboption is appended to the shell
33 33 command once for every range of changed lines in the file. Some values may be
34 34 substituted into the command::
35 35
36 36 {first} The 1-based line number of the first line in the modified range
37 37 {last} The 1-based line number of the last line in the modified range
38 38
39 39 Deleted sections of a file will be ignored by :linerange, because there is no
40 40 corresponding line range in the version being fixed.
41 41
42 42 By default, tools that set :linerange will only be executed if there is at least
43 43 one changed line range. This is meant to prevent accidents like running a code
44 44 formatter in such a way that it unexpectedly reformats the whole file. If such a
45 45 tool needs to operate on unchanged files, it should set the :skipclean suboption
46 46 to false.
47 47
48 48 The :pattern suboption determines which files will be passed through each
49 49 configured tool. See :hg:`help patterns` for possible values. However, all
50 50 patterns are relative to the repo root, even if that text says they are relative
51 51 to the current working directory. If there are file arguments to :hg:`fix`, the
52 52 intersection of these patterns is used.
53 53
54 54 There is also a configurable limit for the maximum size of file that will be
55 55 processed by :hg:`fix`::
56 56
57 57 [fix]
58 58 maxfilesize = 2MB
59 59
60 60 Normally, execution of configured tools will continue after a failure (indicated
61 61 by a non-zero exit status). It can also be configured to abort after the first
62 62 such failure, so that no files will be affected if any tool fails. This abort
63 63 will also cause :hg:`fix` to exit with a non-zero status::
64 64
65 65 [fix]
66 66 failure = abort
67 67
68 68 When multiple tools are configured to affect a file, they execute in an order
69 69 defined by the :priority suboption. The priority suboption has a default value
70 70 of zero for each tool. Tools are executed in order of descending priority. The
71 71 execution order of tools with equal priority is unspecified. For example, you
72 72 could use the 'sort' and 'head' utilities to keep only the 10 smallest numbers
73 73 in a text file by ensuring that 'sort' runs before 'head'::
74 74
75 75 [fix]
76 76 sort:command = sort -n
77 77 head:command = head -n 10
78 78 sort:pattern = numbers.txt
79 79 head:pattern = numbers.txt
80 80 sort:priority = 2
81 81 head:priority = 1
82 82
83 83 To account for changes made by each tool, the line numbers used for incremental
84 84 formatting are recomputed before executing the next tool. So, each tool may see
85 85 different values for the arguments added by the :linerange suboption.
86 86
87 87 Each fixer tool is allowed to return some metadata in addition to the fixed file
88 88 content. The metadata must be placed before the file content on stdout,
89 89 separated from the file content by a zero byte. The metadata is parsed as a JSON
90 90 value (so, it should be UTF-8 encoded and contain no zero bytes). A fixer tool
91 91 is expected to produce this metadata encoding if and only if the :metadata
92 92 suboption is true::
93 93
94 94 [fix]
95 95 tool:command = tool --prepend-json-metadata
96 96 tool:metadata = true
97 97
98 98 The metadata values are passed to hooks, which can be used to print summaries or
99 99 perform other post-fixing work. The supported hooks are::
100 100
101 101 "postfixfile"
102 102 Run once for each file in each revision where any fixer tools made changes
103 103 to the file content. Provides "$HG_REV" and "$HG_PATH" to identify the file,
104 104 and "$HG_METADATA" with a map of fixer names to metadata values from fixer
105 105 tools that affected the file. Fixer tools that didn't affect the file have a
106 106 valueof None. Only fixer tools that executed are present in the metadata.
107 107
108 108 "postfix"
109 109 Run once after all files and revisions have been handled. Provides
110 110 "$HG_REPLACEMENTS" with information about what revisions were created and
111 111 made obsolete. Provides a boolean "$HG_WDIRWRITTEN" to indicate whether any
112 112 files in the working copy were updated. Provides a list "$HG_METADATA"
113 113 mapping fixer tool names to lists of metadata values returned from
114 114 executions that modified a file. This aggregates the same metadata
115 115 previously passed to the "postfixfile" hook.
116 116
117 117 Fixer tools are run the in repository's root directory. This allows them to read
118 118 configuration files from the working copy, or even write to the working copy.
119 119 The working copy is not updated to match the revision being fixed. In fact,
120 120 several revisions may be fixed in parallel. Writes to the working copy are not
121 121 amended into the revision being fixed; fixer tools should always write fixed
122 122 file content back to stdout as documented above.
123 123 """
124 124
125 125 from __future__ import absolute_import
126 126
127 127 import collections
128 128 import itertools
129 import json
130 129 import os
131 130 import re
132 131 import subprocess
133 132
134 133 from mercurial.i18n import _
135 134 from mercurial.node import nullrev
136 135 from mercurial.node import wdirrev
137 136
138 137 from mercurial.utils import procutil
139 138
140 139 from mercurial import (
141 140 cmdutil,
142 141 context,
143 142 copies,
144 143 error,
145 144 match as matchmod,
146 145 mdiff,
147 146 merge,
148 147 obsolete,
149 148 pycompat,
150 149 registrar,
151 150 scmutil,
152 151 util,
153 152 worker,
154 153 )
155 154
156 155 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
157 156 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
158 157 # be specifying the version(s) of Mercurial they are tested with, or
159 158 # leave the attribute unspecified.
160 159 testedwith = b'ships-with-hg-core'
161 160
162 161 cmdtable = {}
163 162 command = registrar.command(cmdtable)
164 163
165 164 configtable = {}
166 165 configitem = registrar.configitem(configtable)
167 166
168 167 # Register the suboptions allowed for each configured fixer, and default values.
169 168 FIXER_ATTRS = {
170 169 b'command': None,
171 170 b'linerange': None,
172 171 b'pattern': None,
173 172 b'priority': 0,
174 173 b'metadata': False,
175 174 b'skipclean': True,
176 175 b'enabled': True,
177 176 }
178 177
179 178 for key, default in FIXER_ATTRS.items():
180 179 configitem(b'fix', b'.*:%s$' % key, default=default, generic=True)
181 180
182 181 # A good default size allows most source code files to be fixed, but avoids
183 182 # letting fixer tools choke on huge inputs, which could be surprising to the
184 183 # user.
185 184 configitem(b'fix', b'maxfilesize', default=b'2MB')
186 185
187 186 # Allow fix commands to exit non-zero if an executed fixer tool exits non-zero.
188 187 # This helps users do shell scripts that stop when a fixer tool signals a
189 188 # problem.
190 189 configitem(b'fix', b'failure', default=b'continue')
191 190
192 191
193 192 def checktoolfailureaction(ui, message, hint=None):
194 193 """Abort with 'message' if fix.failure=abort"""
195 194 action = ui.config(b'fix', b'failure')
196 195 if action not in (b'continue', b'abort'):
197 196 raise error.Abort(
198 197 _(b'unknown fix.failure action: %s') % (action,),
199 198 hint=_(b'use "continue" or "abort"'),
200 199 )
201 200 if action == b'abort':
202 201 raise error.Abort(message, hint=hint)
203 202
204 203
205 204 allopt = (b'', b'all', False, _(b'fix all non-public non-obsolete revisions'))
206 205 baseopt = (
207 206 b'',
208 207 b'base',
209 208 [],
210 209 _(
211 210 b'revisions to diff against (overrides automatic '
212 211 b'selection, and applies to every revision being '
213 212 b'fixed)'
214 213 ),
215 214 _(b'REV'),
216 215 )
217 216 revopt = (b'r', b'rev', [], _(b'revisions to fix'), _(b'REV'))
218 217 wdiropt = (b'w', b'working-dir', False, _(b'fix the working directory'))
219 218 wholeopt = (b'', b'whole', False, _(b'always fix every line of a file'))
220 219 usage = _(b'[OPTION]... [FILE]...')
221 220
222 221
223 222 @command(
224 223 b'fix',
225 224 [allopt, baseopt, revopt, wdiropt, wholeopt],
226 225 usage,
227 226 helpcategory=command.CATEGORY_FILE_CONTENTS,
228 227 )
229 228 def fix(ui, repo, *pats, **opts):
230 229 """rewrite file content in changesets or working directory
231 230
232 231 Runs any configured tools to fix the content of files. Only affects files
233 232 with changes, unless file arguments are provided. Only affects changed lines
234 233 of files, unless the --whole flag is used. Some tools may always affect the
235 234 whole file regardless of --whole.
236 235
237 236 If revisions are specified with --rev, those revisions will be checked, and
238 237 they may be replaced with new revisions that have fixed file content. It is
239 238 desirable to specify all descendants of each specified revision, so that the
240 239 fixes propagate to the descendants. If all descendants are fixed at the same
241 240 time, no merging, rebasing, or evolution will be required.
242 241
243 242 If --working-dir is used, files with uncommitted changes in the working copy
244 243 will be fixed. If the checked-out revision is also fixed, the working
245 244 directory will update to the replacement revision.
246 245
247 246 When determining what lines of each file to fix at each revision, the whole
248 247 set of revisions being fixed is considered, so that fixes to earlier
249 248 revisions are not forgotten in later ones. The --base flag can be used to
250 249 override this default behavior, though it is not usually desirable to do so.
251 250 """
252 251 opts = pycompat.byteskwargs(opts)
253 252 if opts[b'all']:
254 253 if opts[b'rev']:
255 254 raise error.Abort(_(b'cannot specify both "--rev" and "--all"'))
256 255 opts[b'rev'] = [b'not public() and not obsolete()']
257 256 opts[b'working_dir'] = True
258 257 with repo.wlock(), repo.lock(), repo.transaction(b'fix'):
259 258 revstofix = getrevstofix(ui, repo, opts)
260 259 basectxs = getbasectxs(repo, opts, revstofix)
261 260 workqueue, numitems = getworkqueue(
262 261 ui, repo, pats, opts, revstofix, basectxs
263 262 )
264 263 fixers = getfixers(ui)
265 264
266 265 # There are no data dependencies between the workers fixing each file
267 266 # revision, so we can use all available parallelism.
268 267 def getfixes(items):
269 268 for rev, path in items:
270 269 ctx = repo[rev]
271 270 olddata = ctx[path].data()
272 271 metadata, newdata = fixfile(
273 272 ui, repo, opts, fixers, ctx, path, basectxs[rev]
274 273 )
275 274 # Don't waste memory/time passing unchanged content back, but
276 275 # produce one result per item either way.
277 276 yield (
278 277 rev,
279 278 path,
280 279 metadata,
281 280 newdata if newdata != olddata else None,
282 281 )
283 282
284 283 results = worker.worker(
285 284 ui, 1.0, getfixes, tuple(), workqueue, threadsafe=False
286 285 )
287 286
288 287 # We have to hold on to the data for each successor revision in memory
289 288 # until all its parents are committed. We ensure this by committing and
290 289 # freeing memory for the revisions in some topological order. This
291 290 # leaves a little bit of memory efficiency on the table, but also makes
292 291 # the tests deterministic. It might also be considered a feature since
293 292 # it makes the results more easily reproducible.
294 293 filedata = collections.defaultdict(dict)
295 294 aggregatemetadata = collections.defaultdict(list)
296 295 replacements = {}
297 296 wdirwritten = False
298 297 commitorder = sorted(revstofix, reverse=True)
299 298 with ui.makeprogress(
300 299 topic=_(b'fixing'), unit=_(b'files'), total=sum(numitems.values())
301 300 ) as progress:
302 301 for rev, path, filerevmetadata, newdata in results:
303 302 progress.increment(item=path)
304 303 for fixername, fixermetadata in filerevmetadata.items():
305 304 aggregatemetadata[fixername].append(fixermetadata)
306 305 if newdata is not None:
307 306 filedata[rev][path] = newdata
308 307 hookargs = {
309 308 b'rev': rev,
310 309 b'path': path,
311 310 b'metadata': filerevmetadata,
312 311 }
313 312 repo.hook(
314 313 b'postfixfile',
315 314 throw=False,
316 315 **pycompat.strkwargs(hookargs)
317 316 )
318 317 numitems[rev] -= 1
319 318 # Apply the fixes for this and any other revisions that are
320 319 # ready and sitting at the front of the queue. Using a loop here
321 320 # prevents the queue from being blocked by the first revision to
322 321 # be ready out of order.
323 322 while commitorder and not numitems[commitorder[-1]]:
324 323 rev = commitorder.pop()
325 324 ctx = repo[rev]
326 325 if rev == wdirrev:
327 326 writeworkingdir(repo, ctx, filedata[rev], replacements)
328 327 wdirwritten = bool(filedata[rev])
329 328 else:
330 329 replacerev(ui, repo, ctx, filedata[rev], replacements)
331 330 del filedata[rev]
332 331
333 332 cleanup(repo, replacements, wdirwritten)
334 333 hookargs = {
335 334 b'replacements': replacements,
336 335 b'wdirwritten': wdirwritten,
337 336 b'metadata': aggregatemetadata,
338 337 }
339 338 repo.hook(b'postfix', throw=True, **pycompat.strkwargs(hookargs))
340 339
341 340
342 341 def cleanup(repo, replacements, wdirwritten):
343 342 """Calls scmutil.cleanupnodes() with the given replacements.
344 343
345 344 "replacements" is a dict from nodeid to nodeid, with one key and one value
346 345 for every revision that was affected by fixing. This is slightly different
347 346 from cleanupnodes().
348 347
349 348 "wdirwritten" is a bool which tells whether the working copy was affected by
350 349 fixing, since it has no entry in "replacements".
351 350
352 351 Useful as a hook point for extending "hg fix" with output summarizing the
353 352 effects of the command, though we choose not to output anything here.
354 353 """
355 354 replacements = {
356 355 prec: [succ] for prec, succ in pycompat.iteritems(replacements)
357 356 }
358 357 scmutil.cleanupnodes(repo, replacements, b'fix', fixphase=True)
359 358
360 359
361 360 def getworkqueue(ui, repo, pats, opts, revstofix, basectxs):
362 361 """"Constructs the list of files to be fixed at specific revisions
363 362
364 363 It is up to the caller how to consume the work items, and the only
365 364 dependence between them is that replacement revisions must be committed in
366 365 topological order. Each work item represents a file in the working copy or
367 366 in some revision that should be fixed and written back to the working copy
368 367 or into a replacement revision.
369 368
370 369 Work items for the same revision are grouped together, so that a worker
371 370 pool starting with the first N items in parallel is likely to finish the
372 371 first revision's work before other revisions. This can allow us to write
373 372 the result to disk and reduce memory footprint. At time of writing, the
374 373 partition strategy in worker.py seems favorable to this. We also sort the
375 374 items by ascending revision number to match the order in which we commit
376 375 the fixes later.
377 376 """
378 377 workqueue = []
379 378 numitems = collections.defaultdict(int)
380 379 maxfilesize = ui.configbytes(b'fix', b'maxfilesize')
381 380 for rev in sorted(revstofix):
382 381 fixctx = repo[rev]
383 382 match = scmutil.match(fixctx, pats, opts)
384 383 for path in sorted(
385 384 pathstofix(ui, repo, pats, opts, match, basectxs[rev], fixctx)
386 385 ):
387 386 fctx = fixctx[path]
388 387 if fctx.islink():
389 388 continue
390 389 if fctx.size() > maxfilesize:
391 390 ui.warn(
392 391 _(b'ignoring file larger than %s: %s\n')
393 392 % (util.bytecount(maxfilesize), path)
394 393 )
395 394 continue
396 395 workqueue.append((rev, path))
397 396 numitems[rev] += 1
398 397 return workqueue, numitems
399 398
400 399
401 400 def getrevstofix(ui, repo, opts):
402 401 """Returns the set of revision numbers that should be fixed"""
403 402 revs = set(scmutil.revrange(repo, opts[b'rev']))
404 403 for rev in revs:
405 404 checkfixablectx(ui, repo, repo[rev])
406 405 if revs:
407 406 cmdutil.checkunfinished(repo)
408 407 checknodescendants(repo, revs)
409 408 if opts.get(b'working_dir'):
410 409 revs.add(wdirrev)
411 410 if list(merge.mergestate.read(repo).unresolved()):
412 411 raise error.Abort(b'unresolved conflicts', hint=b"use 'hg resolve'")
413 412 if not revs:
414 413 raise error.Abort(
415 414 b'no changesets specified', hint=b'use --rev or --working-dir'
416 415 )
417 416 return revs
418 417
419 418
420 419 def checknodescendants(repo, revs):
421 420 if not obsolete.isenabled(repo, obsolete.allowunstableopt) and repo.revs(
422 421 b'(%ld::) - (%ld)', revs, revs
423 422 ):
424 423 raise error.Abort(
425 424 _(b'can only fix a changeset together with all its descendants')
426 425 )
427 426
428 427
429 428 def checkfixablectx(ui, repo, ctx):
430 429 """Aborts if the revision shouldn't be replaced with a fixed one."""
431 430 if not ctx.mutable():
432 431 raise error.Abort(
433 432 b'can\'t fix immutable changeset %s'
434 433 % (scmutil.formatchangeid(ctx),)
435 434 )
436 435 if ctx.obsolete():
437 436 # It would be better to actually check if the revision has a successor.
438 437 allowdivergence = ui.configbool(
439 438 b'experimental', b'evolution.allowdivergence'
440 439 )
441 440 if not allowdivergence:
442 441 raise error.Abort(
443 442 b'fixing obsolete revision could cause divergence'
444 443 )
445 444
446 445
447 446 def pathstofix(ui, repo, pats, opts, match, basectxs, fixctx):
448 447 """Returns the set of files that should be fixed in a context
449 448
450 449 The result depends on the base contexts; we include any file that has
451 450 changed relative to any of the base contexts. Base contexts should be
452 451 ancestors of the context being fixed.
453 452 """
454 453 files = set()
455 454 for basectx in basectxs:
456 455 stat = basectx.status(
457 456 fixctx, match=match, listclean=bool(pats), listunknown=bool(pats)
458 457 )
459 458 files.update(
460 459 set(
461 460 itertools.chain(
462 461 stat.added, stat.modified, stat.clean, stat.unknown
463 462 )
464 463 )
465 464 )
466 465 return files
467 466
468 467
469 468 def lineranges(opts, path, basectxs, fixctx, content2):
470 469 """Returns the set of line ranges that should be fixed in a file
471 470
472 471 Of the form [(10, 20), (30, 40)].
473 472
474 473 This depends on the given base contexts; we must consider lines that have
475 474 changed versus any of the base contexts, and whether the file has been
476 475 renamed versus any of them.
477 476
478 477 Another way to understand this is that we exclude line ranges that are
479 478 common to the file in all base contexts.
480 479 """
481 480 if opts.get(b'whole'):
482 481 # Return a range containing all lines. Rely on the diff implementation's
483 482 # idea of how many lines are in the file, instead of reimplementing it.
484 483 return difflineranges(b'', content2)
485 484
486 485 rangeslist = []
487 486 for basectx in basectxs:
488 487 basepath = copies.pathcopies(basectx, fixctx).get(path, path)
489 488 if basepath in basectx:
490 489 content1 = basectx[basepath].data()
491 490 else:
492 491 content1 = b''
493 492 rangeslist.extend(difflineranges(content1, content2))
494 493 return unionranges(rangeslist)
495 494
496 495
497 496 def unionranges(rangeslist):
498 497 """Return the union of some closed intervals
499 498
500 499 >>> unionranges([])
501 500 []
502 501 >>> unionranges([(1, 100)])
503 502 [(1, 100)]
504 503 >>> unionranges([(1, 100), (1, 100)])
505 504 [(1, 100)]
506 505 >>> unionranges([(1, 100), (2, 100)])
507 506 [(1, 100)]
508 507 >>> unionranges([(1, 99), (1, 100)])
509 508 [(1, 100)]
510 509 >>> unionranges([(1, 100), (40, 60)])
511 510 [(1, 100)]
512 511 >>> unionranges([(1, 49), (50, 100)])
513 512 [(1, 100)]
514 513 >>> unionranges([(1, 48), (50, 100)])
515 514 [(1, 48), (50, 100)]
516 515 >>> unionranges([(1, 2), (3, 4), (5, 6)])
517 516 [(1, 6)]
518 517 """
519 518 rangeslist = sorted(set(rangeslist))
520 519 unioned = []
521 520 if rangeslist:
522 521 unioned, rangeslist = [rangeslist[0]], rangeslist[1:]
523 522 for a, b in rangeslist:
524 523 c, d = unioned[-1]
525 524 if a > d + 1:
526 525 unioned.append((a, b))
527 526 else:
528 527 unioned[-1] = (c, max(b, d))
529 528 return unioned
530 529
531 530
532 531 def difflineranges(content1, content2):
533 532 """Return list of line number ranges in content2 that differ from content1.
534 533
535 534 Line numbers are 1-based. The numbers are the first and last line contained
536 535 in the range. Single-line ranges have the same line number for the first and
537 536 last line. Excludes any empty ranges that result from lines that are only
538 537 present in content1. Relies on mdiff's idea of where the line endings are in
539 538 the string.
540 539
541 540 >>> from mercurial import pycompat
542 541 >>> lines = lambda s: b'\\n'.join([c for c in pycompat.iterbytestr(s)])
543 542 >>> difflineranges2 = lambda a, b: difflineranges(lines(a), lines(b))
544 543 >>> difflineranges2(b'', b'')
545 544 []
546 545 >>> difflineranges2(b'a', b'')
547 546 []
548 547 >>> difflineranges2(b'', b'A')
549 548 [(1, 1)]
550 549 >>> difflineranges2(b'a', b'a')
551 550 []
552 551 >>> difflineranges2(b'a', b'A')
553 552 [(1, 1)]
554 553 >>> difflineranges2(b'ab', b'')
555 554 []
556 555 >>> difflineranges2(b'', b'AB')
557 556 [(1, 2)]
558 557 >>> difflineranges2(b'abc', b'ac')
559 558 []
560 559 >>> difflineranges2(b'ab', b'aCb')
561 560 [(2, 2)]
562 561 >>> difflineranges2(b'abc', b'aBc')
563 562 [(2, 2)]
564 563 >>> difflineranges2(b'ab', b'AB')
565 564 [(1, 2)]
566 565 >>> difflineranges2(b'abcde', b'aBcDe')
567 566 [(2, 2), (4, 4)]
568 567 >>> difflineranges2(b'abcde', b'aBCDe')
569 568 [(2, 4)]
570 569 """
571 570 ranges = []
572 571 for lines, kind in mdiff.allblocks(content1, content2):
573 572 firstline, lastline = lines[2:4]
574 573 if kind == b'!' and firstline != lastline:
575 574 ranges.append((firstline + 1, lastline))
576 575 return ranges
577 576
578 577
579 578 def getbasectxs(repo, opts, revstofix):
580 579 """Returns a map of the base contexts for each revision
581 580
582 581 The base contexts determine which lines are considered modified when we
583 582 attempt to fix just the modified lines in a file. It also determines which
584 583 files we attempt to fix, so it is important to compute this even when
585 584 --whole is used.
586 585 """
587 586 # The --base flag overrides the usual logic, and we give every revision
588 587 # exactly the set of baserevs that the user specified.
589 588 if opts.get(b'base'):
590 589 baserevs = set(scmutil.revrange(repo, opts.get(b'base')))
591 590 if not baserevs:
592 591 baserevs = {nullrev}
593 592 basectxs = {repo[rev] for rev in baserevs}
594 593 return {rev: basectxs for rev in revstofix}
595 594
596 595 # Proceed in topological order so that we can easily determine each
597 596 # revision's baserevs by looking at its parents and their baserevs.
598 597 basectxs = collections.defaultdict(set)
599 598 for rev in sorted(revstofix):
600 599 ctx = repo[rev]
601 600 for pctx in ctx.parents():
602 601 if pctx.rev() in basectxs:
603 602 basectxs[rev].update(basectxs[pctx.rev()])
604 603 else:
605 604 basectxs[rev].add(pctx)
606 605 return basectxs
607 606
608 607
609 608 def fixfile(ui, repo, opts, fixers, fixctx, path, basectxs):
610 609 """Run any configured fixers that should affect the file in this context
611 610
612 611 Returns the file content that results from applying the fixers in some order
613 612 starting with the file's content in the fixctx. Fixers that support line
614 613 ranges will affect lines that have changed relative to any of the basectxs
615 614 (i.e. they will only avoid lines that are common to all basectxs).
616 615
617 616 A fixer tool's stdout will become the file's new content if and only if it
618 617 exits with code zero. The fixer tool's working directory is the repository's
619 618 root.
620 619 """
621 620 metadata = {}
622 621 newdata = fixctx[path].data()
623 622 for fixername, fixer in pycompat.iteritems(fixers):
624 623 if fixer.affects(opts, fixctx, path):
625 624 ranges = lineranges(opts, path, basectxs, fixctx, newdata)
626 625 command = fixer.command(ui, path, ranges)
627 626 if command is None:
628 627 continue
629 628 ui.debug(b'subprocess: %s\n' % (command,))
630 629 proc = subprocess.Popen(
631 630 procutil.tonativestr(command),
632 631 shell=True,
633 632 cwd=procutil.tonativestr(repo.root),
634 633 stdin=subprocess.PIPE,
635 634 stdout=subprocess.PIPE,
636 635 stderr=subprocess.PIPE,
637 636 )
638 637 stdout, stderr = proc.communicate(newdata)
639 638 if stderr:
640 639 showstderr(ui, fixctx.rev(), fixername, stderr)
641 640 newerdata = stdout
642 641 if fixer.shouldoutputmetadata():
643 642 try:
644 643 metadatajson, newerdata = stdout.split(b'\0', 1)
645 metadata[fixername] = json.loads(metadatajson)
644 metadata[fixername] = pycompat.json_loads(metadatajson)
646 645 except ValueError:
647 646 ui.warn(
648 647 _(b'ignored invalid output from fixer tool: %s\n')
649 648 % (fixername,)
650 649 )
651 650 continue
652 651 else:
653 652 metadata[fixername] = None
654 653 if proc.returncode == 0:
655 654 newdata = newerdata
656 655 else:
657 656 if not stderr:
658 657 message = _(b'exited with status %d\n') % (proc.returncode,)
659 658 showstderr(ui, fixctx.rev(), fixername, message)
660 659 checktoolfailureaction(
661 660 ui,
662 661 _(b'no fixes will be applied'),
663 662 hint=_(
664 663 b'use --config fix.failure=continue to apply any '
665 664 b'successful fixes anyway'
666 665 ),
667 666 )
668 667 return metadata, newdata
669 668
670 669
671 670 def showstderr(ui, rev, fixername, stderr):
672 671 """Writes the lines of the stderr string as warnings on the ui
673 672
674 673 Uses the revision number and fixername to give more context to each line of
675 674 the error message. Doesn't include file names, since those take up a lot of
676 675 space and would tend to be included in the error message if they were
677 676 relevant.
678 677 """
679 678 for line in re.split(b'[\r\n]+', stderr):
680 679 if line:
681 680 ui.warn(b'[')
682 681 if rev is None:
683 682 ui.warn(_(b'wdir'), label=b'evolve.rev')
684 683 else:
685 684 ui.warn((str(rev)), label=b'evolve.rev')
686 685 ui.warn(b'] %s: %s\n' % (fixername, line))
687 686
688 687
689 688 def writeworkingdir(repo, ctx, filedata, replacements):
690 689 """Write new content to the working copy and check out the new p1 if any
691 690
692 691 We check out a new revision if and only if we fixed something in both the
693 692 working directory and its parent revision. This avoids the need for a full
694 693 update/merge, and means that the working directory simply isn't affected
695 694 unless the --working-dir flag is given.
696 695
697 696 Directly updates the dirstate for the affected files.
698 697 """
699 698 for path, data in pycompat.iteritems(filedata):
700 699 fctx = ctx[path]
701 700 fctx.write(data, fctx.flags())
702 701 if repo.dirstate[path] == b'n':
703 702 repo.dirstate.normallookup(path)
704 703
705 704 oldparentnodes = repo.dirstate.parents()
706 705 newparentnodes = [replacements.get(n, n) for n in oldparentnodes]
707 706 if newparentnodes != oldparentnodes:
708 707 repo.setparents(*newparentnodes)
709 708
710 709
711 710 def replacerev(ui, repo, ctx, filedata, replacements):
712 711 """Commit a new revision like the given one, but with file content changes
713 712
714 713 "ctx" is the original revision to be replaced by a modified one.
715 714
716 715 "filedata" is a dict that maps paths to their new file content. All other
717 716 paths will be recreated from the original revision without changes.
718 717 "filedata" may contain paths that didn't exist in the original revision;
719 718 they will be added.
720 719
721 720 "replacements" is a dict that maps a single node to a single node, and it is
722 721 updated to indicate the original revision is replaced by the newly created
723 722 one. No entry is added if the replacement's node already exists.
724 723
725 724 The new revision has the same parents as the old one, unless those parents
726 725 have already been replaced, in which case those replacements are the parents
727 726 of this new revision. Thus, if revisions are replaced in topological order,
728 727 there is no need to rebase them into the original topology later.
729 728 """
730 729
731 730 p1rev, p2rev = repo.changelog.parentrevs(ctx.rev())
732 731 p1ctx, p2ctx = repo[p1rev], repo[p2rev]
733 732 newp1node = replacements.get(p1ctx.node(), p1ctx.node())
734 733 newp2node = replacements.get(p2ctx.node(), p2ctx.node())
735 734
736 735 # We don't want to create a revision that has no changes from the original,
737 736 # but we should if the original revision's parent has been replaced.
738 737 # Otherwise, we would produce an orphan that needs no actual human
739 738 # intervention to evolve. We can't rely on commit() to avoid creating the
740 739 # un-needed revision because the extra field added below produces a new hash
741 740 # regardless of file content changes.
742 741 if (
743 742 not filedata
744 743 and p1ctx.node() not in replacements
745 744 and p2ctx.node() not in replacements
746 745 ):
747 746 return
748 747
749 748 def filectxfn(repo, memctx, path):
750 749 if path not in ctx:
751 750 return None
752 751 fctx = ctx[path]
753 752 copysource = fctx.copysource()
754 753 return context.memfilectx(
755 754 repo,
756 755 memctx,
757 756 path=fctx.path(),
758 757 data=filedata.get(path, fctx.data()),
759 758 islink=fctx.islink(),
760 759 isexec=fctx.isexec(),
761 760 copysource=copysource,
762 761 )
763 762
764 763 extra = ctx.extra().copy()
765 764 extra[b'fix_source'] = ctx.hex()
766 765
767 766 memctx = context.memctx(
768 767 repo,
769 768 parents=(newp1node, newp2node),
770 769 text=ctx.description(),
771 770 files=set(ctx.files()) | set(filedata.keys()),
772 771 filectxfn=filectxfn,
773 772 user=ctx.user(),
774 773 date=ctx.date(),
775 774 extra=extra,
776 775 branch=ctx.branch(),
777 776 editor=None,
778 777 )
779 778 sucnode = memctx.commit()
780 779 prenode = ctx.node()
781 780 if prenode == sucnode:
782 781 ui.debug(b'node %s already existed\n' % (ctx.hex()))
783 782 else:
784 783 replacements[ctx.node()] = sucnode
785 784
786 785
787 786 def getfixers(ui):
788 787 """Returns a map of configured fixer tools indexed by their names
789 788
790 789 Each value is a Fixer object with methods that implement the behavior of the
791 790 fixer's config suboptions. Does not validate the config values.
792 791 """
793 792 fixers = {}
794 793 for name in fixernames(ui):
795 794 enabled = ui.configbool(b'fix', name + b':enabled')
796 795 command = ui.config(b'fix', name + b':command')
797 796 pattern = ui.config(b'fix', name + b':pattern')
798 797 linerange = ui.config(b'fix', name + b':linerange')
799 798 priority = ui.configint(b'fix', name + b':priority')
800 799 metadata = ui.configbool(b'fix', name + b':metadata')
801 800 skipclean = ui.configbool(b'fix', name + b':skipclean')
802 801 # Don't use a fixer if it has no pattern configured. It would be
803 802 # dangerous to let it affect all files. It would be pointless to let it
804 803 # affect no files. There is no reasonable subset of files to use as the
805 804 # default.
806 805 if command is None:
807 806 ui.warn(
808 807 _(b'fixer tool has no command configuration: %s\n') % (name,)
809 808 )
810 809 elif pattern is None:
811 810 ui.warn(
812 811 _(b'fixer tool has no pattern configuration: %s\n') % (name,)
813 812 )
814 813 elif not enabled:
815 814 ui.debug(b'ignoring disabled fixer tool: %s\n' % (name,))
816 815 else:
817 816 fixers[name] = Fixer(
818 817 command, pattern, linerange, priority, metadata, skipclean
819 818 )
820 819 return collections.OrderedDict(
821 820 sorted(fixers.items(), key=lambda item: item[1]._priority, reverse=True)
822 821 )
823 822
824 823
825 824 def fixernames(ui):
826 825 """Returns the names of [fix] config options that have suboptions"""
827 826 names = set()
828 827 for k, v in ui.configitems(b'fix'):
829 828 if b':' in k:
830 829 names.add(k.split(b':', 1)[0])
831 830 return names
832 831
833 832
834 833 class Fixer(object):
835 834 """Wraps the raw config values for a fixer with methods"""
836 835
837 836 def __init__(
838 837 self, command, pattern, linerange, priority, metadata, skipclean
839 838 ):
840 839 self._command = command
841 840 self._pattern = pattern
842 841 self._linerange = linerange
843 842 self._priority = priority
844 843 self._metadata = metadata
845 844 self._skipclean = skipclean
846 845
847 846 def affects(self, opts, fixctx, path):
848 847 """Should this fixer run on the file at the given path and context?"""
849 848 repo = fixctx.repo()
850 849 matcher = matchmod.match(
851 850 repo.root, repo.root, [self._pattern], ctx=fixctx
852 851 )
853 852 return matcher(path)
854 853
855 854 def shouldoutputmetadata(self):
856 855 """Should the stdout of this fixer start with JSON and a null byte?"""
857 856 return self._metadata
858 857
859 858 def command(self, ui, path, ranges):
860 859 """A shell command to use to invoke this fixer on the given file/lines
861 860
862 861 May return None if there is no appropriate command to run for the given
863 862 parameters.
864 863 """
865 864 expand = cmdutil.rendercommandtemplate
866 865 parts = [
867 866 expand(
868 867 ui,
869 868 self._command,
870 869 {b'rootpath': path, b'basename': os.path.basename(path)},
871 870 )
872 871 ]
873 872 if self._linerange:
874 873 if self._skipclean and not ranges:
875 874 # No line ranges to fix, so don't run the fixer.
876 875 return None
877 876 for first, last in ranges:
878 877 parts.append(
879 878 expand(
880 879 ui, self._linerange, {b'first': first, b'last': last}
881 880 )
882 881 )
883 882 return b' '.join(parts)
@@ -1,746 +1,746 b''
1 1 # blobstore.py - local and remote (speaking Git-LFS protocol) blob storages
2 2 #
3 3 # Copyright 2017 Facebook, Inc.
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 __future__ import absolute_import
9 9
10 10 import contextlib
11 11 import errno
12 12 import hashlib
13 13 import json
14 14 import os
15 15 import re
16 16 import socket
17 17
18 18 from mercurial.i18n import _
19 19 from mercurial.pycompat import getattr
20 20
21 21 from mercurial import (
22 22 encoding,
23 23 error,
24 24 node,
25 25 pathutil,
26 26 pycompat,
27 27 url as urlmod,
28 28 util,
29 29 vfs as vfsmod,
30 30 worker,
31 31 )
32 32
33 33 from mercurial.utils import stringutil
34 34
35 35 from ..largefiles import lfutil
36 36
37 37 # 64 bytes for SHA256
38 38 _lfsre = re.compile(br'\A[a-f0-9]{64}\Z')
39 39
40 40
41 41 class lfsvfs(vfsmod.vfs):
42 42 def join(self, path):
43 43 """split the path at first two characters, like: XX/XXXXX..."""
44 44 if not _lfsre.match(path):
45 45 raise error.ProgrammingError(b'unexpected lfs path: %s' % path)
46 46 return super(lfsvfs, self).join(path[0:2], path[2:])
47 47
48 48 def walk(self, path=None, onerror=None):
49 49 """Yield (dirpath, [], oids) tuple for blobs under path
50 50
51 51 Oids only exist in the root of this vfs, so dirpath is always ''.
52 52 """
53 53 root = os.path.normpath(self.base)
54 54 # when dirpath == root, dirpath[prefixlen:] becomes empty
55 55 # because len(dirpath) < prefixlen.
56 56 prefixlen = len(pathutil.normasprefix(root))
57 57 oids = []
58 58
59 59 for dirpath, dirs, files in os.walk(
60 60 self.reljoin(self.base, path or b''), onerror=onerror
61 61 ):
62 62 dirpath = dirpath[prefixlen:]
63 63
64 64 # Silently skip unexpected files and directories
65 65 if len(dirpath) == 2:
66 66 oids.extend(
67 67 [dirpath + f for f in files if _lfsre.match(dirpath + f)]
68 68 )
69 69
70 70 yield (b'', [], oids)
71 71
72 72
73 73 class nullvfs(lfsvfs):
74 74 def __init__(self):
75 75 pass
76 76
77 77 def exists(self, oid):
78 78 return False
79 79
80 80 def read(self, oid):
81 81 # store.read() calls into here if the blob doesn't exist in its
82 82 # self.vfs. Raise the same error as a normal vfs when asked to read a
83 83 # file that doesn't exist. The only difference is the full file path
84 84 # isn't available in the error.
85 85 raise IOError(
86 86 errno.ENOENT,
87 87 pycompat.sysstr(b'%s: No such file or directory' % oid),
88 88 )
89 89
90 90 def walk(self, path=None, onerror=None):
91 91 return (b'', [], [])
92 92
93 93 def write(self, oid, data):
94 94 pass
95 95
96 96
97 97 class filewithprogress(object):
98 98 """a file-like object that supports __len__ and read.
99 99
100 100 Useful to provide progress information for how many bytes are read.
101 101 """
102 102
103 103 def __init__(self, fp, callback):
104 104 self._fp = fp
105 105 self._callback = callback # func(readsize)
106 106 fp.seek(0, os.SEEK_END)
107 107 self._len = fp.tell()
108 108 fp.seek(0)
109 109
110 110 def __len__(self):
111 111 return self._len
112 112
113 113 def read(self, size):
114 114 if self._fp is None:
115 115 return b''
116 116 data = self._fp.read(size)
117 117 if data:
118 118 if self._callback:
119 119 self._callback(len(data))
120 120 else:
121 121 self._fp.close()
122 122 self._fp = None
123 123 return data
124 124
125 125
126 126 class local(object):
127 127 """Local blobstore for large file contents.
128 128
129 129 This blobstore is used both as a cache and as a staging area for large blobs
130 130 to be uploaded to the remote blobstore.
131 131 """
132 132
133 133 def __init__(self, repo):
134 134 fullpath = repo.svfs.join(b'lfs/objects')
135 135 self.vfs = lfsvfs(fullpath)
136 136
137 137 if repo.ui.configbool(b'experimental', b'lfs.disableusercache'):
138 138 self.cachevfs = nullvfs()
139 139 else:
140 140 usercache = lfutil._usercachedir(repo.ui, b'lfs')
141 141 self.cachevfs = lfsvfs(usercache)
142 142 self.ui = repo.ui
143 143
144 144 def open(self, oid):
145 145 """Open a read-only file descriptor to the named blob, in either the
146 146 usercache or the local store."""
147 147 # The usercache is the most likely place to hold the file. Commit will
148 148 # write to both it and the local store, as will anything that downloads
149 149 # the blobs. However, things like clone without an update won't
150 150 # populate the local store. For an init + push of a local clone,
151 151 # the usercache is the only place it _could_ be. If not present, the
152 152 # missing file msg here will indicate the local repo, not the usercache.
153 153 if self.cachevfs.exists(oid):
154 154 return self.cachevfs(oid, b'rb')
155 155
156 156 return self.vfs(oid, b'rb')
157 157
158 158 def download(self, oid, src):
159 159 """Read the blob from the remote source in chunks, verify the content,
160 160 and write to this local blobstore."""
161 161 sha256 = hashlib.sha256()
162 162
163 163 with self.vfs(oid, b'wb', atomictemp=True) as fp:
164 164 for chunk in util.filechunkiter(src, size=1048576):
165 165 fp.write(chunk)
166 166 sha256.update(chunk)
167 167
168 168 realoid = node.hex(sha256.digest())
169 169 if realoid != oid:
170 170 raise LfsCorruptionError(
171 171 _(b'corrupt remote lfs object: %s') % oid
172 172 )
173 173
174 174 self._linktousercache(oid)
175 175
176 176 def write(self, oid, data):
177 177 """Write blob to local blobstore.
178 178
179 179 This should only be called from the filelog during a commit or similar.
180 180 As such, there is no need to verify the data. Imports from a remote
181 181 store must use ``download()`` instead."""
182 182 with self.vfs(oid, b'wb', atomictemp=True) as fp:
183 183 fp.write(data)
184 184
185 185 self._linktousercache(oid)
186 186
187 187 def linkfromusercache(self, oid):
188 188 """Link blobs found in the user cache into this store.
189 189
190 190 The server module needs to do this when it lets the client know not to
191 191 upload the blob, to ensure it is always available in this store.
192 192 Normally this is done implicitly when the client reads or writes the
193 193 blob, but that doesn't happen when the server tells the client that it
194 194 already has the blob.
195 195 """
196 196 if not isinstance(self.cachevfs, nullvfs) and not self.vfs.exists(oid):
197 197 self.ui.note(_(b'lfs: found %s in the usercache\n') % oid)
198 198 lfutil.link(self.cachevfs.join(oid), self.vfs.join(oid))
199 199
200 200 def _linktousercache(self, oid):
201 201 # XXX: should we verify the content of the cache, and hardlink back to
202 202 # the local store on success, but truncate, write and link on failure?
203 203 if not self.cachevfs.exists(oid) and not isinstance(
204 204 self.cachevfs, nullvfs
205 205 ):
206 206 self.ui.note(_(b'lfs: adding %s to the usercache\n') % oid)
207 207 lfutil.link(self.vfs.join(oid), self.cachevfs.join(oid))
208 208
209 209 def read(self, oid, verify=True):
210 210 """Read blob from local blobstore."""
211 211 if not self.vfs.exists(oid):
212 212 blob = self._read(self.cachevfs, oid, verify)
213 213
214 214 # Even if revlog will verify the content, it needs to be verified
215 215 # now before making the hardlink to avoid propagating corrupt blobs.
216 216 # Don't abort if corruption is detected, because `hg verify` will
217 217 # give more useful info about the corruption- simply don't add the
218 218 # hardlink.
219 219 if verify or node.hex(hashlib.sha256(blob).digest()) == oid:
220 220 self.ui.note(_(b'lfs: found %s in the usercache\n') % oid)
221 221 lfutil.link(self.cachevfs.join(oid), self.vfs.join(oid))
222 222 else:
223 223 self.ui.note(_(b'lfs: found %s in the local lfs store\n') % oid)
224 224 blob = self._read(self.vfs, oid, verify)
225 225 return blob
226 226
227 227 def _read(self, vfs, oid, verify):
228 228 """Read blob (after verifying) from the given store"""
229 229 blob = vfs.read(oid)
230 230 if verify:
231 231 _verify(oid, blob)
232 232 return blob
233 233
234 234 def verify(self, oid):
235 235 """Indicate whether or not the hash of the underlying file matches its
236 236 name."""
237 237 sha256 = hashlib.sha256()
238 238
239 239 with self.open(oid) as fp:
240 240 for chunk in util.filechunkiter(fp, size=1048576):
241 241 sha256.update(chunk)
242 242
243 243 return oid == node.hex(sha256.digest())
244 244
245 245 def has(self, oid):
246 246 """Returns True if the local blobstore contains the requested blob,
247 247 False otherwise."""
248 248 return self.cachevfs.exists(oid) or self.vfs.exists(oid)
249 249
250 250
251 251 def _urlerrorreason(urlerror):
252 252 '''Create a friendly message for the given URLError to be used in an
253 253 LfsRemoteError message.
254 254 '''
255 255 inst = urlerror
256 256
257 257 if isinstance(urlerror.reason, Exception):
258 258 inst = urlerror.reason
259 259
260 260 if util.safehasattr(inst, b'reason'):
261 261 try: # usually it is in the form (errno, strerror)
262 262 reason = inst.reason.args[1]
263 263 except (AttributeError, IndexError):
264 264 # it might be anything, for example a string
265 265 reason = inst.reason
266 266 if isinstance(reason, pycompat.unicode):
267 267 # SSLError of Python 2.7.9 contains a unicode
268 268 reason = encoding.unitolocal(reason)
269 269 return reason
270 270 elif getattr(inst, "strerror", None):
271 271 return encoding.strtolocal(inst.strerror)
272 272 else:
273 273 return stringutil.forcebytestr(urlerror)
274 274
275 275
276 276 class lfsauthhandler(util.urlreq.basehandler):
277 277 handler_order = 480 # Before HTTPDigestAuthHandler (== 490)
278 278
279 279 def http_error_401(self, req, fp, code, msg, headers):
280 280 """Enforces that any authentication performed is HTTP Basic
281 281 Authentication. No authentication is also acceptable.
282 282 """
283 283 authreq = headers.get(r'www-authenticate', None)
284 284 if authreq:
285 285 scheme = authreq.split()[0]
286 286
287 287 if scheme.lower() != r'basic':
288 288 msg = _(b'the server must support Basic Authentication')
289 289 raise util.urlerr.httperror(
290 290 req.get_full_url(),
291 291 code,
292 292 encoding.strfromlocal(msg),
293 293 headers,
294 294 fp,
295 295 )
296 296 return None
297 297
298 298
299 299 class _gitlfsremote(object):
300 300 def __init__(self, repo, url):
301 301 ui = repo.ui
302 302 self.ui = ui
303 303 baseurl, authinfo = url.authinfo()
304 304 self.baseurl = baseurl.rstrip(b'/')
305 305 useragent = repo.ui.config(b'experimental', b'lfs.user-agent')
306 306 if not useragent:
307 307 useragent = b'git-lfs/2.3.4 (Mercurial %s)' % util.version()
308 308 self.urlopener = urlmod.opener(ui, authinfo, useragent)
309 309 self.urlopener.add_handler(lfsauthhandler())
310 310 self.retry = ui.configint(b'lfs', b'retry')
311 311
312 312 def writebatch(self, pointers, fromstore):
313 313 """Batch upload from local to remote blobstore."""
314 314 self._batch(_deduplicate(pointers), fromstore, b'upload')
315 315
316 316 def readbatch(self, pointers, tostore):
317 317 """Batch download from remote to local blostore."""
318 318 self._batch(_deduplicate(pointers), tostore, b'download')
319 319
320 320 def _batchrequest(self, pointers, action):
321 321 """Get metadata about objects pointed by pointers for given action
322 322
323 323 Return decoded JSON object like {'objects': [{'oid': '', 'size': 1}]}
324 324 See https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
325 325 """
326 326 objects = [
327 327 {r'oid': pycompat.strurl(p.oid()), r'size': p.size()}
328 328 for p in pointers
329 329 ]
330 330 requestdata = pycompat.bytesurl(
331 331 json.dumps(
332 332 {r'objects': objects, r'operation': pycompat.strurl(action),}
333 333 )
334 334 )
335 335 url = b'%s/objects/batch' % self.baseurl
336 336 batchreq = util.urlreq.request(pycompat.strurl(url), data=requestdata)
337 337 batchreq.add_header(r'Accept', r'application/vnd.git-lfs+json')
338 338 batchreq.add_header(r'Content-Type', r'application/vnd.git-lfs+json')
339 339 try:
340 340 with contextlib.closing(self.urlopener.open(batchreq)) as rsp:
341 341 rawjson = rsp.read()
342 342 except util.urlerr.httperror as ex:
343 343 hints = {
344 344 400: _(
345 345 b'check that lfs serving is enabled on %s and "%s" is '
346 346 b'supported'
347 347 )
348 348 % (self.baseurl, action),
349 349 404: _(b'the "lfs.url" config may be used to override %s')
350 350 % self.baseurl,
351 351 }
352 352 hint = hints.get(ex.code, _(b'api=%s, action=%s') % (url, action))
353 353 raise LfsRemoteError(
354 354 _(b'LFS HTTP error: %s') % stringutil.forcebytestr(ex),
355 355 hint=hint,
356 356 )
357 357 except util.urlerr.urlerror as ex:
358 358 hint = (
359 359 _(b'the "lfs.url" config may be used to override %s')
360 360 % self.baseurl
361 361 )
362 362 raise LfsRemoteError(
363 363 _(b'LFS error: %s') % _urlerrorreason(ex), hint=hint
364 364 )
365 365 try:
366 response = json.loads(rawjson)
366 response = pycompat.json_loads(rawjson)
367 367 except ValueError:
368 368 raise LfsRemoteError(
369 369 _(b'LFS server returns invalid JSON: %s')
370 370 % rawjson.encode("utf-8")
371 371 )
372 372
373 373 if self.ui.debugflag:
374 374 self.ui.debug(b'Status: %d\n' % rsp.status)
375 375 # lfs-test-server and hg serve return headers in different order
376 376 headers = pycompat.bytestr(rsp.info()).strip()
377 377 self.ui.debug(b'%s\n' % b'\n'.join(sorted(headers.splitlines())))
378 378
379 379 if r'objects' in response:
380 380 response[r'objects'] = sorted(
381 381 response[r'objects'], key=lambda p: p[r'oid']
382 382 )
383 383 self.ui.debug(
384 384 b'%s\n'
385 385 % pycompat.bytesurl(
386 386 json.dumps(
387 387 response,
388 388 indent=2,
389 389 separators=(r'', r': '),
390 390 sort_keys=True,
391 391 )
392 392 )
393 393 )
394 394
395 395 def encodestr(x):
396 396 if isinstance(x, pycompat.unicode):
397 397 return x.encode('utf-8')
398 398 return x
399 399
400 400 return pycompat.rapply(encodestr, response)
401 401
402 402 def _checkforservererror(self, pointers, responses, action):
403 403 """Scans errors from objects
404 404
405 405 Raises LfsRemoteError if any objects have an error"""
406 406 for response in responses:
407 407 # The server should return 404 when objects cannot be found. Some
408 408 # server implementation (ex. lfs-test-server) does not set "error"
409 409 # but just removes "download" from "actions". Treat that case
410 410 # as the same as 404 error.
411 411 if b'error' not in response:
412 412 if action == b'download' and action not in response.get(
413 413 b'actions', []
414 414 ):
415 415 code = 404
416 416 else:
417 417 continue
418 418 else:
419 419 # An error dict without a code doesn't make much sense, so
420 420 # treat as a server error.
421 421 code = response.get(b'error').get(b'code', 500)
422 422
423 423 ptrmap = {p.oid(): p for p in pointers}
424 424 p = ptrmap.get(response[b'oid'], None)
425 425 if p:
426 426 filename = getattr(p, 'filename', b'unknown')
427 427 errors = {
428 428 404: b'The object does not exist',
429 429 410: b'The object was removed by the owner',
430 430 422: b'Validation error',
431 431 500: b'Internal server error',
432 432 }
433 433 msg = errors.get(code, b'status code %d' % code)
434 434 raise LfsRemoteError(
435 435 _(b'LFS server error for "%s": %s') % (filename, msg)
436 436 )
437 437 else:
438 438 raise LfsRemoteError(
439 439 _(b'LFS server error. Unsolicited response for oid %s')
440 440 % response[b'oid']
441 441 )
442 442
443 443 def _extractobjects(self, response, pointers, action):
444 444 """extract objects from response of the batch API
445 445
446 446 response: parsed JSON object returned by batch API
447 447 return response['objects'] filtered by action
448 448 raise if any object has an error
449 449 """
450 450 # Scan errors from objects - fail early
451 451 objects = response.get(b'objects', [])
452 452 self._checkforservererror(pointers, objects, action)
453 453
454 454 # Filter objects with given action. Practically, this skips uploading
455 455 # objects which exist in the server.
456 456 filteredobjects = [
457 457 o for o in objects if action in o.get(b'actions', [])
458 458 ]
459 459
460 460 return filteredobjects
461 461
462 462 def _basictransfer(self, obj, action, localstore):
463 463 """Download or upload a single object using basic transfer protocol
464 464
465 465 obj: dict, an object description returned by batch API
466 466 action: string, one of ['upload', 'download']
467 467 localstore: blobstore.local
468 468
469 469 See https://github.com/git-lfs/git-lfs/blob/master/docs/api/\
470 470 basic-transfers.md
471 471 """
472 472 oid = obj[b'oid']
473 473 href = obj[b'actions'][action].get(b'href')
474 474 headers = obj[b'actions'][action].get(b'header', {}).items()
475 475
476 476 request = util.urlreq.request(pycompat.strurl(href))
477 477 if action == b'upload':
478 478 # If uploading blobs, read data from local blobstore.
479 479 if not localstore.verify(oid):
480 480 raise error.Abort(
481 481 _(b'detected corrupt lfs object: %s') % oid,
482 482 hint=_(b'run hg verify'),
483 483 )
484 484 request.data = filewithprogress(localstore.open(oid), None)
485 485 request.get_method = lambda: r'PUT'
486 486 request.add_header(r'Content-Type', r'application/octet-stream')
487 487 request.add_header(r'Content-Length', len(request.data))
488 488
489 489 for k, v in headers:
490 490 request.add_header(pycompat.strurl(k), pycompat.strurl(v))
491 491
492 492 response = b''
493 493 try:
494 494 with contextlib.closing(self.urlopener.open(request)) as req:
495 495 ui = self.ui # Shorten debug lines
496 496 if self.ui.debugflag:
497 497 ui.debug(b'Status: %d\n' % req.status)
498 498 # lfs-test-server and hg serve return headers in different
499 499 # order
500 500 headers = pycompat.bytestr(req.info()).strip()
501 501 ui.debug(b'%s\n' % b'\n'.join(sorted(headers.splitlines())))
502 502
503 503 if action == b'download':
504 504 # If downloading blobs, store downloaded data to local
505 505 # blobstore
506 506 localstore.download(oid, req)
507 507 else:
508 508 while True:
509 509 data = req.read(1048576)
510 510 if not data:
511 511 break
512 512 response += data
513 513 if response:
514 514 ui.debug(b'lfs %s response: %s' % (action, response))
515 515 except util.urlerr.httperror as ex:
516 516 if self.ui.debugflag:
517 517 self.ui.debug(
518 518 b'%s: %s\n' % (oid, ex.read())
519 519 ) # XXX: also bytes?
520 520 raise LfsRemoteError(
521 521 _(b'LFS HTTP error: %s (oid=%s, action=%s)')
522 522 % (stringutil.forcebytestr(ex), oid, action)
523 523 )
524 524 except util.urlerr.urlerror as ex:
525 525 hint = _(b'attempted connection to %s') % pycompat.bytesurl(
526 526 util.urllibcompat.getfullurl(request)
527 527 )
528 528 raise LfsRemoteError(
529 529 _(b'LFS error: %s') % _urlerrorreason(ex), hint=hint
530 530 )
531 531
532 532 def _batch(self, pointers, localstore, action):
533 533 if action not in [b'upload', b'download']:
534 534 raise error.ProgrammingError(b'invalid Git-LFS action: %s' % action)
535 535
536 536 response = self._batchrequest(pointers, action)
537 537 objects = self._extractobjects(response, pointers, action)
538 538 total = sum(x.get(b'size', 0) for x in objects)
539 539 sizes = {}
540 540 for obj in objects:
541 541 sizes[obj.get(b'oid')] = obj.get(b'size', 0)
542 542 topic = {
543 543 b'upload': _(b'lfs uploading'),
544 544 b'download': _(b'lfs downloading'),
545 545 }[action]
546 546 if len(objects) > 1:
547 547 self.ui.note(
548 548 _(b'lfs: need to transfer %d objects (%s)\n')
549 549 % (len(objects), util.bytecount(total))
550 550 )
551 551
552 552 def transfer(chunk):
553 553 for obj in chunk:
554 554 objsize = obj.get(b'size', 0)
555 555 if self.ui.verbose:
556 556 if action == b'download':
557 557 msg = _(b'lfs: downloading %s (%s)\n')
558 558 elif action == b'upload':
559 559 msg = _(b'lfs: uploading %s (%s)\n')
560 560 self.ui.note(
561 561 msg % (obj.get(b'oid'), util.bytecount(objsize))
562 562 )
563 563 retry = self.retry
564 564 while True:
565 565 try:
566 566 self._basictransfer(obj, action, localstore)
567 567 yield 1, obj.get(b'oid')
568 568 break
569 569 except socket.error as ex:
570 570 if retry > 0:
571 571 self.ui.note(
572 572 _(b'lfs: failed: %r (remaining retry %d)\n')
573 573 % (stringutil.forcebytestr(ex), retry)
574 574 )
575 575 retry -= 1
576 576 continue
577 577 raise
578 578
579 579 # Until https multiplexing gets sorted out
580 580 if self.ui.configbool(b'experimental', b'lfs.worker-enable'):
581 581 oids = worker.worker(
582 582 self.ui,
583 583 0.1,
584 584 transfer,
585 585 (),
586 586 sorted(objects, key=lambda o: o.get(b'oid')),
587 587 )
588 588 else:
589 589 oids = transfer(sorted(objects, key=lambda o: o.get(b'oid')))
590 590
591 591 with self.ui.makeprogress(topic, total=total) as progress:
592 592 progress.update(0)
593 593 processed = 0
594 594 blobs = 0
595 595 for _one, oid in oids:
596 596 processed += sizes[oid]
597 597 blobs += 1
598 598 progress.update(processed)
599 599 self.ui.note(_(b'lfs: processed: %s\n') % oid)
600 600
601 601 if blobs > 0:
602 602 if action == b'upload':
603 603 self.ui.status(
604 604 _(b'lfs: uploaded %d files (%s)\n')
605 605 % (blobs, util.bytecount(processed))
606 606 )
607 607 elif action == b'download':
608 608 self.ui.status(
609 609 _(b'lfs: downloaded %d files (%s)\n')
610 610 % (blobs, util.bytecount(processed))
611 611 )
612 612
613 613 def __del__(self):
614 614 # copied from mercurial/httppeer.py
615 615 urlopener = getattr(self, 'urlopener', None)
616 616 if urlopener:
617 617 for h in urlopener.handlers:
618 618 h.close()
619 619 getattr(h, "close_all", lambda: None)()
620 620
621 621
622 622 class _dummyremote(object):
623 623 """Dummy store storing blobs to temp directory."""
624 624
625 625 def __init__(self, repo, url):
626 626 fullpath = repo.vfs.join(b'lfs', url.path)
627 627 self.vfs = lfsvfs(fullpath)
628 628
629 629 def writebatch(self, pointers, fromstore):
630 630 for p in _deduplicate(pointers):
631 631 content = fromstore.read(p.oid(), verify=True)
632 632 with self.vfs(p.oid(), b'wb', atomictemp=True) as fp:
633 633 fp.write(content)
634 634
635 635 def readbatch(self, pointers, tostore):
636 636 for p in _deduplicate(pointers):
637 637 with self.vfs(p.oid(), b'rb') as fp:
638 638 tostore.download(p.oid(), fp)
639 639
640 640
641 641 class _nullremote(object):
642 642 """Null store storing blobs to /dev/null."""
643 643
644 644 def __init__(self, repo, url):
645 645 pass
646 646
647 647 def writebatch(self, pointers, fromstore):
648 648 pass
649 649
650 650 def readbatch(self, pointers, tostore):
651 651 pass
652 652
653 653
654 654 class _promptremote(object):
655 655 """Prompt user to set lfs.url when accessed."""
656 656
657 657 def __init__(self, repo, url):
658 658 pass
659 659
660 660 def writebatch(self, pointers, fromstore, ui=None):
661 661 self._prompt()
662 662
663 663 def readbatch(self, pointers, tostore, ui=None):
664 664 self._prompt()
665 665
666 666 def _prompt(self):
667 667 raise error.Abort(_(b'lfs.url needs to be configured'))
668 668
669 669
670 670 _storemap = {
671 671 b'https': _gitlfsremote,
672 672 b'http': _gitlfsremote,
673 673 b'file': _dummyremote,
674 674 b'null': _nullremote,
675 675 None: _promptremote,
676 676 }
677 677
678 678
679 679 def _deduplicate(pointers):
680 680 """Remove any duplicate oids that exist in the list"""
681 681 reduced = util.sortdict()
682 682 for p in pointers:
683 683 reduced[p.oid()] = p
684 684 return reduced.values()
685 685
686 686
687 687 def _verify(oid, content):
688 688 realoid = node.hex(hashlib.sha256(content).digest())
689 689 if realoid != oid:
690 690 raise LfsCorruptionError(
691 691 _(b'detected corrupt lfs object: %s') % oid,
692 692 hint=_(b'run hg verify'),
693 693 )
694 694
695 695
696 696 def remote(repo, remote=None):
697 697 """remotestore factory. return a store in _storemap depending on config
698 698
699 699 If ``lfs.url`` is specified, use that remote endpoint. Otherwise, try to
700 700 infer the endpoint, based on the remote repository using the same path
701 701 adjustments as git. As an extension, 'http' is supported as well so that
702 702 ``hg serve`` works out of the box.
703 703
704 704 https://github.com/git-lfs/git-lfs/blob/master/docs/api/server-discovery.md
705 705 """
706 706 lfsurl = repo.ui.config(b'lfs', b'url')
707 707 url = util.url(lfsurl or b'')
708 708 if lfsurl is None:
709 709 if remote:
710 710 path = remote
711 711 elif util.safehasattr(repo, b'_subtoppath'):
712 712 # The pull command sets this during the optional update phase, which
713 713 # tells exactly where the pull originated, whether 'paths.default'
714 714 # or explicit.
715 715 path = repo._subtoppath
716 716 else:
717 717 # TODO: investigate 'paths.remote:lfsurl' style path customization,
718 718 # and fall back to inferring from 'paths.remote' if unspecified.
719 719 path = repo.ui.config(b'paths', b'default') or b''
720 720
721 721 defaulturl = util.url(path)
722 722
723 723 # TODO: support local paths as well.
724 724 # TODO: consider the ssh -> https transformation that git applies
725 725 if defaulturl.scheme in (b'http', b'https'):
726 726 if defaulturl.path and defaulturl.path[:-1] != b'/':
727 727 defaulturl.path += b'/'
728 728 defaulturl.path = (defaulturl.path or b'') + b'.git/info/lfs'
729 729
730 730 url = util.url(bytes(defaulturl))
731 731 repo.ui.note(_(b'lfs: assuming remote store: %s\n') % url)
732 732
733 733 scheme = url.scheme
734 734 if scheme not in _storemap:
735 735 raise error.Abort(_(b'lfs: unknown url scheme: %s') % scheme)
736 736 return _storemap[scheme](repo, url)
737 737
738 738
739 739 class LfsRemoteError(error.StorageError):
740 740 pass
741 741
742 742
743 743 class LfsCorruptionError(error.Abort):
744 744 """Raised when a corrupt blob is detected, aborting an operation
745 745
746 746 It exists to allow specialized handling on the server side."""
@@ -1,370 +1,370 b''
1 1 # wireprotolfsserver.py - lfs protocol server side implementation
2 2 #
3 3 # Copyright 2018 Matt Harbison <matt_harbison@yahoo.com>
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 __future__ import absolute_import
9 9
10 10 import datetime
11 11 import errno
12 12 import json
13 13 import traceback
14 14
15 15 from mercurial.hgweb import common as hgwebcommon
16 16
17 17 from mercurial import (
18 18 exthelper,
19 19 pycompat,
20 20 util,
21 21 wireprotoserver,
22 22 )
23 23
24 24 from . import blobstore
25 25
26 26 HTTP_OK = hgwebcommon.HTTP_OK
27 27 HTTP_CREATED = hgwebcommon.HTTP_CREATED
28 28 HTTP_BAD_REQUEST = hgwebcommon.HTTP_BAD_REQUEST
29 29 HTTP_NOT_FOUND = hgwebcommon.HTTP_NOT_FOUND
30 30 HTTP_METHOD_NOT_ALLOWED = hgwebcommon.HTTP_METHOD_NOT_ALLOWED
31 31 HTTP_NOT_ACCEPTABLE = hgwebcommon.HTTP_NOT_ACCEPTABLE
32 32 HTTP_UNSUPPORTED_MEDIA_TYPE = hgwebcommon.HTTP_UNSUPPORTED_MEDIA_TYPE
33 33
34 34 eh = exthelper.exthelper()
35 35
36 36
37 37 @eh.wrapfunction(wireprotoserver, b'handlewsgirequest')
38 38 def handlewsgirequest(orig, rctx, req, res, checkperm):
39 39 """Wrap wireprotoserver.handlewsgirequest() to possibly process an LFS
40 40 request if it is left unprocessed by the wrapped method.
41 41 """
42 42 if orig(rctx, req, res, checkperm):
43 43 return True
44 44
45 45 if not rctx.repo.ui.configbool(b'experimental', b'lfs.serve'):
46 46 return False
47 47
48 48 if not util.safehasattr(rctx.repo.svfs, 'lfslocalblobstore'):
49 49 return False
50 50
51 51 if not req.dispatchpath:
52 52 return False
53 53
54 54 try:
55 55 if req.dispatchpath == b'.git/info/lfs/objects/batch':
56 56 checkperm(rctx, req, b'pull')
57 57 return _processbatchrequest(rctx.repo, req, res)
58 58 # TODO: reserve and use a path in the proposed http wireprotocol /api/
59 59 # namespace?
60 60 elif req.dispatchpath.startswith(b'.hg/lfs/objects'):
61 61 return _processbasictransfer(
62 62 rctx.repo, req, res, lambda perm: checkperm(rctx, req, perm)
63 63 )
64 64 return False
65 65 except hgwebcommon.ErrorResponse as e:
66 66 # XXX: copied from the handler surrounding wireprotoserver._callhttp()
67 67 # in the wrapped function. Should this be moved back to hgweb to
68 68 # be a common handler?
69 69 for k, v in e.headers:
70 70 res.headers[k] = v
71 71 res.status = hgwebcommon.statusmessage(e.code, pycompat.bytestr(e))
72 72 res.setbodybytes(b'0\n%s\n' % pycompat.bytestr(e))
73 73 return True
74 74
75 75
76 76 def _sethttperror(res, code, message=None):
77 77 res.status = hgwebcommon.statusmessage(code, message=message)
78 78 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
79 79 res.setbodybytes(b'')
80 80
81 81
82 82 def _logexception(req):
83 83 """Write information about the current exception to wsgi.errors."""
84 84 tb = pycompat.sysbytes(traceback.format_exc())
85 85 errorlog = req.rawenv[b'wsgi.errors']
86 86
87 87 uri = b''
88 88 if req.apppath:
89 89 uri += req.apppath
90 90 uri += b'/' + req.dispatchpath
91 91
92 92 errorlog.write(
93 93 b"Exception happened while processing request '%s':\n%s" % (uri, tb)
94 94 )
95 95
96 96
97 97 def _processbatchrequest(repo, req, res):
98 98 """Handle a request for the Batch API, which is the gateway to granting file
99 99 access.
100 100
101 101 https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
102 102 """
103 103
104 104 # Mercurial client request:
105 105 #
106 106 # HOST: localhost:$HGPORT
107 107 # ACCEPT: application/vnd.git-lfs+json
108 108 # ACCEPT-ENCODING: identity
109 109 # USER-AGENT: git-lfs/2.3.4 (Mercurial 4.5.2+1114-f48b9754f04c+20180316)
110 110 # Content-Length: 125
111 111 # Content-Type: application/vnd.git-lfs+json
112 112 #
113 113 # {
114 114 # "objects": [
115 115 # {
116 116 # "oid": "31cf...8e5b"
117 117 # "size": 12
118 118 # }
119 119 # ]
120 120 # "operation": "upload"
121 121 # }
122 122
123 123 if req.method != b'POST':
124 124 _sethttperror(res, HTTP_METHOD_NOT_ALLOWED)
125 125 return True
126 126
127 127 if req.headers[b'Content-Type'] != b'application/vnd.git-lfs+json':
128 128 _sethttperror(res, HTTP_UNSUPPORTED_MEDIA_TYPE)
129 129 return True
130 130
131 131 if req.headers[b'Accept'] != b'application/vnd.git-lfs+json':
132 132 _sethttperror(res, HTTP_NOT_ACCEPTABLE)
133 133 return True
134 134
135 135 # XXX: specify an encoding?
136 lfsreq = json.loads(req.bodyfh.read())
136 lfsreq = pycompat.json_loads(req.bodyfh.read())
137 137
138 138 # If no transfer handlers are explicitly requested, 'basic' is assumed.
139 139 if r'basic' not in lfsreq.get(r'transfers', [r'basic']):
140 140 _sethttperror(
141 141 res,
142 142 HTTP_BAD_REQUEST,
143 143 b'Only the basic LFS transfer handler is supported',
144 144 )
145 145 return True
146 146
147 147 operation = lfsreq.get(r'operation')
148 148 operation = pycompat.bytestr(operation)
149 149
150 150 if operation not in (b'upload', b'download'):
151 151 _sethttperror(
152 152 res,
153 153 HTTP_BAD_REQUEST,
154 154 b'Unsupported LFS transfer operation: %s' % operation,
155 155 )
156 156 return True
157 157
158 158 localstore = repo.svfs.lfslocalblobstore
159 159
160 160 objects = [
161 161 p
162 162 for p in _batchresponseobjects(
163 163 req, lfsreq.get(r'objects', []), operation, localstore
164 164 )
165 165 ]
166 166
167 167 rsp = {
168 168 r'transfer': r'basic',
169 169 r'objects': objects,
170 170 }
171 171
172 172 res.status = hgwebcommon.statusmessage(HTTP_OK)
173 173 res.headers[b'Content-Type'] = b'application/vnd.git-lfs+json'
174 174 res.setbodybytes(pycompat.bytestr(json.dumps(rsp)))
175 175
176 176 return True
177 177
178 178
179 179 def _batchresponseobjects(req, objects, action, store):
180 180 """Yield one dictionary of attributes for the Batch API response for each
181 181 object in the list.
182 182
183 183 req: The parsedrequest for the Batch API request
184 184 objects: The list of objects in the Batch API object request list
185 185 action: 'upload' or 'download'
186 186 store: The local blob store for servicing requests"""
187 187
188 188 # Successful lfs-test-server response to solict an upload:
189 189 # {
190 190 # u'objects': [{
191 191 # u'size': 12,
192 192 # u'oid': u'31cf...8e5b',
193 193 # u'actions': {
194 194 # u'upload': {
195 195 # u'href': u'http://localhost:$HGPORT/objects/31cf...8e5b',
196 196 # u'expires_at': u'0001-01-01T00:00:00Z',
197 197 # u'header': {
198 198 # u'Accept': u'application/vnd.git-lfs'
199 199 # }
200 200 # }
201 201 # }
202 202 # }]
203 203 # }
204 204
205 205 # TODO: Sort out the expires_at/expires_in/authenticated keys.
206 206
207 207 for obj in objects:
208 208 # Convert unicode to ASCII to create a filesystem path
209 209 soid = obj.get(r'oid')
210 210 oid = soid.encode(r'ascii')
211 211 rsp = {
212 212 r'oid': soid,
213 213 r'size': obj.get(r'size'), # XXX: should this check the local size?
214 214 # r'authenticated': True,
215 215 }
216 216
217 217 exists = True
218 218 verifies = False
219 219
220 220 # Verify an existing file on the upload request, so that the client is
221 221 # solicited to re-upload if it corrupt locally. Download requests are
222 222 # also verified, so the error can be flagged in the Batch API response.
223 223 # (Maybe we can use this to short circuit the download for `hg verify`,
224 224 # IFF the client can assert that the remote end is an hg server.)
225 225 # Otherwise, it's potentially overkill on download, since it is also
226 226 # verified as the file is streamed to the caller.
227 227 try:
228 228 verifies = store.verify(oid)
229 229 if verifies and action == b'upload':
230 230 # The client will skip this upload, but make sure it remains
231 231 # available locally.
232 232 store.linkfromusercache(oid)
233 233 except IOError as inst:
234 234 if inst.errno != errno.ENOENT:
235 235 _logexception(req)
236 236
237 237 rsp[r'error'] = {
238 238 r'code': 500,
239 239 r'message': inst.strerror or r'Internal Server Server',
240 240 }
241 241 yield rsp
242 242 continue
243 243
244 244 exists = False
245 245
246 246 # Items are always listed for downloads. They are dropped for uploads
247 247 # IFF they already exist locally.
248 248 if action == b'download':
249 249 if not exists:
250 250 rsp[r'error'] = {
251 251 r'code': 404,
252 252 r'message': r"The object does not exist",
253 253 }
254 254 yield rsp
255 255 continue
256 256
257 257 elif not verifies:
258 258 rsp[r'error'] = {
259 259 r'code': 422, # XXX: is this the right code?
260 260 r'message': r"The object is corrupt",
261 261 }
262 262 yield rsp
263 263 continue
264 264
265 265 elif verifies:
266 266 yield rsp # Skip 'actions': already uploaded
267 267 continue
268 268
269 269 expiresat = datetime.datetime.now() + datetime.timedelta(minutes=10)
270 270
271 271 def _buildheader():
272 272 # The spec doesn't mention the Accept header here, but avoid
273 273 # a gratuitous deviation from lfs-test-server in the test
274 274 # output.
275 275 hdr = {r'Accept': r'application/vnd.git-lfs'}
276 276
277 277 auth = req.headers.get(b'Authorization', b'')
278 278 if auth.startswith(b'Basic '):
279 279 hdr[r'Authorization'] = pycompat.strurl(auth)
280 280
281 281 return hdr
282 282
283 283 rsp[r'actions'] = {
284 284 r'%s'
285 285 % pycompat.strurl(action): {
286 286 r'href': pycompat.strurl(
287 287 b'%s%s/.hg/lfs/objects/%s' % (req.baseurl, req.apppath, oid)
288 288 ),
289 289 # datetime.isoformat() doesn't include the 'Z' suffix
290 290 r"expires_at": expiresat.strftime(r'%Y-%m-%dT%H:%M:%SZ'),
291 291 r'header': _buildheader(),
292 292 }
293 293 }
294 294
295 295 yield rsp
296 296
297 297
298 298 def _processbasictransfer(repo, req, res, checkperm):
299 299 """Handle a single file upload (PUT) or download (GET) action for the Basic
300 300 Transfer Adapter.
301 301
302 302 After determining if the request is for an upload or download, the access
303 303 must be checked by calling ``checkperm()`` with either 'pull' or 'upload'
304 304 before accessing the files.
305 305
306 306 https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
307 307 """
308 308
309 309 method = req.method
310 310 oid = req.dispatchparts[-1]
311 311 localstore = repo.svfs.lfslocalblobstore
312 312
313 313 if len(req.dispatchparts) != 4:
314 314 _sethttperror(res, HTTP_NOT_FOUND)
315 315 return True
316 316
317 317 if method == b'PUT':
318 318 checkperm(b'upload')
319 319
320 320 # TODO: verify Content-Type?
321 321
322 322 existed = localstore.has(oid)
323 323
324 324 # TODO: how to handle timeouts? The body proxy handles limiting to
325 325 # Content-Length, but what happens if a client sends less than it
326 326 # says it will?
327 327
328 328 statusmessage = hgwebcommon.statusmessage
329 329 try:
330 330 localstore.download(oid, req.bodyfh)
331 331 res.status = statusmessage(HTTP_OK if existed else HTTP_CREATED)
332 332 except blobstore.LfsCorruptionError:
333 333 _logexception(req)
334 334
335 335 # XXX: Is this the right code?
336 336 res.status = statusmessage(422, b'corrupt blob')
337 337
338 338 # There's no payload here, but this is the header that lfs-test-server
339 339 # sends back. This eliminates some gratuitous test output conditionals.
340 340 res.headers[b'Content-Type'] = b'text/plain; charset=utf-8'
341 341 res.setbodybytes(b'')
342 342
343 343 return True
344 344 elif method == b'GET':
345 345 checkperm(b'pull')
346 346
347 347 res.status = hgwebcommon.statusmessage(HTTP_OK)
348 348 res.headers[b'Content-Type'] = b'application/octet-stream'
349 349
350 350 try:
351 351 # TODO: figure out how to send back the file in chunks, instead of
352 352 # reading the whole thing. (Also figure out how to send back
353 353 # an error status if an IOError occurs after a partial write
354 354 # in that case. Here, everything is read before starting.)
355 355 res.setbodybytes(localstore.read(oid))
356 356 except blobstore.LfsCorruptionError:
357 357 _logexception(req)
358 358
359 359 # XXX: Is this the right code?
360 360 res.status = hgwebcommon.statusmessage(422, b'corrupt blob')
361 361 res.setbodybytes(b'')
362 362
363 363 return True
364 364 else:
365 365 _sethttperror(
366 366 res,
367 367 HTTP_METHOD_NOT_ALLOWED,
368 368 message=b'Unsupported LFS transfer method: %s' % method,
369 369 )
370 370 return True
@@ -1,1651 +1,1651 b''
1 1 # phabricator.py - simple Phabricator integration
2 2 #
3 3 # Copyright 2017 Facebook, Inc.
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 """simple Phabricator integration (EXPERIMENTAL)
8 8
9 9 This extension provides a ``phabsend`` command which sends a stack of
10 10 changesets to Phabricator, and a ``phabread`` command which prints a stack of
11 11 revisions in a format suitable for :hg:`import`, and a ``phabupdate`` command
12 12 to update statuses in batch.
13 13
14 14 By default, Phabricator requires ``Test Plan`` which might prevent some
15 15 changeset from being sent. The requirement could be disabled by changing
16 16 ``differential.require-test-plan-field`` config server side.
17 17
18 18 Config::
19 19
20 20 [phabricator]
21 21 # Phabricator URL
22 22 url = https://phab.example.com/
23 23
24 24 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
25 25 # callsign is "FOO".
26 26 callsign = FOO
27 27
28 28 # curl command to use. If not set (default), use builtin HTTP library to
29 29 # communicate. If set, use the specified curl command. This could be useful
30 30 # if you need to specify advanced options that is not easily supported by
31 31 # the internal library.
32 32 curlcmd = curl --connect-timeout 2 --retry 3 --silent
33 33
34 34 [auth]
35 35 example.schemes = https
36 36 example.prefix = phab.example.com
37 37
38 38 # API token. Get it from https://$HOST/conduit/login/
39 39 example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
40 40 """
41 41
42 42 from __future__ import absolute_import
43 43
44 44 import base64
45 45 import contextlib
46 46 import hashlib
47 47 import itertools
48 48 import json
49 49 import mimetypes
50 50 import operator
51 51 import re
52 52
53 53 from mercurial.node import bin, nullid
54 54 from mercurial.i18n import _
55 55 from mercurial.pycompat import getattr
56 56 from mercurial.thirdparty import attr
57 57 from mercurial import (
58 58 cmdutil,
59 59 context,
60 60 encoding,
61 61 error,
62 62 exthelper,
63 63 httpconnection as httpconnectionmod,
64 64 match,
65 65 mdiff,
66 66 obsutil,
67 67 parser,
68 68 patch,
69 69 phases,
70 70 pycompat,
71 71 scmutil,
72 72 smartset,
73 73 tags,
74 74 templatefilters,
75 75 templateutil,
76 76 url as urlmod,
77 77 util,
78 78 )
79 79 from mercurial.utils import (
80 80 procutil,
81 81 stringutil,
82 82 )
83 83
84 84 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
85 85 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
86 86 # be specifying the version(s) of Mercurial they are tested with, or
87 87 # leave the attribute unspecified.
88 88 testedwith = b'ships-with-hg-core'
89 89
90 90 eh = exthelper.exthelper()
91 91
92 92 cmdtable = eh.cmdtable
93 93 command = eh.command
94 94 configtable = eh.configtable
95 95 templatekeyword = eh.templatekeyword
96 96
97 97 # developer config: phabricator.batchsize
98 98 eh.configitem(
99 99 b'phabricator', b'batchsize', default=12,
100 100 )
101 101 eh.configitem(
102 102 b'phabricator', b'callsign', default=None,
103 103 )
104 104 eh.configitem(
105 105 b'phabricator', b'curlcmd', default=None,
106 106 )
107 107 # developer config: phabricator.repophid
108 108 eh.configitem(
109 109 b'phabricator', b'repophid', default=None,
110 110 )
111 111 eh.configitem(
112 112 b'phabricator', b'url', default=None,
113 113 )
114 114 eh.configitem(
115 115 b'phabsend', b'confirm', default=False,
116 116 )
117 117
118 118 colortable = {
119 119 b'phabricator.action.created': b'green',
120 120 b'phabricator.action.skipped': b'magenta',
121 121 b'phabricator.action.updated': b'magenta',
122 122 b'phabricator.desc': b'',
123 123 b'phabricator.drev': b'bold',
124 124 b'phabricator.node': b'',
125 125 }
126 126
127 127 _VCR_FLAGS = [
128 128 (
129 129 b'',
130 130 b'test-vcr',
131 131 b'',
132 132 _(
133 133 b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
134 134 b', otherwise will mock all http requests using the specified vcr file.'
135 135 b' (ADVANCED)'
136 136 ),
137 137 ),
138 138 ]
139 139
140 140
141 141 def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
142 142 fullflags = flags + _VCR_FLAGS
143 143
144 144 def hgmatcher(r1, r2):
145 145 if r1.uri != r2.uri or r1.method != r2.method:
146 146 return False
147 147 r1params = util.urlreq.parseqs(r1.body)
148 148 r2params = util.urlreq.parseqs(r2.body)
149 149 for key in r1params:
150 150 if key not in r2params:
151 151 return False
152 152 value = r1params[key][0]
153 153 # we want to compare json payloads without worrying about ordering
154 154 if value.startswith(b'{') and value.endswith(b'}'):
155 r1json = json.loads(value)
156 r2json = json.loads(r2params[key][0])
155 r1json = pycompat.json_loads(value)
156 r2json = pycompat.json_loads(r2params[key][0])
157 157 if r1json != r2json:
158 158 return False
159 159 elif r2params[key][0] != value:
160 160 return False
161 161 return True
162 162
163 163 def sanitiserequest(request):
164 164 request.body = re.sub(
165 165 br'cli-[a-z0-9]+', br'cli-hahayouwish', request.body
166 166 )
167 167 return request
168 168
169 169 def sanitiseresponse(response):
170 170 if r'set-cookie' in response[r'headers']:
171 171 del response[r'headers'][r'set-cookie']
172 172 return response
173 173
174 174 def decorate(fn):
175 175 def inner(*args, **kwargs):
176 176 cassette = pycompat.fsdecode(kwargs.pop(r'test_vcr', None))
177 177 if cassette:
178 178 import hgdemandimport
179 179
180 180 with hgdemandimport.deactivated():
181 181 import vcr as vcrmod
182 182 import vcr.stubs as stubs
183 183
184 184 vcr = vcrmod.VCR(
185 185 serializer=r'json',
186 186 before_record_request=sanitiserequest,
187 187 before_record_response=sanitiseresponse,
188 188 custom_patches=[
189 189 (
190 190 urlmod,
191 191 r'httpconnection',
192 192 stubs.VCRHTTPConnection,
193 193 ),
194 194 (
195 195 urlmod,
196 196 r'httpsconnection',
197 197 stubs.VCRHTTPSConnection,
198 198 ),
199 199 ],
200 200 )
201 201 vcr.register_matcher(r'hgmatcher', hgmatcher)
202 202 with vcr.use_cassette(cassette, match_on=[r'hgmatcher']):
203 203 return fn(*args, **kwargs)
204 204 return fn(*args, **kwargs)
205 205
206 206 inner.__name__ = fn.__name__
207 207 inner.__doc__ = fn.__doc__
208 208 return command(
209 209 name,
210 210 fullflags,
211 211 spec,
212 212 helpcategory=helpcategory,
213 213 optionalrepo=optionalrepo,
214 214 )(inner)
215 215
216 216 return decorate
217 217
218 218
219 219 def urlencodenested(params):
220 220 """like urlencode, but works with nested parameters.
221 221
222 222 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
223 223 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
224 224 urlencode. Note: the encoding is consistent with PHP's http_build_query.
225 225 """
226 226 flatparams = util.sortdict()
227 227
228 228 def process(prefix, obj):
229 229 if isinstance(obj, bool):
230 230 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
231 231 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
232 232 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
233 233 if items is None:
234 234 flatparams[prefix] = obj
235 235 else:
236 236 for k, v in items(obj):
237 237 if prefix:
238 238 process(b'%s[%s]' % (prefix, k), v)
239 239 else:
240 240 process(k, v)
241 241
242 242 process(b'', params)
243 243 return util.urlreq.urlencode(flatparams)
244 244
245 245
246 246 def readurltoken(ui):
247 247 """return conduit url, token and make sure they exist
248 248
249 249 Currently read from [auth] config section. In the future, it might
250 250 make sense to read from .arcconfig and .arcrc as well.
251 251 """
252 252 url = ui.config(b'phabricator', b'url')
253 253 if not url:
254 254 raise error.Abort(
255 255 _(b'config %s.%s is required') % (b'phabricator', b'url')
256 256 )
257 257
258 258 res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user)
259 259 token = None
260 260
261 261 if res:
262 262 group, auth = res
263 263
264 264 ui.debug(b"using auth.%s.* for authentication\n" % group)
265 265
266 266 token = auth.get(b'phabtoken')
267 267
268 268 if not token:
269 269 raise error.Abort(
270 270 _(b'Can\'t find conduit token associated to %s') % (url,)
271 271 )
272 272
273 273 return url, token
274 274
275 275
276 276 def callconduit(ui, name, params):
277 277 """call Conduit API, params is a dict. return json.loads result, or None"""
278 278 host, token = readurltoken(ui)
279 279 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
280 280 ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
281 281 params = params.copy()
282 282 params[b'__conduit__'] = {
283 283 b'token': token,
284 284 }
285 285 rawdata = {
286 286 b'params': templatefilters.json(params),
287 287 b'output': b'json',
288 288 b'__conduit__': 1,
289 289 }
290 290 data = urlencodenested(rawdata)
291 291 curlcmd = ui.config(b'phabricator', b'curlcmd')
292 292 if curlcmd:
293 293 sin, sout = procutil.popen2(
294 294 b'%s -d @- %s' % (curlcmd, procutil.shellquote(url))
295 295 )
296 296 sin.write(data)
297 297 sin.close()
298 298 body = sout.read()
299 299 else:
300 300 urlopener = urlmod.opener(ui, authinfo)
301 301 request = util.urlreq.request(pycompat.strurl(url), data=data)
302 302 with contextlib.closing(urlopener.open(request)) as rsp:
303 303 body = rsp.read()
304 304 ui.debug(b'Conduit Response: %s\n' % body)
305 305 parsed = pycompat.rapply(
306 306 lambda x: encoding.unitolocal(x)
307 307 if isinstance(x, pycompat.unicode)
308 308 else x,
309 309 # json.loads only accepts bytes from py3.6+
310 json.loads(encoding.unifromlocal(body)),
310 pycompat.json_loads(encoding.unifromlocal(body)),
311 311 )
312 312 if parsed.get(b'error_code'):
313 313 msg = _(b'Conduit Error (%s): %s') % (
314 314 parsed[b'error_code'],
315 315 parsed[b'error_info'],
316 316 )
317 317 raise error.Abort(msg)
318 318 return parsed[b'result']
319 319
320 320
321 321 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
322 322 def debugcallconduit(ui, repo, name):
323 323 """call Conduit API
324 324
325 325 Call parameters are read from stdin as a JSON blob. Result will be written
326 326 to stdout as a JSON blob.
327 327 """
328 328 # json.loads only accepts bytes from 3.6+
329 329 rawparams = encoding.unifromlocal(ui.fin.read())
330 330 # json.loads only returns unicode strings
331 331 params = pycompat.rapply(
332 332 lambda x: encoding.unitolocal(x)
333 333 if isinstance(x, pycompat.unicode)
334 334 else x,
335 json.loads(rawparams),
335 pycompat.json_loads(rawparams),
336 336 )
337 337 # json.dumps only accepts unicode strings
338 338 result = pycompat.rapply(
339 339 lambda x: encoding.unifromlocal(x) if isinstance(x, bytes) else x,
340 340 callconduit(ui, name, params),
341 341 )
342 342 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
343 343 ui.write(b'%s\n' % encoding.unitolocal(s))
344 344
345 345
346 346 def getrepophid(repo):
347 347 """given callsign, return repository PHID or None"""
348 348 # developer config: phabricator.repophid
349 349 repophid = repo.ui.config(b'phabricator', b'repophid')
350 350 if repophid:
351 351 return repophid
352 352 callsign = repo.ui.config(b'phabricator', b'callsign')
353 353 if not callsign:
354 354 return None
355 355 query = callconduit(
356 356 repo.ui,
357 357 b'diffusion.repository.search',
358 358 {b'constraints': {b'callsigns': [callsign]}},
359 359 )
360 360 if len(query[b'data']) == 0:
361 361 return None
362 362 repophid = query[b'data'][0][b'phid']
363 363 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
364 364 return repophid
365 365
366 366
367 367 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
368 368 _differentialrevisiondescre = re.compile(
369 369 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M
370 370 )
371 371
372 372
373 373 def getoldnodedrevmap(repo, nodelist):
374 374 """find previous nodes that has been sent to Phabricator
375 375
376 376 return {node: (oldnode, Differential diff, Differential Revision ID)}
377 377 for node in nodelist with known previous sent versions, or associated
378 378 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
379 379 be ``None``.
380 380
381 381 Examines commit messages like "Differential Revision:" to get the
382 382 association information.
383 383
384 384 If such commit message line is not found, examines all precursors and their
385 385 tags. Tags with format like "D1234" are considered a match and the node
386 386 with that tag, and the number after "D" (ex. 1234) will be returned.
387 387
388 388 The ``old node``, if not None, is guaranteed to be the last diff of
389 389 corresponding Differential Revision, and exist in the repo.
390 390 """
391 391 unfi = repo.unfiltered()
392 392 nodemap = unfi.changelog.nodemap
393 393
394 394 result = {} # {node: (oldnode?, lastdiff?, drev)}
395 395 toconfirm = {} # {node: (force, {precnode}, drev)}
396 396 for node in nodelist:
397 397 ctx = unfi[node]
398 398 # For tags like "D123", put them into "toconfirm" to verify later
399 399 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
400 400 for n in precnodes:
401 401 if n in nodemap:
402 402 for tag in unfi.nodetags(n):
403 403 m = _differentialrevisiontagre.match(tag)
404 404 if m:
405 405 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
406 406 continue
407 407
408 408 # Check commit message
409 409 m = _differentialrevisiondescre.search(ctx.description())
410 410 if m:
411 411 toconfirm[node] = (1, set(precnodes), int(m.group(r'id')))
412 412
413 413 # Double check if tags are genuine by collecting all old nodes from
414 414 # Phabricator, and expect precursors overlap with it.
415 415 if toconfirm:
416 416 drevs = [drev for force, precs, drev in toconfirm.values()]
417 417 alldiffs = callconduit(
418 418 unfi.ui, b'differential.querydiffs', {b'revisionIDs': drevs}
419 419 )
420 420 getnode = lambda d: bin(getdiffmeta(d).get(b'node', b'')) or None
421 421 for newnode, (force, precset, drev) in toconfirm.items():
422 422 diffs = [
423 423 d for d in alldiffs.values() if int(d[b'revisionID']) == drev
424 424 ]
425 425
426 426 # "precursors" as known by Phabricator
427 427 phprecset = set(getnode(d) for d in diffs)
428 428
429 429 # Ignore if precursors (Phabricator and local repo) do not overlap,
430 430 # and force is not set (when commit message says nothing)
431 431 if not force and not bool(phprecset & precset):
432 432 tagname = b'D%d' % drev
433 433 tags.tag(
434 434 repo,
435 435 tagname,
436 436 nullid,
437 437 message=None,
438 438 user=None,
439 439 date=None,
440 440 local=True,
441 441 )
442 442 unfi.ui.warn(
443 443 _(
444 444 b'D%s: local tag removed - does not match '
445 445 b'Differential history\n'
446 446 )
447 447 % drev
448 448 )
449 449 continue
450 450
451 451 # Find the last node using Phabricator metadata, and make sure it
452 452 # exists in the repo
453 453 oldnode = lastdiff = None
454 454 if diffs:
455 455 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
456 456 oldnode = getnode(lastdiff)
457 457 if oldnode and oldnode not in nodemap:
458 458 oldnode = None
459 459
460 460 result[newnode] = (oldnode, lastdiff, drev)
461 461
462 462 return result
463 463
464 464
465 465 def getdiff(ctx, diffopts):
466 466 """plain-text diff without header (user, commit message, etc)"""
467 467 output = util.stringio()
468 468 for chunk, _label in patch.diffui(
469 469 ctx.repo(), ctx.p1().node(), ctx.node(), None, opts=diffopts
470 470 ):
471 471 output.write(chunk)
472 472 return output.getvalue()
473 473
474 474
475 475 class DiffChangeType(object):
476 476 ADD = 1
477 477 CHANGE = 2
478 478 DELETE = 3
479 479 MOVE_AWAY = 4
480 480 COPY_AWAY = 5
481 481 MOVE_HERE = 6
482 482 COPY_HERE = 7
483 483 MULTICOPY = 8
484 484
485 485
486 486 class DiffFileType(object):
487 487 TEXT = 1
488 488 IMAGE = 2
489 489 BINARY = 3
490 490
491 491
492 492 @attr.s
493 493 class phabhunk(dict):
494 494 """Represents a Differential hunk, which is owned by a Differential change
495 495 """
496 496
497 497 oldOffset = attr.ib(default=0) # camelcase-required
498 498 oldLength = attr.ib(default=0) # camelcase-required
499 499 newOffset = attr.ib(default=0) # camelcase-required
500 500 newLength = attr.ib(default=0) # camelcase-required
501 501 corpus = attr.ib(default='')
502 502 # These get added to the phabchange's equivalents
503 503 addLines = attr.ib(default=0) # camelcase-required
504 504 delLines = attr.ib(default=0) # camelcase-required
505 505
506 506
507 507 @attr.s
508 508 class phabchange(object):
509 509 """Represents a Differential change, owns Differential hunks and owned by a
510 510 Differential diff. Each one represents one file in a diff.
511 511 """
512 512
513 513 currentPath = attr.ib(default=None) # camelcase-required
514 514 oldPath = attr.ib(default=None) # camelcase-required
515 515 awayPaths = attr.ib(default=attr.Factory(list)) # camelcase-required
516 516 metadata = attr.ib(default=attr.Factory(dict))
517 517 oldProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
518 518 newProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
519 519 type = attr.ib(default=DiffChangeType.CHANGE)
520 520 fileType = attr.ib(default=DiffFileType.TEXT) # camelcase-required
521 521 commitHash = attr.ib(default=None) # camelcase-required
522 522 addLines = attr.ib(default=0) # camelcase-required
523 523 delLines = attr.ib(default=0) # camelcase-required
524 524 hunks = attr.ib(default=attr.Factory(list))
525 525
526 526 def copynewmetadatatoold(self):
527 527 for key in list(self.metadata.keys()):
528 528 newkey = key.replace(b'new:', b'old:')
529 529 self.metadata[newkey] = self.metadata[key]
530 530
531 531 def addoldmode(self, value):
532 532 self.oldProperties[b'unix:filemode'] = value
533 533
534 534 def addnewmode(self, value):
535 535 self.newProperties[b'unix:filemode'] = value
536 536
537 537 def addhunk(self, hunk):
538 538 if not isinstance(hunk, phabhunk):
539 539 raise error.Abort(b'phabchange.addhunk only takes phabhunks')
540 540 self.hunks.append(pycompat.byteskwargs(attr.asdict(hunk)))
541 541 # It's useful to include these stats since the Phab web UI shows them,
542 542 # and uses them to estimate how large a change a Revision is. Also used
543 543 # in email subjects for the [+++--] bit.
544 544 self.addLines += hunk.addLines
545 545 self.delLines += hunk.delLines
546 546
547 547
548 548 @attr.s
549 549 class phabdiff(object):
550 550 """Represents a Differential diff, owns Differential changes. Corresponds
551 551 to a commit.
552 552 """
553 553
554 554 # Doesn't seem to be any reason to send this (output of uname -n)
555 555 sourceMachine = attr.ib(default=b'') # camelcase-required
556 556 sourcePath = attr.ib(default=b'/') # camelcase-required
557 557 sourceControlBaseRevision = attr.ib(default=b'0' * 40) # camelcase-required
558 558 sourceControlPath = attr.ib(default=b'/') # camelcase-required
559 559 sourceControlSystem = attr.ib(default=b'hg') # camelcase-required
560 560 branch = attr.ib(default=b'default')
561 561 bookmark = attr.ib(default=None)
562 562 creationMethod = attr.ib(default=b'phabsend') # camelcase-required
563 563 lintStatus = attr.ib(default=b'none') # camelcase-required
564 564 unitStatus = attr.ib(default=b'none') # camelcase-required
565 565 changes = attr.ib(default=attr.Factory(dict))
566 566 repositoryPHID = attr.ib(default=None) # camelcase-required
567 567
568 568 def addchange(self, change):
569 569 if not isinstance(change, phabchange):
570 570 raise error.Abort(b'phabdiff.addchange only takes phabchanges')
571 571 self.changes[change.currentPath] = pycompat.byteskwargs(
572 572 attr.asdict(change)
573 573 )
574 574
575 575
576 576 def maketext(pchange, ctx, fname):
577 577 """populate the phabchange for a text file"""
578 578 repo = ctx.repo()
579 579 fmatcher = match.exact([fname])
580 580 diffopts = mdiff.diffopts(git=True, context=32767)
581 581 _pfctx, _fctx, header, fhunks = next(
582 582 patch.diffhunks(repo, ctx.p1(), ctx, fmatcher, opts=diffopts)
583 583 )
584 584
585 585 for fhunk in fhunks:
586 586 (oldOffset, oldLength, newOffset, newLength), lines = fhunk
587 587 corpus = b''.join(lines[1:])
588 588 shunk = list(header)
589 589 shunk.extend(lines)
590 590 _mf, _mt, addLines, delLines, _hb = patch.diffstatsum(
591 591 patch.diffstatdata(util.iterlines(shunk))
592 592 )
593 593 pchange.addhunk(
594 594 phabhunk(
595 595 oldOffset,
596 596 oldLength,
597 597 newOffset,
598 598 newLength,
599 599 corpus,
600 600 addLines,
601 601 delLines,
602 602 )
603 603 )
604 604
605 605
606 606 def uploadchunks(fctx, fphid):
607 607 """upload large binary files as separate chunks.
608 608 Phab requests chunking over 8MiB, and splits into 4MiB chunks
609 609 """
610 610 ui = fctx.repo().ui
611 611 chunks = callconduit(ui, b'file.querychunks', {b'filePHID': fphid})
612 612 progress = ui.makeprogress(
613 613 _(b'uploading file chunks'), unit=_(b'chunks'), total=len(chunks)
614 614 )
615 615 for chunk in chunks:
616 616 progress.increment()
617 617 if chunk[b'complete']:
618 618 continue
619 619 bstart = int(chunk[b'byteStart'])
620 620 bend = int(chunk[b'byteEnd'])
621 621 callconduit(
622 622 ui,
623 623 b'file.uploadchunk',
624 624 {
625 625 b'filePHID': fphid,
626 626 b'byteStart': bstart,
627 627 b'data': base64.b64encode(fctx.data()[bstart:bend]),
628 628 b'dataEncoding': b'base64',
629 629 },
630 630 )
631 631 progress.complete()
632 632
633 633
634 634 def uploadfile(fctx):
635 635 """upload binary files to Phabricator"""
636 636 repo = fctx.repo()
637 637 ui = repo.ui
638 638 fname = fctx.path()
639 639 size = fctx.size()
640 640 fhash = pycompat.bytestr(hashlib.sha256(fctx.data()).hexdigest())
641 641
642 642 # an allocate call is required first to see if an upload is even required
643 643 # (Phab might already have it) and to determine if chunking is needed
644 644 allocateparams = {
645 645 b'name': fname,
646 646 b'contentLength': size,
647 647 b'contentHash': fhash,
648 648 }
649 649 filealloc = callconduit(ui, b'file.allocate', allocateparams)
650 650 fphid = filealloc[b'filePHID']
651 651
652 652 if filealloc[b'upload']:
653 653 ui.write(_(b'uploading %s\n') % bytes(fctx))
654 654 if not fphid:
655 655 uploadparams = {
656 656 b'name': fname,
657 657 b'data_base64': base64.b64encode(fctx.data()),
658 658 }
659 659 fphid = callconduit(ui, b'file.upload', uploadparams)
660 660 else:
661 661 uploadchunks(fctx, fphid)
662 662 else:
663 663 ui.debug(b'server already has %s\n' % bytes(fctx))
664 664
665 665 if not fphid:
666 666 raise error.Abort(b'Upload of %s failed.' % bytes(fctx))
667 667
668 668 return fphid
669 669
670 670
671 671 def addoldbinary(pchange, fctx, originalfname):
672 672 """add the metadata for the previous version of a binary file to the
673 673 phabchange for the new version
674 674 """
675 675 oldfctx = fctx.p1()[originalfname]
676 676 if fctx.cmp(oldfctx):
677 677 # Files differ, add the old one
678 678 pchange.metadata[b'old:file:size'] = oldfctx.size()
679 679 mimeguess, _enc = mimetypes.guess_type(
680 680 encoding.unifromlocal(oldfctx.path())
681 681 )
682 682 if mimeguess:
683 683 pchange.metadata[b'old:file:mime-type'] = pycompat.bytestr(
684 684 mimeguess
685 685 )
686 686 fphid = uploadfile(oldfctx)
687 687 pchange.metadata[b'old:binary-phid'] = fphid
688 688 else:
689 689 # If it's left as IMAGE/BINARY web UI might try to display it
690 690 pchange.fileType = DiffFileType.TEXT
691 691 pchange.copynewmetadatatoold()
692 692
693 693
694 694 def makebinary(pchange, fctx):
695 695 """populate the phabchange for a binary file"""
696 696 pchange.fileType = DiffFileType.BINARY
697 697 fphid = uploadfile(fctx)
698 698 pchange.metadata[b'new:binary-phid'] = fphid
699 699 pchange.metadata[b'new:file:size'] = fctx.size()
700 700 mimeguess, _enc = mimetypes.guess_type(encoding.unifromlocal(fctx.path()))
701 701 if mimeguess:
702 702 mimeguess = pycompat.bytestr(mimeguess)
703 703 pchange.metadata[b'new:file:mime-type'] = mimeguess
704 704 if mimeguess.startswith(b'image/'):
705 705 pchange.fileType = DiffFileType.IMAGE
706 706
707 707
708 708 # Copied from mercurial/patch.py
709 709 gitmode = {b'l': b'120000', b'x': b'100755', b'': b'100644'}
710 710
711 711
712 712 def notutf8(fctx):
713 713 """detect non-UTF-8 text files since Phabricator requires them to be marked
714 714 as binary
715 715 """
716 716 try:
717 717 fctx.data().decode('utf-8')
718 718 if fctx.parents():
719 719 fctx.p1().data().decode('utf-8')
720 720 return False
721 721 except UnicodeDecodeError:
722 722 fctx.repo().ui.write(
723 723 _(b'file %s detected as non-UTF-8, marked as binary\n')
724 724 % fctx.path()
725 725 )
726 726 return True
727 727
728 728
729 729 def addremoved(pdiff, ctx, removed):
730 730 """add removed files to the phabdiff. Shouldn't include moves"""
731 731 for fname in removed:
732 732 pchange = phabchange(
733 733 currentPath=fname, oldPath=fname, type=DiffChangeType.DELETE
734 734 )
735 735 pchange.addoldmode(gitmode[ctx.p1()[fname].flags()])
736 736 fctx = ctx.p1()[fname]
737 737 if not (fctx.isbinary() or notutf8(fctx)):
738 738 maketext(pchange, ctx, fname)
739 739
740 740 pdiff.addchange(pchange)
741 741
742 742
743 743 def addmodified(pdiff, ctx, modified):
744 744 """add modified files to the phabdiff"""
745 745 for fname in modified:
746 746 fctx = ctx[fname]
747 747 pchange = phabchange(currentPath=fname, oldPath=fname)
748 748 filemode = gitmode[ctx[fname].flags()]
749 749 originalmode = gitmode[ctx.p1()[fname].flags()]
750 750 if filemode != originalmode:
751 751 pchange.addoldmode(originalmode)
752 752 pchange.addnewmode(filemode)
753 753
754 754 if fctx.isbinary() or notutf8(fctx):
755 755 makebinary(pchange, fctx)
756 756 addoldbinary(pchange, fctx, fname)
757 757 else:
758 758 maketext(pchange, ctx, fname)
759 759
760 760 pdiff.addchange(pchange)
761 761
762 762
763 763 def addadded(pdiff, ctx, added, removed):
764 764 """add file adds to the phabdiff, both new files and copies/moves"""
765 765 # Keep track of files that've been recorded as moved/copied, so if there are
766 766 # additional copies we can mark them (moves get removed from removed)
767 767 copiedchanges = {}
768 768 movedchanges = {}
769 769 for fname in added:
770 770 fctx = ctx[fname]
771 771 pchange = phabchange(currentPath=fname)
772 772
773 773 filemode = gitmode[ctx[fname].flags()]
774 774 renamed = fctx.renamed()
775 775
776 776 if renamed:
777 777 originalfname = renamed[0]
778 778 originalmode = gitmode[ctx.p1()[originalfname].flags()]
779 779 pchange.oldPath = originalfname
780 780
781 781 if originalfname in removed:
782 782 origpchange = phabchange(
783 783 currentPath=originalfname,
784 784 oldPath=originalfname,
785 785 type=DiffChangeType.MOVE_AWAY,
786 786 awayPaths=[fname],
787 787 )
788 788 movedchanges[originalfname] = origpchange
789 789 removed.remove(originalfname)
790 790 pchange.type = DiffChangeType.MOVE_HERE
791 791 elif originalfname in movedchanges:
792 792 movedchanges[originalfname].type = DiffChangeType.MULTICOPY
793 793 movedchanges[originalfname].awayPaths.append(fname)
794 794 pchange.type = DiffChangeType.COPY_HERE
795 795 else: # pure copy
796 796 if originalfname not in copiedchanges:
797 797 origpchange = phabchange(
798 798 currentPath=originalfname, type=DiffChangeType.COPY_AWAY
799 799 )
800 800 copiedchanges[originalfname] = origpchange
801 801 else:
802 802 origpchange = copiedchanges[originalfname]
803 803 origpchange.awayPaths.append(fname)
804 804 pchange.type = DiffChangeType.COPY_HERE
805 805
806 806 if filemode != originalmode:
807 807 pchange.addoldmode(originalmode)
808 808 pchange.addnewmode(filemode)
809 809 else: # Brand-new file
810 810 pchange.addnewmode(gitmode[fctx.flags()])
811 811 pchange.type = DiffChangeType.ADD
812 812
813 813 if fctx.isbinary() or notutf8(fctx):
814 814 makebinary(pchange, fctx)
815 815 if renamed:
816 816 addoldbinary(pchange, fctx, originalfname)
817 817 else:
818 818 maketext(pchange, ctx, fname)
819 819
820 820 pdiff.addchange(pchange)
821 821
822 822 for _path, copiedchange in copiedchanges.items():
823 823 pdiff.addchange(copiedchange)
824 824 for _path, movedchange in movedchanges.items():
825 825 pdiff.addchange(movedchange)
826 826
827 827
828 828 def creatediff(ctx):
829 829 """create a Differential Diff"""
830 830 repo = ctx.repo()
831 831 repophid = getrepophid(repo)
832 832 # Create a "Differential Diff" via "differential.creatediff" API
833 833 pdiff = phabdiff(
834 834 sourceControlBaseRevision=b'%s' % ctx.p1().hex(),
835 835 branch=b'%s' % ctx.branch(),
836 836 )
837 837 modified, added, removed, _d, _u, _i, _c = ctx.p1().status(ctx)
838 838 # addadded will remove moved files from removed, so addremoved won't get
839 839 # them
840 840 addadded(pdiff, ctx, added, removed)
841 841 addmodified(pdiff, ctx, modified)
842 842 addremoved(pdiff, ctx, removed)
843 843 if repophid:
844 844 pdiff.repositoryPHID = repophid
845 845 diff = callconduit(
846 846 repo.ui,
847 847 b'differential.creatediff',
848 848 pycompat.byteskwargs(attr.asdict(pdiff)),
849 849 )
850 850 if not diff:
851 851 raise error.Abort(_(b'cannot create diff for %s') % ctx)
852 852 return diff
853 853
854 854
855 855 def writediffproperties(ctx, diff):
856 856 """write metadata to diff so patches could be applied losslessly"""
857 857 # creatediff returns with a diffid but query returns with an id
858 858 diffid = diff.get(b'diffid', diff.get(b'id'))
859 859 params = {
860 860 b'diff_id': diffid,
861 861 b'name': b'hg:meta',
862 862 b'data': templatefilters.json(
863 863 {
864 864 b'user': ctx.user(),
865 865 b'date': b'%d %d' % ctx.date(),
866 866 b'branch': ctx.branch(),
867 867 b'node': ctx.hex(),
868 868 b'parent': ctx.p1().hex(),
869 869 }
870 870 ),
871 871 }
872 872 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
873 873
874 874 params = {
875 875 b'diff_id': diffid,
876 876 b'name': b'local:commits',
877 877 b'data': templatefilters.json(
878 878 {
879 879 ctx.hex(): {
880 880 b'author': stringutil.person(ctx.user()),
881 881 b'authorEmail': stringutil.email(ctx.user()),
882 882 b'time': int(ctx.date()[0]),
883 883 b'commit': ctx.hex(),
884 884 b'parents': [ctx.p1().hex()],
885 885 b'branch': ctx.branch(),
886 886 },
887 887 }
888 888 ),
889 889 }
890 890 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
891 891
892 892
893 893 def createdifferentialrevision(
894 894 ctx,
895 895 revid=None,
896 896 parentrevphid=None,
897 897 oldnode=None,
898 898 olddiff=None,
899 899 actions=None,
900 900 comment=None,
901 901 ):
902 902 """create or update a Differential Revision
903 903
904 904 If revid is None, create a new Differential Revision, otherwise update
905 905 revid. If parentrevphid is not None, set it as a dependency.
906 906
907 907 If oldnode is not None, check if the patch content (without commit message
908 908 and metadata) has changed before creating another diff.
909 909
910 910 If actions is not None, they will be appended to the transaction.
911 911 """
912 912 repo = ctx.repo()
913 913 if oldnode:
914 914 diffopts = mdiff.diffopts(git=True, context=32767)
915 915 oldctx = repo.unfiltered()[oldnode]
916 916 neednewdiff = getdiff(ctx, diffopts) != getdiff(oldctx, diffopts)
917 917 else:
918 918 neednewdiff = True
919 919
920 920 transactions = []
921 921 if neednewdiff:
922 922 diff = creatediff(ctx)
923 923 transactions.append({b'type': b'update', b'value': diff[b'phid']})
924 924 if comment:
925 925 transactions.append({b'type': b'comment', b'value': comment})
926 926 else:
927 927 # Even if we don't need to upload a new diff because the patch content
928 928 # does not change. We might still need to update its metadata so
929 929 # pushers could know the correct node metadata.
930 930 assert olddiff
931 931 diff = olddiff
932 932 writediffproperties(ctx, diff)
933 933
934 934 # Set the parent Revision every time, so commit re-ordering is picked-up
935 935 if parentrevphid:
936 936 transactions.append(
937 937 {b'type': b'parents.set', b'value': [parentrevphid]}
938 938 )
939 939
940 940 if actions:
941 941 transactions += actions
942 942
943 943 # Parse commit message and update related fields.
944 944 desc = ctx.description()
945 945 info = callconduit(
946 946 repo.ui, b'differential.parsecommitmessage', {b'corpus': desc}
947 947 )
948 948 for k, v in info[b'fields'].items():
949 949 if k in [b'title', b'summary', b'testPlan']:
950 950 transactions.append({b'type': k, b'value': v})
951 951
952 952 params = {b'transactions': transactions}
953 953 if revid is not None:
954 954 # Update an existing Differential Revision
955 955 params[b'objectIdentifier'] = revid
956 956
957 957 revision = callconduit(repo.ui, b'differential.revision.edit', params)
958 958 if not revision:
959 959 raise error.Abort(_(b'cannot create revision for %s') % ctx)
960 960
961 961 return revision, diff
962 962
963 963
964 964 def userphids(repo, names):
965 965 """convert user names to PHIDs"""
966 966 names = [name.lower() for name in names]
967 967 query = {b'constraints': {b'usernames': names}}
968 968 result = callconduit(repo.ui, b'user.search', query)
969 969 # username not found is not an error of the API. So check if we have missed
970 970 # some names here.
971 971 data = result[b'data']
972 972 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
973 973 unresolved = set(names) - resolved
974 974 if unresolved:
975 975 raise error.Abort(
976 976 _(b'unknown username: %s') % b' '.join(sorted(unresolved))
977 977 )
978 978 return [entry[b'phid'] for entry in data]
979 979
980 980
981 981 @vcrcommand(
982 982 b'phabsend',
983 983 [
984 984 (b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
985 985 (b'', b'amend', True, _(b'update commit messages')),
986 986 (b'', b'reviewer', [], _(b'specify reviewers')),
987 987 (b'', b'blocker', [], _(b'specify blocking reviewers')),
988 988 (
989 989 b'm',
990 990 b'comment',
991 991 b'',
992 992 _(b'add a comment to Revisions with new/updated Diffs'),
993 993 ),
994 994 (b'', b'confirm', None, _(b'ask for confirmation before sending')),
995 995 ],
996 996 _(b'REV [OPTIONS]'),
997 997 helpcategory=command.CATEGORY_IMPORT_EXPORT,
998 998 )
999 999 def phabsend(ui, repo, *revs, **opts):
1000 1000 """upload changesets to Phabricator
1001 1001
1002 1002 If there are multiple revisions specified, they will be send as a stack
1003 1003 with a linear dependencies relationship using the order specified by the
1004 1004 revset.
1005 1005
1006 1006 For the first time uploading changesets, local tags will be created to
1007 1007 maintain the association. After the first time, phabsend will check
1008 1008 obsstore and tags information so it can figure out whether to update an
1009 1009 existing Differential Revision, or create a new one.
1010 1010
1011 1011 If --amend is set, update commit messages so they have the
1012 1012 ``Differential Revision`` URL, remove related tags. This is similar to what
1013 1013 arcanist will do, and is more desired in author-push workflows. Otherwise,
1014 1014 use local tags to record the ``Differential Revision`` association.
1015 1015
1016 1016 The --confirm option lets you confirm changesets before sending them. You
1017 1017 can also add following to your configuration file to make it default
1018 1018 behaviour::
1019 1019
1020 1020 [phabsend]
1021 1021 confirm = true
1022 1022
1023 1023 phabsend will check obsstore and the above association to decide whether to
1024 1024 update an existing Differential Revision, or create a new one.
1025 1025 """
1026 1026 opts = pycompat.byteskwargs(opts)
1027 1027 revs = list(revs) + opts.get(b'rev', [])
1028 1028 revs = scmutil.revrange(repo, revs)
1029 1029
1030 1030 if not revs:
1031 1031 raise error.Abort(_(b'phabsend requires at least one changeset'))
1032 1032 if opts.get(b'amend'):
1033 1033 cmdutil.checkunfinished(repo)
1034 1034
1035 1035 # {newnode: (oldnode, olddiff, olddrev}
1036 1036 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
1037 1037
1038 1038 confirm = ui.configbool(b'phabsend', b'confirm')
1039 1039 confirm |= bool(opts.get(b'confirm'))
1040 1040 if confirm:
1041 1041 confirmed = _confirmbeforesend(repo, revs, oldmap)
1042 1042 if not confirmed:
1043 1043 raise error.Abort(_(b'phabsend cancelled'))
1044 1044
1045 1045 actions = []
1046 1046 reviewers = opts.get(b'reviewer', [])
1047 1047 blockers = opts.get(b'blocker', [])
1048 1048 phids = []
1049 1049 if reviewers:
1050 1050 phids.extend(userphids(repo, reviewers))
1051 1051 if blockers:
1052 1052 phids.extend(
1053 1053 map(lambda phid: b'blocking(%s)' % phid, userphids(repo, blockers))
1054 1054 )
1055 1055 if phids:
1056 1056 actions.append({b'type': b'reviewers.add', b'value': phids})
1057 1057
1058 1058 drevids = [] # [int]
1059 1059 diffmap = {} # {newnode: diff}
1060 1060
1061 1061 # Send patches one by one so we know their Differential Revision PHIDs and
1062 1062 # can provide dependency relationship
1063 1063 lastrevphid = None
1064 1064 for rev in revs:
1065 1065 ui.debug(b'sending rev %d\n' % rev)
1066 1066 ctx = repo[rev]
1067 1067
1068 1068 # Get Differential Revision ID
1069 1069 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
1070 1070 if oldnode != ctx.node() or opts.get(b'amend'):
1071 1071 # Create or update Differential Revision
1072 1072 revision, diff = createdifferentialrevision(
1073 1073 ctx,
1074 1074 revid,
1075 1075 lastrevphid,
1076 1076 oldnode,
1077 1077 olddiff,
1078 1078 actions,
1079 1079 opts.get(b'comment'),
1080 1080 )
1081 1081 diffmap[ctx.node()] = diff
1082 1082 newrevid = int(revision[b'object'][b'id'])
1083 1083 newrevphid = revision[b'object'][b'phid']
1084 1084 if revid:
1085 1085 action = b'updated'
1086 1086 else:
1087 1087 action = b'created'
1088 1088
1089 1089 # Create a local tag to note the association, if commit message
1090 1090 # does not have it already
1091 1091 m = _differentialrevisiondescre.search(ctx.description())
1092 1092 if not m or int(m.group(r'id')) != newrevid:
1093 1093 tagname = b'D%d' % newrevid
1094 1094 tags.tag(
1095 1095 repo,
1096 1096 tagname,
1097 1097 ctx.node(),
1098 1098 message=None,
1099 1099 user=None,
1100 1100 date=None,
1101 1101 local=True,
1102 1102 )
1103 1103 else:
1104 1104 # Nothing changed. But still set "newrevphid" so the next revision
1105 1105 # could depend on this one and "newrevid" for the summary line.
1106 1106 newrevphid = querydrev(repo, b'%d' % revid)[0][b'phid']
1107 1107 newrevid = revid
1108 1108 action = b'skipped'
1109 1109
1110 1110 actiondesc = ui.label(
1111 1111 {
1112 1112 b'created': _(b'created'),
1113 1113 b'skipped': _(b'skipped'),
1114 1114 b'updated': _(b'updated'),
1115 1115 }[action],
1116 1116 b'phabricator.action.%s' % action,
1117 1117 )
1118 1118 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
1119 1119 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
1120 1120 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
1121 1121 ui.write(
1122 1122 _(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc, desc)
1123 1123 )
1124 1124 drevids.append(newrevid)
1125 1125 lastrevphid = newrevphid
1126 1126
1127 1127 # Update commit messages and remove tags
1128 1128 if opts.get(b'amend'):
1129 1129 unfi = repo.unfiltered()
1130 1130 drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
1131 1131 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
1132 1132 wnode = unfi[b'.'].node()
1133 1133 mapping = {} # {oldnode: [newnode]}
1134 1134 for i, rev in enumerate(revs):
1135 1135 old = unfi[rev]
1136 1136 drevid = drevids[i]
1137 1137 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
1138 1138 newdesc = getdescfromdrev(drev)
1139 1139 # Make sure commit message contain "Differential Revision"
1140 1140 if old.description() != newdesc:
1141 1141 if old.phase() == phases.public:
1142 1142 ui.warn(
1143 1143 _(b"warning: not updating public commit %s\n")
1144 1144 % scmutil.formatchangeid(old)
1145 1145 )
1146 1146 continue
1147 1147 parents = [
1148 1148 mapping.get(old.p1().node(), (old.p1(),))[0],
1149 1149 mapping.get(old.p2().node(), (old.p2(),))[0],
1150 1150 ]
1151 1151 new = context.metadataonlyctx(
1152 1152 repo,
1153 1153 old,
1154 1154 parents=parents,
1155 1155 text=newdesc,
1156 1156 user=old.user(),
1157 1157 date=old.date(),
1158 1158 extra=old.extra(),
1159 1159 )
1160 1160
1161 1161 newnode = new.commit()
1162 1162
1163 1163 mapping[old.node()] = [newnode]
1164 1164 # Update diff property
1165 1165 # If it fails just warn and keep going, otherwise the DREV
1166 1166 # associations will be lost
1167 1167 try:
1168 1168 writediffproperties(unfi[newnode], diffmap[old.node()])
1169 1169 except util.urlerr.urlerror:
1170 1170 ui.warnnoi18n(
1171 1171 b'Failed to update metadata for D%d\n' % drevid
1172 1172 )
1173 1173 # Remove local tags since it's no longer necessary
1174 1174 tagname = b'D%d' % drevid
1175 1175 if tagname in repo.tags():
1176 1176 tags.tag(
1177 1177 repo,
1178 1178 tagname,
1179 1179 nullid,
1180 1180 message=None,
1181 1181 user=None,
1182 1182 date=None,
1183 1183 local=True,
1184 1184 )
1185 1185 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
1186 1186 if wnode in mapping:
1187 1187 unfi.setparents(mapping[wnode][0])
1188 1188
1189 1189
1190 1190 # Map from "hg:meta" keys to header understood by "hg import". The order is
1191 1191 # consistent with "hg export" output.
1192 1192 _metanamemap = util.sortdict(
1193 1193 [
1194 1194 (b'user', b'User'),
1195 1195 (b'date', b'Date'),
1196 1196 (b'branch', b'Branch'),
1197 1197 (b'node', b'Node ID'),
1198 1198 (b'parent', b'Parent '),
1199 1199 ]
1200 1200 )
1201 1201
1202 1202
1203 1203 def _confirmbeforesend(repo, revs, oldmap):
1204 1204 url, token = readurltoken(repo.ui)
1205 1205 ui = repo.ui
1206 1206 for rev in revs:
1207 1207 ctx = repo[rev]
1208 1208 desc = ctx.description().splitlines()[0]
1209 1209 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
1210 1210 if drevid:
1211 1211 drevdesc = ui.label(b'D%d' % drevid, b'phabricator.drev')
1212 1212 else:
1213 1213 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
1214 1214
1215 1215 ui.write(
1216 1216 _(b'%s - %s: %s\n')
1217 1217 % (
1218 1218 drevdesc,
1219 1219 ui.label(bytes(ctx), b'phabricator.node'),
1220 1220 ui.label(desc, b'phabricator.desc'),
1221 1221 )
1222 1222 )
1223 1223
1224 1224 if ui.promptchoice(
1225 1225 _(b'Send the above changes to %s (yn)?$$ &Yes $$ &No') % url
1226 1226 ):
1227 1227 return False
1228 1228
1229 1229 return True
1230 1230
1231 1231
1232 1232 _knownstatusnames = {
1233 1233 b'accepted',
1234 1234 b'needsreview',
1235 1235 b'needsrevision',
1236 1236 b'closed',
1237 1237 b'abandoned',
1238 1238 }
1239 1239
1240 1240
1241 1241 def _getstatusname(drev):
1242 1242 """get normalized status name from a Differential Revision"""
1243 1243 return drev[b'statusName'].replace(b' ', b'').lower()
1244 1244
1245 1245
1246 1246 # Small language to specify differential revisions. Support symbols: (), :X,
1247 1247 # +, and -.
1248 1248
1249 1249 _elements = {
1250 1250 # token-type: binding-strength, primary, prefix, infix, suffix
1251 1251 b'(': (12, None, (b'group', 1, b')'), None, None),
1252 1252 b':': (8, None, (b'ancestors', 8), None, None),
1253 1253 b'&': (5, None, None, (b'and_', 5), None),
1254 1254 b'+': (4, None, None, (b'add', 4), None),
1255 1255 b'-': (4, None, None, (b'sub', 4), None),
1256 1256 b')': (0, None, None, None, None),
1257 1257 b'symbol': (0, b'symbol', None, None, None),
1258 1258 b'end': (0, None, None, None, None),
1259 1259 }
1260 1260
1261 1261
1262 1262 def _tokenize(text):
1263 1263 view = memoryview(text) # zero-copy slice
1264 1264 special = b'():+-& '
1265 1265 pos = 0
1266 1266 length = len(text)
1267 1267 while pos < length:
1268 1268 symbol = b''.join(
1269 1269 itertools.takewhile(
1270 1270 lambda ch: ch not in special, pycompat.iterbytestr(view[pos:])
1271 1271 )
1272 1272 )
1273 1273 if symbol:
1274 1274 yield (b'symbol', symbol, pos)
1275 1275 pos += len(symbol)
1276 1276 else: # special char, ignore space
1277 1277 if text[pos : pos + 1] != b' ':
1278 1278 yield (text[pos : pos + 1], None, pos)
1279 1279 pos += 1
1280 1280 yield (b'end', None, pos)
1281 1281
1282 1282
1283 1283 def _parse(text):
1284 1284 tree, pos = parser.parser(_elements).parse(_tokenize(text))
1285 1285 if pos != len(text):
1286 1286 raise error.ParseError(b'invalid token', pos)
1287 1287 return tree
1288 1288
1289 1289
1290 1290 def _parsedrev(symbol):
1291 1291 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
1292 1292 if symbol.startswith(b'D') and symbol[1:].isdigit():
1293 1293 return int(symbol[1:])
1294 1294 if symbol.isdigit():
1295 1295 return int(symbol)
1296 1296
1297 1297
1298 1298 def _prefetchdrevs(tree):
1299 1299 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
1300 1300 drevs = set()
1301 1301 ancestordrevs = set()
1302 1302 op = tree[0]
1303 1303 if op == b'symbol':
1304 1304 r = _parsedrev(tree[1])
1305 1305 if r:
1306 1306 drevs.add(r)
1307 1307 elif op == b'ancestors':
1308 1308 r, a = _prefetchdrevs(tree[1])
1309 1309 drevs.update(r)
1310 1310 ancestordrevs.update(r)
1311 1311 ancestordrevs.update(a)
1312 1312 else:
1313 1313 for t in tree[1:]:
1314 1314 r, a = _prefetchdrevs(t)
1315 1315 drevs.update(r)
1316 1316 ancestordrevs.update(a)
1317 1317 return drevs, ancestordrevs
1318 1318
1319 1319
1320 1320 def querydrev(repo, spec):
1321 1321 """return a list of "Differential Revision" dicts
1322 1322
1323 1323 spec is a string using a simple query language, see docstring in phabread
1324 1324 for details.
1325 1325
1326 1326 A "Differential Revision dict" looks like:
1327 1327
1328 1328 {
1329 1329 "id": "2",
1330 1330 "phid": "PHID-DREV-672qvysjcczopag46qty",
1331 1331 "title": "example",
1332 1332 "uri": "https://phab.example.com/D2",
1333 1333 "dateCreated": "1499181406",
1334 1334 "dateModified": "1499182103",
1335 1335 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
1336 1336 "status": "0",
1337 1337 "statusName": "Needs Review",
1338 1338 "properties": [],
1339 1339 "branch": null,
1340 1340 "summary": "",
1341 1341 "testPlan": "",
1342 1342 "lineCount": "2",
1343 1343 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
1344 1344 "diffs": [
1345 1345 "3",
1346 1346 "4",
1347 1347 ],
1348 1348 "commits": [],
1349 1349 "reviewers": [],
1350 1350 "ccs": [],
1351 1351 "hashes": [],
1352 1352 "auxiliary": {
1353 1353 "phabricator:projects": [],
1354 1354 "phabricator:depends-on": [
1355 1355 "PHID-DREV-gbapp366kutjebt7agcd"
1356 1356 ]
1357 1357 },
1358 1358 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
1359 1359 "sourcePath": null
1360 1360 }
1361 1361 """
1362 1362
1363 1363 def fetch(params):
1364 1364 """params -> single drev or None"""
1365 1365 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
1366 1366 if key in prefetched:
1367 1367 return prefetched[key]
1368 1368 drevs = callconduit(repo.ui, b'differential.query', params)
1369 1369 # Fill prefetched with the result
1370 1370 for drev in drevs:
1371 1371 prefetched[drev[b'phid']] = drev
1372 1372 prefetched[int(drev[b'id'])] = drev
1373 1373 if key not in prefetched:
1374 1374 raise error.Abort(
1375 1375 _(b'cannot get Differential Revision %r') % params
1376 1376 )
1377 1377 return prefetched[key]
1378 1378
1379 1379 def getstack(topdrevids):
1380 1380 """given a top, get a stack from the bottom, [id] -> [id]"""
1381 1381 visited = set()
1382 1382 result = []
1383 1383 queue = [{b'ids': [i]} for i in topdrevids]
1384 1384 while queue:
1385 1385 params = queue.pop()
1386 1386 drev = fetch(params)
1387 1387 if drev[b'id'] in visited:
1388 1388 continue
1389 1389 visited.add(drev[b'id'])
1390 1390 result.append(int(drev[b'id']))
1391 1391 auxiliary = drev.get(b'auxiliary', {})
1392 1392 depends = auxiliary.get(b'phabricator:depends-on', [])
1393 1393 for phid in depends:
1394 1394 queue.append({b'phids': [phid]})
1395 1395 result.reverse()
1396 1396 return smartset.baseset(result)
1397 1397
1398 1398 # Initialize prefetch cache
1399 1399 prefetched = {} # {id or phid: drev}
1400 1400
1401 1401 tree = _parse(spec)
1402 1402 drevs, ancestordrevs = _prefetchdrevs(tree)
1403 1403
1404 1404 # developer config: phabricator.batchsize
1405 1405 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
1406 1406
1407 1407 # Prefetch Differential Revisions in batch
1408 1408 tofetch = set(drevs)
1409 1409 for r in ancestordrevs:
1410 1410 tofetch.update(range(max(1, r - batchsize), r + 1))
1411 1411 if drevs:
1412 1412 fetch({b'ids': list(tofetch)})
1413 1413 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
1414 1414
1415 1415 # Walk through the tree, return smartsets
1416 1416 def walk(tree):
1417 1417 op = tree[0]
1418 1418 if op == b'symbol':
1419 1419 drev = _parsedrev(tree[1])
1420 1420 if drev:
1421 1421 return smartset.baseset([drev])
1422 1422 elif tree[1] in _knownstatusnames:
1423 1423 drevs = [
1424 1424 r
1425 1425 for r in validids
1426 1426 if _getstatusname(prefetched[r]) == tree[1]
1427 1427 ]
1428 1428 return smartset.baseset(drevs)
1429 1429 else:
1430 1430 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
1431 1431 elif op in {b'and_', b'add', b'sub'}:
1432 1432 assert len(tree) == 3
1433 1433 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
1434 1434 elif op == b'group':
1435 1435 return walk(tree[1])
1436 1436 elif op == b'ancestors':
1437 1437 return getstack(walk(tree[1]))
1438 1438 else:
1439 1439 raise error.ProgrammingError(b'illegal tree: %r' % tree)
1440 1440
1441 1441 return [prefetched[r] for r in walk(tree)]
1442 1442
1443 1443
1444 1444 def getdescfromdrev(drev):
1445 1445 """get description (commit message) from "Differential Revision"
1446 1446
1447 1447 This is similar to differential.getcommitmessage API. But we only care
1448 1448 about limited fields: title, summary, test plan, and URL.
1449 1449 """
1450 1450 title = drev[b'title']
1451 1451 summary = drev[b'summary'].rstrip()
1452 1452 testplan = drev[b'testPlan'].rstrip()
1453 1453 if testplan:
1454 1454 testplan = b'Test Plan:\n%s' % testplan
1455 1455 uri = b'Differential Revision: %s' % drev[b'uri']
1456 1456 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
1457 1457
1458 1458
1459 1459 def getdiffmeta(diff):
1460 1460 """get commit metadata (date, node, user, p1) from a diff object
1461 1461
1462 1462 The metadata could be "hg:meta", sent by phabsend, like:
1463 1463
1464 1464 "properties": {
1465 1465 "hg:meta": {
1466 1466 "date": "1499571514 25200",
1467 1467 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
1468 1468 "user": "Foo Bar <foo@example.com>",
1469 1469 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
1470 1470 }
1471 1471 }
1472 1472
1473 1473 Or converted from "local:commits", sent by "arc", like:
1474 1474
1475 1475 "properties": {
1476 1476 "local:commits": {
1477 1477 "98c08acae292b2faf60a279b4189beb6cff1414d": {
1478 1478 "author": "Foo Bar",
1479 1479 "time": 1499546314,
1480 1480 "branch": "default",
1481 1481 "tag": "",
1482 1482 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
1483 1483 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
1484 1484 "local": "1000",
1485 1485 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
1486 1486 "summary": "...",
1487 1487 "message": "...",
1488 1488 "authorEmail": "foo@example.com"
1489 1489 }
1490 1490 }
1491 1491 }
1492 1492
1493 1493 Note: metadata extracted from "local:commits" will lose time zone
1494 1494 information.
1495 1495 """
1496 1496 props = diff.get(b'properties') or {}
1497 1497 meta = props.get(b'hg:meta')
1498 1498 if not meta:
1499 1499 if props.get(b'local:commits'):
1500 1500 commit = sorted(props[b'local:commits'].values())[0]
1501 1501 meta = {}
1502 1502 if b'author' in commit and b'authorEmail' in commit:
1503 1503 meta[b'user'] = b'%s <%s>' % (
1504 1504 commit[b'author'],
1505 1505 commit[b'authorEmail'],
1506 1506 )
1507 1507 if b'time' in commit:
1508 1508 meta[b'date'] = b'%d 0' % int(commit[b'time'])
1509 1509 if b'branch' in commit:
1510 1510 meta[b'branch'] = commit[b'branch']
1511 1511 node = commit.get(b'commit', commit.get(b'rev'))
1512 1512 if node:
1513 1513 meta[b'node'] = node
1514 1514 if len(commit.get(b'parents', ())) >= 1:
1515 1515 meta[b'parent'] = commit[b'parents'][0]
1516 1516 else:
1517 1517 meta = {}
1518 1518 if b'date' not in meta and b'dateCreated' in diff:
1519 1519 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
1520 1520 if b'branch' not in meta and diff.get(b'branch'):
1521 1521 meta[b'branch'] = diff[b'branch']
1522 1522 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
1523 1523 meta[b'parent'] = diff[b'sourceControlBaseRevision']
1524 1524 return meta
1525 1525
1526 1526
1527 1527 def readpatch(repo, drevs, write):
1528 1528 """generate plain-text patch readable by 'hg import'
1529 1529
1530 1530 write is usually ui.write. drevs is what "querydrev" returns, results of
1531 1531 "differential.query".
1532 1532 """
1533 1533 # Prefetch hg:meta property for all diffs
1534 1534 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
1535 1535 diffs = callconduit(repo.ui, b'differential.querydiffs', {b'ids': diffids})
1536 1536
1537 1537 # Generate patch for each drev
1538 1538 for drev in drevs:
1539 1539 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
1540 1540
1541 1541 diffid = max(int(v) for v in drev[b'diffs'])
1542 1542 body = callconduit(
1543 1543 repo.ui, b'differential.getrawdiff', {b'diffID': diffid}
1544 1544 )
1545 1545 desc = getdescfromdrev(drev)
1546 1546 header = b'# HG changeset patch\n'
1547 1547
1548 1548 # Try to preserve metadata from hg:meta property. Write hg patch
1549 1549 # headers that can be read by the "import" command. See patchheadermap
1550 1550 # and extract in mercurial/patch.py for supported headers.
1551 1551 meta = getdiffmeta(diffs[b'%d' % diffid])
1552 1552 for k in _metanamemap.keys():
1553 1553 if k in meta:
1554 1554 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
1555 1555
1556 1556 content = b'%s%s\n%s' % (header, desc, body)
1557 1557 write(content)
1558 1558
1559 1559
1560 1560 @vcrcommand(
1561 1561 b'phabread',
1562 1562 [(b'', b'stack', False, _(b'read dependencies'))],
1563 1563 _(b'DREVSPEC [OPTIONS]'),
1564 1564 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1565 1565 )
1566 1566 def phabread(ui, repo, spec, **opts):
1567 1567 """print patches from Phabricator suitable for importing
1568 1568
1569 1569 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
1570 1570 the number ``123``. It could also have common operators like ``+``, ``-``,
1571 1571 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
1572 1572 select a stack.
1573 1573
1574 1574 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
1575 1575 could be used to filter patches by status. For performance reason, they
1576 1576 only represent a subset of non-status selections and cannot be used alone.
1577 1577
1578 1578 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
1579 1579 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
1580 1580 stack up to D9.
1581 1581
1582 1582 If --stack is given, follow dependencies information and read all patches.
1583 1583 It is equivalent to the ``:`` operator.
1584 1584 """
1585 1585 opts = pycompat.byteskwargs(opts)
1586 1586 if opts.get(b'stack'):
1587 1587 spec = b':(%s)' % spec
1588 1588 drevs = querydrev(repo, spec)
1589 1589 readpatch(repo, drevs, ui.write)
1590 1590
1591 1591
1592 1592 @vcrcommand(
1593 1593 b'phabupdate',
1594 1594 [
1595 1595 (b'', b'accept', False, _(b'accept revisions')),
1596 1596 (b'', b'reject', False, _(b'reject revisions')),
1597 1597 (b'', b'abandon', False, _(b'abandon revisions')),
1598 1598 (b'', b'reclaim', False, _(b'reclaim revisions')),
1599 1599 (b'm', b'comment', b'', _(b'comment on the last revision')),
1600 1600 ],
1601 1601 _(b'DREVSPEC [OPTIONS]'),
1602 1602 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1603 1603 )
1604 1604 def phabupdate(ui, repo, spec, **opts):
1605 1605 """update Differential Revision in batch
1606 1606
1607 1607 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
1608 1608 """
1609 1609 opts = pycompat.byteskwargs(opts)
1610 1610 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1611 1611 if len(flags) > 1:
1612 1612 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1613 1613
1614 1614 actions = []
1615 1615 for f in flags:
1616 1616 actions.append({b'type': f, b'value': True})
1617 1617
1618 1618 drevs = querydrev(repo, spec)
1619 1619 for i, drev in enumerate(drevs):
1620 1620 if i + 1 == len(drevs) and opts.get(b'comment'):
1621 1621 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1622 1622 if actions:
1623 1623 params = {
1624 1624 b'objectIdentifier': drev[b'phid'],
1625 1625 b'transactions': actions,
1626 1626 }
1627 1627 callconduit(ui, b'differential.revision.edit', params)
1628 1628
1629 1629
1630 1630 @eh.templatekeyword(b'phabreview', requires={b'ctx'})
1631 1631 def template_review(context, mapping):
1632 1632 """:phabreview: Object describing the review for this changeset.
1633 1633 Has attributes `url` and `id`.
1634 1634 """
1635 1635 ctx = context.resource(mapping, b'ctx')
1636 1636 m = _differentialrevisiondescre.search(ctx.description())
1637 1637 if m:
1638 1638 return templateutil.hybriddict(
1639 1639 {b'url': m.group(r'url'), b'id': b"D%s" % m.group(r'id'),}
1640 1640 )
1641 1641 else:
1642 1642 tags = ctx.repo().nodetags(ctx.node())
1643 1643 for t in tags:
1644 1644 if _differentialrevisiontagre.match(t):
1645 1645 url = ctx.repo().ui.config(b'phabricator', b'url')
1646 1646 if not url.endswith(b'/'):
1647 1647 url += b'/'
1648 1648 url += t
1649 1649
1650 1650 return templateutil.hybriddict({b'url': url, b'id': t,})
1651 1651 return None
@@ -1,454 +1,499 b''
1 1 # pycompat.py - portability shim for python 3
2 2 #
3 3 # This software may be used and distributed according to the terms of the
4 4 # GNU General Public License version 2 or any later version.
5 5
6 6 """Mercurial portability shim for python 3.
7 7
8 8 This contains aliases to hide python version-specific details from the core.
9 9 """
10 10
11 11 from __future__ import absolute_import
12 12
13 13 import getopt
14 14 import inspect
15 import json
15 16 import os
16 17 import shlex
17 18 import sys
18 19 import tempfile
19 20
20 21 ispy3 = sys.version_info[0] >= 3
21 22 ispypy = r'__pypy__' in sys.builtin_module_names
22 23
23 24 if not ispy3:
24 25 import cookielib
25 26 import cPickle as pickle
26 27 import httplib
27 28 import Queue as queue
28 29 import SocketServer as socketserver
29 30 import xmlrpclib
30 31
31 32 from .thirdparty.concurrent import futures
32 33
33 34 def future_set_exception_info(f, exc_info):
34 35 f.set_exception_info(*exc_info)
35 36
36 37
37 38 else:
38 39 import concurrent.futures as futures
39 40 import http.cookiejar as cookielib
40 41 import http.client as httplib
41 42 import pickle
42 43 import queue as queue
43 44 import socketserver
44 45 import xmlrpc.client as xmlrpclib
45 46
46 47 def future_set_exception_info(f, exc_info):
47 48 f.set_exception(exc_info[0])
48 49
49 50
50 51 def identity(a):
51 52 return a
52 53
53 54
54 55 def _rapply(f, xs):
55 56 if xs is None:
56 57 # assume None means non-value of optional data
57 58 return xs
58 59 if isinstance(xs, (list, set, tuple)):
59 60 return type(xs)(_rapply(f, x) for x in xs)
60 61 if isinstance(xs, dict):
61 62 return type(xs)((_rapply(f, k), _rapply(f, v)) for k, v in xs.items())
62 63 return f(xs)
63 64
64 65
65 66 def rapply(f, xs):
66 67 """Apply function recursively to every item preserving the data structure
67 68
68 69 >>> def f(x):
69 70 ... return 'f(%s)' % x
70 71 >>> rapply(f, None) is None
71 72 True
72 73 >>> rapply(f, 'a')
73 74 'f(a)'
74 75 >>> rapply(f, {'a'}) == {'f(a)'}
75 76 True
76 77 >>> rapply(f, ['a', 'b', None, {'c': 'd'}, []])
77 78 ['f(a)', 'f(b)', None, {'f(c)': 'f(d)'}, []]
78 79
79 80 >>> xs = [object()]
80 81 >>> rapply(identity, xs) is xs
81 82 True
82 83 """
83 84 if f is identity:
84 85 # fast path mainly for py2
85 86 return xs
86 87 return _rapply(f, xs)
87 88
88 89
89 90 if ispy3:
90 91 import builtins
92 import codecs
91 93 import functools
92 94 import io
93 95 import struct
94 96
95 97 fsencode = os.fsencode
96 98 fsdecode = os.fsdecode
97 99 oscurdir = os.curdir.encode('ascii')
98 100 oslinesep = os.linesep.encode('ascii')
99 101 osname = os.name.encode('ascii')
100 102 ospathsep = os.pathsep.encode('ascii')
101 103 ospardir = os.pardir.encode('ascii')
102 104 ossep = os.sep.encode('ascii')
103 105 osaltsep = os.altsep
104 106 if osaltsep:
105 107 osaltsep = osaltsep.encode('ascii')
106 108
107 109 sysplatform = sys.platform.encode('ascii')
108 110 sysexecutable = sys.executable
109 111 if sysexecutable:
110 112 sysexecutable = os.fsencode(sysexecutable)
111 113 bytesio = io.BytesIO
112 114 # TODO deprecate stringio name, as it is a lie on Python 3.
113 115 stringio = bytesio
114 116
115 117 def maplist(*args):
116 118 return list(map(*args))
117 119
118 120 def rangelist(*args):
119 121 return list(range(*args))
120 122
121 123 def ziplist(*args):
122 124 return list(zip(*args))
123 125
124 126 rawinput = input
125 127 getargspec = inspect.getfullargspec
126 128
127 129 long = int
128 130
129 131 # TODO: .buffer might not exist if std streams were replaced; we'll need
130 132 # a silly wrapper to make a bytes stream backed by a unicode one.
131 133 stdin = sys.stdin.buffer
132 134 stdout = sys.stdout.buffer
133 135 stderr = sys.stderr.buffer
134 136
135 137 # Since Python 3 converts argv to wchar_t type by Py_DecodeLocale() on Unix,
136 138 # we can use os.fsencode() to get back bytes argv.
137 139 #
138 140 # https://hg.python.org/cpython/file/v3.5.1/Programs/python.c#l55
139 141 #
140 142 # TODO: On Windows, the native argv is wchar_t, so we'll need a different
141 143 # workaround to simulate the Python 2 (i.e. ANSI Win32 API) behavior.
142 144 if getattr(sys, 'argv', None) is not None:
143 145 sysargv = list(map(os.fsencode, sys.argv))
144 146
145 147 bytechr = struct.Struct(r'>B').pack
146 148 byterepr = b'%r'.__mod__
147 149
148 150 class bytestr(bytes):
149 151 """A bytes which mostly acts as a Python 2 str
150 152
151 153 >>> bytestr(), bytestr(bytearray(b'foo')), bytestr(u'ascii'), bytestr(1)
152 154 ('', 'foo', 'ascii', '1')
153 155 >>> s = bytestr(b'foo')
154 156 >>> assert s is bytestr(s)
155 157
156 158 __bytes__() should be called if provided:
157 159
158 160 >>> class bytesable(object):
159 161 ... def __bytes__(self):
160 162 ... return b'bytes'
161 163 >>> bytestr(bytesable())
162 164 'bytes'
163 165
164 166 There's no implicit conversion from non-ascii str as its encoding is
165 167 unknown:
166 168
167 169 >>> bytestr(chr(0x80)) # doctest: +ELLIPSIS
168 170 Traceback (most recent call last):
169 171 ...
170 172 UnicodeEncodeError: ...
171 173
172 174 Comparison between bytestr and bytes should work:
173 175
174 176 >>> assert bytestr(b'foo') == b'foo'
175 177 >>> assert b'foo' == bytestr(b'foo')
176 178 >>> assert b'f' in bytestr(b'foo')
177 179 >>> assert bytestr(b'f') in b'foo'
178 180
179 181 Sliced elements should be bytes, not integer:
180 182
181 183 >>> s[1], s[:2]
182 184 (b'o', b'fo')
183 185 >>> list(s), list(reversed(s))
184 186 ([b'f', b'o', b'o'], [b'o', b'o', b'f'])
185 187
186 188 As bytestr type isn't propagated across operations, you need to cast
187 189 bytes to bytestr explicitly:
188 190
189 191 >>> s = bytestr(b'foo').upper()
190 192 >>> t = bytestr(s)
191 193 >>> s[0], t[0]
192 194 (70, b'F')
193 195
194 196 Be careful to not pass a bytestr object to a function which expects
195 197 bytearray-like behavior.
196 198
197 199 >>> t = bytes(t) # cast to bytes
198 200 >>> assert type(t) is bytes
199 201 """
200 202
201 203 def __new__(cls, s=b''):
202 204 if isinstance(s, bytestr):
203 205 return s
204 206 if not isinstance(
205 207 s, (bytes, bytearray)
206 208 ) and not hasattr( # hasattr-py3-only
207 209 s, u'__bytes__'
208 210 ):
209 211 s = str(s).encode('ascii')
210 212 return bytes.__new__(cls, s)
211 213
212 214 def __getitem__(self, key):
213 215 s = bytes.__getitem__(self, key)
214 216 if not isinstance(s, bytes):
215 217 s = bytechr(s)
216 218 return s
217 219
218 220 def __iter__(self):
219 221 return iterbytestr(bytes.__iter__(self))
220 222
221 223 def __repr__(self):
222 224 return bytes.__repr__(self)[1:] # drop b''
223 225
224 226 def iterbytestr(s):
225 227 """Iterate bytes as if it were a str object of Python 2"""
226 228 return map(bytechr, s)
227 229
228 230 def maybebytestr(s):
229 231 """Promote bytes to bytestr"""
230 232 if isinstance(s, bytes):
231 233 return bytestr(s)
232 234 return s
233 235
234 236 def sysbytes(s):
235 237 """Convert an internal str (e.g. keyword, __doc__) back to bytes
236 238
237 239 This never raises UnicodeEncodeError, but only ASCII characters
238 240 can be round-trip by sysstr(sysbytes(s)).
239 241 """
240 242 return s.encode('utf-8')
241 243
242 244 def sysstr(s):
243 245 """Return a keyword str to be passed to Python functions such as
244 246 getattr() and str.encode()
245 247
246 248 This never raises UnicodeDecodeError. Non-ascii characters are
247 249 considered invalid and mapped to arbitrary but unique code points
248 250 such that 'sysstr(a) != sysstr(b)' for all 'a != b'.
249 251 """
250 252 if isinstance(s, builtins.str):
251 253 return s
252 254 return s.decode('latin-1')
253 255
254 256 def strurl(url):
255 257 """Converts a bytes url back to str"""
256 258 if isinstance(url, bytes):
257 259 return url.decode('ascii')
258 260 return url
259 261
260 262 def bytesurl(url):
261 263 """Converts a str url to bytes by encoding in ascii"""
262 264 if isinstance(url, str):
263 265 return url.encode('ascii')
264 266 return url
265 267
266 268 def raisewithtb(exc, tb):
267 269 """Raise exception with the given traceback"""
268 270 raise exc.with_traceback(tb)
269 271
270 272 def getdoc(obj):
271 273 """Get docstring as bytes; may be None so gettext() won't confuse it
272 274 with _('')"""
273 275 doc = getattr(obj, '__doc__', None)
274 276 if doc is None:
275 277 return doc
276 278 return sysbytes(doc)
277 279
278 280 def _wrapattrfunc(f):
279 281 @functools.wraps(f)
280 282 def w(object, name, *args):
281 283 return f(object, sysstr(name), *args)
282 284
283 285 return w
284 286
285 287 # these wrappers are automagically imported by hgloader
286 288 delattr = _wrapattrfunc(builtins.delattr)
287 289 getattr = _wrapattrfunc(builtins.getattr)
288 290 hasattr = _wrapattrfunc(builtins.hasattr)
289 291 setattr = _wrapattrfunc(builtins.setattr)
290 292 xrange = builtins.range
291 293 unicode = str
292 294
293 295 def open(name, mode=b'r', buffering=-1, encoding=None):
294 296 return builtins.open(name, sysstr(mode), buffering, encoding)
295 297
296 298 safehasattr = _wrapattrfunc(builtins.hasattr)
297 299
298 300 def _getoptbwrapper(orig, args, shortlist, namelist):
299 301 """
300 302 Takes bytes arguments, converts them to unicode, pass them to
301 303 getopt.getopt(), convert the returned values back to bytes and then
302 304 return them for Python 3 compatibility as getopt.getopt() don't accepts
303 305 bytes on Python 3.
304 306 """
305 307 args = [a.decode('latin-1') for a in args]
306 308 shortlist = shortlist.decode('latin-1')
307 309 namelist = [a.decode('latin-1') for a in namelist]
308 310 opts, args = orig(args, shortlist, namelist)
309 311 opts = [(a[0].encode('latin-1'), a[1].encode('latin-1')) for a in opts]
310 312 args = [a.encode('latin-1') for a in args]
311 313 return opts, args
312 314
313 315 def strkwargs(dic):
314 316 """
315 317 Converts the keys of a python dictonary to str i.e. unicodes so that
316 318 they can be passed as keyword arguments as dictonaries with bytes keys
317 319 can't be passed as keyword arguments to functions on Python 3.
318 320 """
319 321 dic = dict((k.decode('latin-1'), v) for k, v in dic.items())
320 322 return dic
321 323
322 324 def byteskwargs(dic):
323 325 """
324 326 Converts keys of python dictonaries to bytes as they were converted to
325 327 str to pass that dictonary as a keyword argument on Python 3.
326 328 """
327 329 dic = dict((k.encode('latin-1'), v) for k, v in dic.items())
328 330 return dic
329 331
330 332 # TODO: handle shlex.shlex().
331 333 def shlexsplit(s, comments=False, posix=True):
332 334 """
333 335 Takes bytes argument, convert it to str i.e. unicodes, pass that into
334 336 shlex.split(), convert the returned value to bytes and return that for
335 337 Python 3 compatibility as shelx.split() don't accept bytes on Python 3.
336 338 """
337 339 ret = shlex.split(s.decode('latin-1'), comments, posix)
338 340 return [a.encode('latin-1') for a in ret]
339 341
340 342 iteritems = lambda x: x.items()
341 343 itervalues = lambda x: x.values()
342 344
345 # Python 3.5's json.load and json.loads require str. We polyfill its
346 # code for detecting encoding from bytes.
347 if sys.version_info[0:2] < (3, 6):
348
349 def _detect_encoding(b):
350 bstartswith = b.startswith
351 if bstartswith((codecs.BOM_UTF32_BE, codecs.BOM_UTF32_LE)):
352 return 'utf-32'
353 if bstartswith((codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE)):
354 return 'utf-16'
355 if bstartswith(codecs.BOM_UTF8):
356 return 'utf-8-sig'
357
358 if len(b) >= 4:
359 if not b[0]:
360 # 00 00 -- -- - utf-32-be
361 # 00 XX -- -- - utf-16-be
362 return 'utf-16-be' if b[1] else 'utf-32-be'
363 if not b[1]:
364 # XX 00 00 00 - utf-32-le
365 # XX 00 00 XX - utf-16-le
366 # XX 00 XX -- - utf-16-le
367 return 'utf-16-le' if b[2] or b[3] else 'utf-32-le'
368 elif len(b) == 2:
369 if not b[0]:
370 # 00 XX - utf-16-be
371 return 'utf-16-be'
372 if not b[1]:
373 # XX 00 - utf-16-le
374 return 'utf-16-le'
375 # default
376 return 'utf-8'
377
378 def json_loads(s, *args, **kwargs):
379 if isinstance(s, (bytes, bytearray)):
380 s = s.decode(_detect_encoding(s), 'surrogatepass')
381
382 return json.loads(s, *args, **kwargs)
383
384 else:
385 json_loads = json.loads
386
343 387 else:
344 388 import cStringIO
345 389
346 390 xrange = xrange
347 391 unicode = unicode
348 392 bytechr = chr
349 393 byterepr = repr
350 394 bytestr = str
351 395 iterbytestr = iter
352 396 maybebytestr = identity
353 397 sysbytes = identity
354 398 sysstr = identity
355 399 strurl = identity
356 400 bytesurl = identity
357 401 open = open
358 402 delattr = delattr
359 403 getattr = getattr
360 404 hasattr = hasattr
361 405 setattr = setattr
362 406
363 407 # this can't be parsed on Python 3
364 408 exec(b'def raisewithtb(exc, tb):\n raise exc, None, tb\n')
365 409
366 410 def fsencode(filename):
367 411 """
368 412 Partial backport from os.py in Python 3, which only accepts bytes.
369 413 In Python 2, our paths should only ever be bytes, a unicode path
370 414 indicates a bug.
371 415 """
372 416 if isinstance(filename, str):
373 417 return filename
374 418 else:
375 419 raise TypeError(r"expect str, not %s" % type(filename).__name__)
376 420
377 421 # In Python 2, fsdecode() has a very chance to receive bytes. So it's
378 422 # better not to touch Python 2 part as it's already working fine.
379 423 fsdecode = identity
380 424
381 425 def getdoc(obj):
382 426 return getattr(obj, '__doc__', None)
383 427
384 428 _notset = object()
385 429
386 430 def safehasattr(thing, attr):
387 431 return getattr(thing, attr, _notset) is not _notset
388 432
389 433 def _getoptbwrapper(orig, args, shortlist, namelist):
390 434 return orig(args, shortlist, namelist)
391 435
392 436 strkwargs = identity
393 437 byteskwargs = identity
394 438
395 439 oscurdir = os.curdir
396 440 oslinesep = os.linesep
397 441 osname = os.name
398 442 ospathsep = os.pathsep
399 443 ospardir = os.pardir
400 444 ossep = os.sep
401 445 osaltsep = os.altsep
402 446 long = long
403 447 stdin = sys.stdin
404 448 stdout = sys.stdout
405 449 stderr = sys.stderr
406 450 if getattr(sys, 'argv', None) is not None:
407 451 sysargv = sys.argv
408 452 sysplatform = sys.platform
409 453 sysexecutable = sys.executable
410 454 shlexsplit = shlex.split
411 455 bytesio = cStringIO.StringIO
412 456 stringio = bytesio
413 457 maplist = map
414 458 rangelist = range
415 459 ziplist = zip
416 460 rawinput = raw_input
417 461 getargspec = inspect.getargspec
418 462 iteritems = lambda x: x.iteritems()
419 463 itervalues = lambda x: x.itervalues()
464 json_loads = json.loads
420 465
421 466 isjython = sysplatform.startswith(b'java')
422 467
423 468 isdarwin = sysplatform.startswith(b'darwin')
424 469 islinux = sysplatform.startswith(b'linux')
425 470 isposix = osname == b'posix'
426 471 iswindows = osname == b'nt'
427 472
428 473
429 474 def getoptb(args, shortlist, namelist):
430 475 return _getoptbwrapper(getopt.getopt, args, shortlist, namelist)
431 476
432 477
433 478 def gnugetoptb(args, shortlist, namelist):
434 479 return _getoptbwrapper(getopt.gnu_getopt, args, shortlist, namelist)
435 480
436 481
437 482 def mkdtemp(suffix=b'', prefix=b'tmp', dir=None):
438 483 return tempfile.mkdtemp(suffix, prefix, dir)
439 484
440 485
441 486 # text=True is not supported; use util.from/tonativeeol() instead
442 487 def mkstemp(suffix=b'', prefix=b'tmp', dir=None):
443 488 return tempfile.mkstemp(suffix, prefix, dir)
444 489
445 490
446 491 # mode must include 'b'ytes as encoding= is not supported
447 492 def namedtempfile(
448 493 mode=b'w+b', bufsize=-1, suffix=b'', prefix=b'tmp', dir=None, delete=True
449 494 ):
450 495 mode = sysstr(mode)
451 496 assert r'b' in mode
452 497 return tempfile.NamedTemporaryFile(
453 498 mode, bufsize, suffix=suffix, prefix=prefix, dir=dir, delete=delete
454 499 )
@@ -1,124 +1,124 b''
1 1 #!/usr/bin/env python
2 2
3 3 """This does HTTP GET requests given a host:port and path and returns
4 4 a subset of the headers plus the body of the result."""
5 5
6 6 from __future__ import absolute_import
7 7
8 8 import argparse
9 9 import json
10 10 import os
11 11 import sys
12 12
13 13 from mercurial import (
14 14 pycompat,
15 15 util,
16 16 )
17 17
18 18 httplib = util.httplib
19 19
20 20 try:
21 21 import msvcrt
22 22
23 23 msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
24 24 msvcrt.setmode(sys.stderr.fileno(), os.O_BINARY)
25 25 except ImportError:
26 26 pass
27 27
28 28 stdout = getattr(sys.stdout, 'buffer', sys.stdout)
29 29
30 30 parser = argparse.ArgumentParser()
31 31 parser.add_argument('--twice', action='store_true')
32 32 parser.add_argument('--headeronly', action='store_true')
33 33 parser.add_argument('--json', action='store_true')
34 34 parser.add_argument('--hgproto')
35 35 parser.add_argument(
36 36 '--requestheader',
37 37 nargs='*',
38 38 default=[],
39 39 help='Send an additional HTTP request header. Argument '
40 40 'value is <header>=<value>',
41 41 )
42 42 parser.add_argument('--bodyfile', help='Write HTTP response body to a file')
43 43 parser.add_argument('host')
44 44 parser.add_argument('path')
45 45 parser.add_argument('show', nargs='*')
46 46
47 47 args = parser.parse_args()
48 48
49 49 twice = args.twice
50 50 headeronly = args.headeronly
51 51 formatjson = args.json
52 52 hgproto = args.hgproto
53 53 requestheaders = args.requestheader
54 54
55 55 tag = None
56 56
57 57
58 58 def request(host, path, show):
59 59 assert not path.startswith('/'), path
60 60 global tag
61 61 headers = {}
62 62 if tag:
63 63 headers['If-None-Match'] = tag
64 64 if hgproto:
65 65 headers['X-HgProto-1'] = hgproto
66 66
67 67 for header in requestheaders:
68 68 key, value = header.split('=', 1)
69 69 headers[key] = value
70 70
71 71 conn = httplib.HTTPConnection(host)
72 72 conn.request("GET", '/' + path, None, headers)
73 73 response = conn.getresponse()
74 74 stdout.write(
75 75 b'%d %s\n' % (response.status, response.reason.encode('ascii'))
76 76 )
77 77 if show[:1] == ['-']:
78 78 show = sorted(
79 79 h for h, v in response.getheaders() if h.lower() not in show
80 80 )
81 81 for h in [h.lower() for h in show]:
82 82 if response.getheader(h, None) is not None:
83 83 stdout.write(
84 84 b"%s: %s\n"
85 85 % (h.encode('ascii'), response.getheader(h).encode('ascii'))
86 86 )
87 87 if not headeronly:
88 88 stdout.write(b'\n')
89 89 data = response.read()
90 90
91 91 if args.bodyfile:
92 92 bodyfh = open(args.bodyfile, 'wb')
93 93 else:
94 94 bodyfh = stdout
95 95
96 96 # Pretty print JSON. This also has the beneficial side-effect
97 97 # of verifying emitted JSON is well-formed.
98 98 if formatjson:
99 99 # json.dumps() will print trailing newlines. Eliminate them
100 100 # to make tests easier to write.
101 data = json.loads(data)
101 data = pycompat.json_loads(data)
102 102 lines = json.dumps(data, sort_keys=True, indent=2).splitlines()
103 103 for line in lines:
104 104 bodyfh.write(pycompat.sysbytes(line.rstrip()))
105 105 bodyfh.write(b'\n')
106 106 else:
107 107 bodyfh.write(data)
108 108
109 109 if args.bodyfile:
110 110 bodyfh.close()
111 111
112 112 if twice and response.getheader('ETag', None):
113 113 tag = response.getheader('ETag')
114 114
115 115 return response.status
116 116
117 117
118 118 status = request(args.host, args.path, args.show)
119 119 if twice:
120 120 status = request(args.host, args.path, args.show)
121 121
122 122 if 200 <= status <= 305:
123 123 sys.exit(0)
124 124 sys.exit(1)
General Comments 0
You need to be logged in to leave comments. Login now