##// END OF EJS Templates
phabricator: use .arcconfig for the callsign if not set locally (issue6243)...
Matt Harbison -
r44586:59b3fe1e default
parent child Browse files
Show More
@@ -1,1757 +1,1792 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 A "phabstatus" view for :hg:`show` is also provided; it displays status
15 15 information of Phabricator differentials associated with unfinished
16 16 changesets.
17 17
18 18 By default, Phabricator requires ``Test Plan`` which might prevent some
19 19 changeset from being sent. The requirement could be disabled by changing
20 20 ``differential.require-test-plan-field`` config server side.
21 21
22 22 Config::
23 23
24 24 [phabricator]
25 25 # Phabricator URL
26 26 url = https://phab.example.com/
27 27
28 28 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
29 29 # callsign is "FOO".
30 30 callsign = FOO
31 31
32 32 # curl command to use. If not set (default), use builtin HTTP library to
33 33 # communicate. If set, use the specified curl command. This could be useful
34 34 # if you need to specify advanced options that is not easily supported by
35 35 # the internal library.
36 36 curlcmd = curl --connect-timeout 2 --retry 3 --silent
37 37
38 38 [auth]
39 39 example.schemes = https
40 40 example.prefix = phab.example.com
41 41
42 42 # API token. Get it from https://$HOST/conduit/login/
43 43 example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
44 44 """
45 45
46 46 from __future__ import absolute_import
47 47
48 48 import base64
49 49 import contextlib
50 50 import hashlib
51 51 import itertools
52 52 import json
53 53 import mimetypes
54 54 import operator
55 55 import re
56 56
57 57 from mercurial.node import bin, nullid
58 58 from mercurial.i18n import _
59 59 from mercurial.pycompat import getattr
60 60 from mercurial.thirdparty import attr
61 61 from mercurial import (
62 62 cmdutil,
63 63 context,
64 64 encoding,
65 65 error,
66 66 exthelper,
67 67 graphmod,
68 68 httpconnection as httpconnectionmod,
69 localrepo,
69 70 logcmdutil,
70 71 match,
71 72 mdiff,
72 73 obsutil,
73 74 parser,
74 75 patch,
75 76 phases,
76 77 pycompat,
77 78 scmutil,
78 79 smartset,
79 80 tags,
80 81 templatefilters,
81 82 templateutil,
82 83 url as urlmod,
83 84 util,
84 85 )
85 86 from mercurial.utils import (
86 87 procutil,
87 88 stringutil,
88 89 )
89 90 from . import show
90 91
91 92
92 93 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
93 94 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
94 95 # be specifying the version(s) of Mercurial they are tested with, or
95 96 # leave the attribute unspecified.
96 97 testedwith = b'ships-with-hg-core'
97 98
98 99 eh = exthelper.exthelper()
99 100
100 101 cmdtable = eh.cmdtable
101 102 command = eh.command
102 103 configtable = eh.configtable
103 104 templatekeyword = eh.templatekeyword
105 uisetup = eh.finaluisetup
104 106
105 107 # developer config: phabricator.batchsize
106 108 eh.configitem(
107 109 b'phabricator', b'batchsize', default=12,
108 110 )
109 111 eh.configitem(
110 112 b'phabricator', b'callsign', default=None,
111 113 )
112 114 eh.configitem(
113 115 b'phabricator', b'curlcmd', default=None,
114 116 )
115 117 # developer config: phabricator.repophid
116 118 eh.configitem(
117 119 b'phabricator', b'repophid', default=None,
118 120 )
119 121 eh.configitem(
120 122 b'phabricator', b'url', default=None,
121 123 )
122 124 eh.configitem(
123 125 b'phabsend', b'confirm', default=False,
124 126 )
125 127
126 128 colortable = {
127 129 b'phabricator.action.created': b'green',
128 130 b'phabricator.action.skipped': b'magenta',
129 131 b'phabricator.action.updated': b'magenta',
130 132 b'phabricator.desc': b'',
131 133 b'phabricator.drev': b'bold',
132 134 b'phabricator.node': b'',
133 135 b'phabricator.status.abandoned': b'magenta dim',
134 136 b'phabricator.status.accepted': b'green bold',
135 137 b'phabricator.status.closed': b'green',
136 138 b'phabricator.status.needsreview': b'yellow',
137 139 b'phabricator.status.needsrevision': b'red',
138 140 b'phabricator.status.changesplanned': b'red',
139 141 }
140 142
141 143 _VCR_FLAGS = [
142 144 (
143 145 b'',
144 146 b'test-vcr',
145 147 b'',
146 148 _(
147 149 b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
148 150 b', otherwise will mock all http requests using the specified vcr file.'
149 151 b' (ADVANCED)'
150 152 ),
151 153 ),
152 154 ]
153 155
154 156
157 @eh.wrapfunction(localrepo, "loadhgrc")
158 def _loadhgrc(orig, ui, wdirvfs, hgvfs, requirements):
159 """Load ``.arcconfig`` content into a ui instance on repository open.
160 """
161 result = False
162 arcconfig = {}
163
164 try:
165 # json.loads only accepts bytes from 3.6+
166 rawparams = encoding.unifromlocal(wdirvfs.read(b".arcconfig"))
167 # json.loads only returns unicode strings
168 arcconfig = pycompat.rapply(
169 lambda x: encoding.unitolocal(x)
170 if isinstance(x, pycompat.unicode)
171 else x,
172 pycompat.json_loads(rawparams),
173 )
174
175 result = True
176 except ValueError:
177 ui.warn(_(b"invalid JSON in %s\n") % wdirvfs.join(b".arcconfig"))
178 except IOError:
179 pass
180
181 if b"repository.callsign" in arcconfig:
182 ui.applyconfig(
183 {(b"phabricator", b"callsign"): arcconfig[b"repository.callsign"]},
184 source=wdirvfs.join(b".arcconfig"),
185 )
186
187 return orig(ui, wdirvfs, hgvfs, requirements) or result # Load .hg/hgrc
188
189
155 190 def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
156 191 fullflags = flags + _VCR_FLAGS
157 192
158 193 def hgmatcher(r1, r2):
159 194 if r1.uri != r2.uri or r1.method != r2.method:
160 195 return False
161 196 r1params = util.urlreq.parseqs(r1.body)
162 197 r2params = util.urlreq.parseqs(r2.body)
163 198 for key in r1params:
164 199 if key not in r2params:
165 200 return False
166 201 value = r1params[key][0]
167 202 # we want to compare json payloads without worrying about ordering
168 203 if value.startswith(b'{') and value.endswith(b'}'):
169 204 r1json = pycompat.json_loads(value)
170 205 r2json = pycompat.json_loads(r2params[key][0])
171 206 if r1json != r2json:
172 207 return False
173 208 elif r2params[key][0] != value:
174 209 return False
175 210 return True
176 211
177 212 def sanitiserequest(request):
178 213 request.body = re.sub(
179 214 br'cli-[a-z0-9]+', br'cli-hahayouwish', request.body
180 215 )
181 216 return request
182 217
183 218 def sanitiseresponse(response):
184 219 if 'set-cookie' in response['headers']:
185 220 del response['headers']['set-cookie']
186 221 return response
187 222
188 223 def decorate(fn):
189 224 def inner(*args, **kwargs):
190 225 cassette = pycompat.fsdecode(kwargs.pop('test_vcr', None))
191 226 if cassette:
192 227 import hgdemandimport
193 228
194 229 with hgdemandimport.deactivated():
195 230 import vcr as vcrmod
196 231 import vcr.stubs as stubs
197 232
198 233 vcr = vcrmod.VCR(
199 234 serializer='json',
200 235 before_record_request=sanitiserequest,
201 236 before_record_response=sanitiseresponse,
202 237 custom_patches=[
203 238 (
204 239 urlmod,
205 240 'httpconnection',
206 241 stubs.VCRHTTPConnection,
207 242 ),
208 243 (
209 244 urlmod,
210 245 'httpsconnection',
211 246 stubs.VCRHTTPSConnection,
212 247 ),
213 248 ],
214 249 )
215 250 vcr.register_matcher('hgmatcher', hgmatcher)
216 251 with vcr.use_cassette(cassette, match_on=['hgmatcher']):
217 252 return fn(*args, **kwargs)
218 253 return fn(*args, **kwargs)
219 254
220 255 inner.__name__ = fn.__name__
221 256 inner.__doc__ = fn.__doc__
222 257 return command(
223 258 name,
224 259 fullflags,
225 260 spec,
226 261 helpcategory=helpcategory,
227 262 optionalrepo=optionalrepo,
228 263 )(inner)
229 264
230 265 return decorate
231 266
232 267
233 268 def urlencodenested(params):
234 269 """like urlencode, but works with nested parameters.
235 270
236 271 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
237 272 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
238 273 urlencode. Note: the encoding is consistent with PHP's http_build_query.
239 274 """
240 275 flatparams = util.sortdict()
241 276
242 277 def process(prefix, obj):
243 278 if isinstance(obj, bool):
244 279 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
245 280 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
246 281 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
247 282 if items is None:
248 283 flatparams[prefix] = obj
249 284 else:
250 285 for k, v in items(obj):
251 286 if prefix:
252 287 process(b'%s[%s]' % (prefix, k), v)
253 288 else:
254 289 process(k, v)
255 290
256 291 process(b'', params)
257 292 return util.urlreq.urlencode(flatparams)
258 293
259 294
260 295 def readurltoken(ui):
261 296 """return conduit url, token and make sure they exist
262 297
263 298 Currently read from [auth] config section. In the future, it might
264 299 make sense to read from .arcconfig and .arcrc as well.
265 300 """
266 301 url = ui.config(b'phabricator', b'url')
267 302 if not url:
268 303 raise error.Abort(
269 304 _(b'config %s.%s is required') % (b'phabricator', b'url')
270 305 )
271 306
272 307 res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user)
273 308 token = None
274 309
275 310 if res:
276 311 group, auth = res
277 312
278 313 ui.debug(b"using auth.%s.* for authentication\n" % group)
279 314
280 315 token = auth.get(b'phabtoken')
281 316
282 317 if not token:
283 318 raise error.Abort(
284 319 _(b'Can\'t find conduit token associated to %s') % (url,)
285 320 )
286 321
287 322 return url, token
288 323
289 324
290 325 def callconduit(ui, name, params):
291 326 """call Conduit API, params is a dict. return json.loads result, or None"""
292 327 host, token = readurltoken(ui)
293 328 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
294 329 ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
295 330 params = params.copy()
296 331 params[b'__conduit__'] = {
297 332 b'token': token,
298 333 }
299 334 rawdata = {
300 335 b'params': templatefilters.json(params),
301 336 b'output': b'json',
302 337 b'__conduit__': 1,
303 338 }
304 339 data = urlencodenested(rawdata)
305 340 curlcmd = ui.config(b'phabricator', b'curlcmd')
306 341 if curlcmd:
307 342 sin, sout = procutil.popen2(
308 343 b'%s -d @- %s' % (curlcmd, procutil.shellquote(url))
309 344 )
310 345 sin.write(data)
311 346 sin.close()
312 347 body = sout.read()
313 348 else:
314 349 urlopener = urlmod.opener(ui, authinfo)
315 350 request = util.urlreq.request(pycompat.strurl(url), data=data)
316 351 with contextlib.closing(urlopener.open(request)) as rsp:
317 352 body = rsp.read()
318 353 ui.debug(b'Conduit Response: %s\n' % body)
319 354 parsed = pycompat.rapply(
320 355 lambda x: encoding.unitolocal(x)
321 356 if isinstance(x, pycompat.unicode)
322 357 else x,
323 358 # json.loads only accepts bytes from py3.6+
324 359 pycompat.json_loads(encoding.unifromlocal(body)),
325 360 )
326 361 if parsed.get(b'error_code'):
327 362 msg = _(b'Conduit Error (%s): %s') % (
328 363 parsed[b'error_code'],
329 364 parsed[b'error_info'],
330 365 )
331 366 raise error.Abort(msg)
332 367 return parsed[b'result']
333 368
334 369
335 370 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
336 371 def debugcallconduit(ui, repo, name):
337 372 """call Conduit API
338 373
339 374 Call parameters are read from stdin as a JSON blob. Result will be written
340 375 to stdout as a JSON blob.
341 376 """
342 377 # json.loads only accepts bytes from 3.6+
343 378 rawparams = encoding.unifromlocal(ui.fin.read())
344 379 # json.loads only returns unicode strings
345 380 params = pycompat.rapply(
346 381 lambda x: encoding.unitolocal(x)
347 382 if isinstance(x, pycompat.unicode)
348 383 else x,
349 384 pycompat.json_loads(rawparams),
350 385 )
351 386 # json.dumps only accepts unicode strings
352 387 result = pycompat.rapply(
353 388 lambda x: encoding.unifromlocal(x) if isinstance(x, bytes) else x,
354 389 callconduit(ui, name, params),
355 390 )
356 391 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
357 392 ui.write(b'%s\n' % encoding.unitolocal(s))
358 393
359 394
360 395 def getrepophid(repo):
361 396 """given callsign, return repository PHID or None"""
362 397 # developer config: phabricator.repophid
363 398 repophid = repo.ui.config(b'phabricator', b'repophid')
364 399 if repophid:
365 400 return repophid
366 401 callsign = repo.ui.config(b'phabricator', b'callsign')
367 402 if not callsign:
368 403 return None
369 404 query = callconduit(
370 405 repo.ui,
371 406 b'diffusion.repository.search',
372 407 {b'constraints': {b'callsigns': [callsign]}},
373 408 )
374 409 if len(query[b'data']) == 0:
375 410 return None
376 411 repophid = query[b'data'][0][b'phid']
377 412 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
378 413 return repophid
379 414
380 415
381 416 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
382 417 _differentialrevisiondescre = re.compile(
383 418 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M
384 419 )
385 420
386 421
387 422 def getoldnodedrevmap(repo, nodelist):
388 423 """find previous nodes that has been sent to Phabricator
389 424
390 425 return {node: (oldnode, Differential diff, Differential Revision ID)}
391 426 for node in nodelist with known previous sent versions, or associated
392 427 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
393 428 be ``None``.
394 429
395 430 Examines commit messages like "Differential Revision:" to get the
396 431 association information.
397 432
398 433 If such commit message line is not found, examines all precursors and their
399 434 tags. Tags with format like "D1234" are considered a match and the node
400 435 with that tag, and the number after "D" (ex. 1234) will be returned.
401 436
402 437 The ``old node``, if not None, is guaranteed to be the last diff of
403 438 corresponding Differential Revision, and exist in the repo.
404 439 """
405 440 unfi = repo.unfiltered()
406 441 has_node = unfi.changelog.index.has_node
407 442
408 443 result = {} # {node: (oldnode?, lastdiff?, drev)}
409 444 toconfirm = {} # {node: (force, {precnode}, drev)}
410 445 for node in nodelist:
411 446 ctx = unfi[node]
412 447 # For tags like "D123", put them into "toconfirm" to verify later
413 448 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
414 449 for n in precnodes:
415 450 if has_node(n):
416 451 for tag in unfi.nodetags(n):
417 452 m = _differentialrevisiontagre.match(tag)
418 453 if m:
419 454 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
420 455 break
421 456 else:
422 457 continue # move to next predecessor
423 458 break # found a tag, stop
424 459 else:
425 460 # Check commit message
426 461 m = _differentialrevisiondescre.search(ctx.description())
427 462 if m:
428 463 toconfirm[node] = (1, set(precnodes), int(m.group('id')))
429 464
430 465 # Double check if tags are genuine by collecting all old nodes from
431 466 # Phabricator, and expect precursors overlap with it.
432 467 if toconfirm:
433 468 drevs = [drev for force, precs, drev in toconfirm.values()]
434 469 alldiffs = callconduit(
435 470 unfi.ui, b'differential.querydiffs', {b'revisionIDs': drevs}
436 471 )
437 472 getnode = lambda d: bin(getdiffmeta(d).get(b'node', b'')) or None
438 473 for newnode, (force, precset, drev) in toconfirm.items():
439 474 diffs = [
440 475 d for d in alldiffs.values() if int(d[b'revisionID']) == drev
441 476 ]
442 477
443 478 # "precursors" as known by Phabricator
444 479 phprecset = set(getnode(d) for d in diffs)
445 480
446 481 # Ignore if precursors (Phabricator and local repo) do not overlap,
447 482 # and force is not set (when commit message says nothing)
448 483 if not force and not bool(phprecset & precset):
449 484 tagname = b'D%d' % drev
450 485 tags.tag(
451 486 repo,
452 487 tagname,
453 488 nullid,
454 489 message=None,
455 490 user=None,
456 491 date=None,
457 492 local=True,
458 493 )
459 494 unfi.ui.warn(
460 495 _(
461 496 b'D%d: local tag removed - does not match '
462 497 b'Differential history\n'
463 498 )
464 499 % drev
465 500 )
466 501 continue
467 502
468 503 # Find the last node using Phabricator metadata, and make sure it
469 504 # exists in the repo
470 505 oldnode = lastdiff = None
471 506 if diffs:
472 507 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
473 508 oldnode = getnode(lastdiff)
474 509 if oldnode and not has_node(oldnode):
475 510 oldnode = None
476 511
477 512 result[newnode] = (oldnode, lastdiff, drev)
478 513
479 514 return result
480 515
481 516
482 517 def getdrevmap(repo, revs):
483 518 """Return a dict mapping each rev in `revs` to their Differential Revision
484 519 ID or None.
485 520 """
486 521 result = {}
487 522 for rev in revs:
488 523 result[rev] = None
489 524 ctx = repo[rev]
490 525 # Check commit message
491 526 m = _differentialrevisiondescre.search(ctx.description())
492 527 if m:
493 528 result[rev] = int(m.group('id'))
494 529 continue
495 530 # Check tags
496 531 for tag in repo.nodetags(ctx.node()):
497 532 m = _differentialrevisiontagre.match(tag)
498 533 if m:
499 534 result[rev] = int(m.group(1))
500 535 break
501 536
502 537 return result
503 538
504 539
505 540 def getdiff(ctx, diffopts):
506 541 """plain-text diff without header (user, commit message, etc)"""
507 542 output = util.stringio()
508 543 for chunk, _label in patch.diffui(
509 544 ctx.repo(), ctx.p1().node(), ctx.node(), None, opts=diffopts
510 545 ):
511 546 output.write(chunk)
512 547 return output.getvalue()
513 548
514 549
515 550 class DiffChangeType(object):
516 551 ADD = 1
517 552 CHANGE = 2
518 553 DELETE = 3
519 554 MOVE_AWAY = 4
520 555 COPY_AWAY = 5
521 556 MOVE_HERE = 6
522 557 COPY_HERE = 7
523 558 MULTICOPY = 8
524 559
525 560
526 561 class DiffFileType(object):
527 562 TEXT = 1
528 563 IMAGE = 2
529 564 BINARY = 3
530 565
531 566
532 567 @attr.s
533 568 class phabhunk(dict):
534 569 """Represents a Differential hunk, which is owned by a Differential change
535 570 """
536 571
537 572 oldOffset = attr.ib(default=0) # camelcase-required
538 573 oldLength = attr.ib(default=0) # camelcase-required
539 574 newOffset = attr.ib(default=0) # camelcase-required
540 575 newLength = attr.ib(default=0) # camelcase-required
541 576 corpus = attr.ib(default='')
542 577 # These get added to the phabchange's equivalents
543 578 addLines = attr.ib(default=0) # camelcase-required
544 579 delLines = attr.ib(default=0) # camelcase-required
545 580
546 581
547 582 @attr.s
548 583 class phabchange(object):
549 584 """Represents a Differential change, owns Differential hunks and owned by a
550 585 Differential diff. Each one represents one file in a diff.
551 586 """
552 587
553 588 currentPath = attr.ib(default=None) # camelcase-required
554 589 oldPath = attr.ib(default=None) # camelcase-required
555 590 awayPaths = attr.ib(default=attr.Factory(list)) # camelcase-required
556 591 metadata = attr.ib(default=attr.Factory(dict))
557 592 oldProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
558 593 newProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
559 594 type = attr.ib(default=DiffChangeType.CHANGE)
560 595 fileType = attr.ib(default=DiffFileType.TEXT) # camelcase-required
561 596 commitHash = attr.ib(default=None) # camelcase-required
562 597 addLines = attr.ib(default=0) # camelcase-required
563 598 delLines = attr.ib(default=0) # camelcase-required
564 599 hunks = attr.ib(default=attr.Factory(list))
565 600
566 601 def copynewmetadatatoold(self):
567 602 for key in list(self.metadata.keys()):
568 603 newkey = key.replace(b'new:', b'old:')
569 604 self.metadata[newkey] = self.metadata[key]
570 605
571 606 def addoldmode(self, value):
572 607 self.oldProperties[b'unix:filemode'] = value
573 608
574 609 def addnewmode(self, value):
575 610 self.newProperties[b'unix:filemode'] = value
576 611
577 612 def addhunk(self, hunk):
578 613 if not isinstance(hunk, phabhunk):
579 614 raise error.Abort(b'phabchange.addhunk only takes phabhunks')
580 615 self.hunks.append(pycompat.byteskwargs(attr.asdict(hunk)))
581 616 # It's useful to include these stats since the Phab web UI shows them,
582 617 # and uses them to estimate how large a change a Revision is. Also used
583 618 # in email subjects for the [+++--] bit.
584 619 self.addLines += hunk.addLines
585 620 self.delLines += hunk.delLines
586 621
587 622
588 623 @attr.s
589 624 class phabdiff(object):
590 625 """Represents a Differential diff, owns Differential changes. Corresponds
591 626 to a commit.
592 627 """
593 628
594 629 # Doesn't seem to be any reason to send this (output of uname -n)
595 630 sourceMachine = attr.ib(default=b'') # camelcase-required
596 631 sourcePath = attr.ib(default=b'/') # camelcase-required
597 632 sourceControlBaseRevision = attr.ib(default=b'0' * 40) # camelcase-required
598 633 sourceControlPath = attr.ib(default=b'/') # camelcase-required
599 634 sourceControlSystem = attr.ib(default=b'hg') # camelcase-required
600 635 branch = attr.ib(default=b'default')
601 636 bookmark = attr.ib(default=None)
602 637 creationMethod = attr.ib(default=b'phabsend') # camelcase-required
603 638 lintStatus = attr.ib(default=b'none') # camelcase-required
604 639 unitStatus = attr.ib(default=b'none') # camelcase-required
605 640 changes = attr.ib(default=attr.Factory(dict))
606 641 repositoryPHID = attr.ib(default=None) # camelcase-required
607 642
608 643 def addchange(self, change):
609 644 if not isinstance(change, phabchange):
610 645 raise error.Abort(b'phabdiff.addchange only takes phabchanges')
611 646 self.changes[change.currentPath] = pycompat.byteskwargs(
612 647 attr.asdict(change)
613 648 )
614 649
615 650
616 651 def maketext(pchange, ctx, fname):
617 652 """populate the phabchange for a text file"""
618 653 repo = ctx.repo()
619 654 fmatcher = match.exact([fname])
620 655 diffopts = mdiff.diffopts(git=True, context=32767)
621 656 _pfctx, _fctx, header, fhunks = next(
622 657 patch.diffhunks(repo, ctx.p1(), ctx, fmatcher, opts=diffopts)
623 658 )
624 659
625 660 for fhunk in fhunks:
626 661 (oldOffset, oldLength, newOffset, newLength), lines = fhunk
627 662 corpus = b''.join(lines[1:])
628 663 shunk = list(header)
629 664 shunk.extend(lines)
630 665 _mf, _mt, addLines, delLines, _hb = patch.diffstatsum(
631 666 patch.diffstatdata(util.iterlines(shunk))
632 667 )
633 668 pchange.addhunk(
634 669 phabhunk(
635 670 oldOffset,
636 671 oldLength,
637 672 newOffset,
638 673 newLength,
639 674 corpus,
640 675 addLines,
641 676 delLines,
642 677 )
643 678 )
644 679
645 680
646 681 def uploadchunks(fctx, fphid):
647 682 """upload large binary files as separate chunks.
648 683 Phab requests chunking over 8MiB, and splits into 4MiB chunks
649 684 """
650 685 ui = fctx.repo().ui
651 686 chunks = callconduit(ui, b'file.querychunks', {b'filePHID': fphid})
652 687 with ui.makeprogress(
653 688 _(b'uploading file chunks'), unit=_(b'chunks'), total=len(chunks)
654 689 ) as progress:
655 690 for chunk in chunks:
656 691 progress.increment()
657 692 if chunk[b'complete']:
658 693 continue
659 694 bstart = int(chunk[b'byteStart'])
660 695 bend = int(chunk[b'byteEnd'])
661 696 callconduit(
662 697 ui,
663 698 b'file.uploadchunk',
664 699 {
665 700 b'filePHID': fphid,
666 701 b'byteStart': bstart,
667 702 b'data': base64.b64encode(fctx.data()[bstart:bend]),
668 703 b'dataEncoding': b'base64',
669 704 },
670 705 )
671 706
672 707
673 708 def uploadfile(fctx):
674 709 """upload binary files to Phabricator"""
675 710 repo = fctx.repo()
676 711 ui = repo.ui
677 712 fname = fctx.path()
678 713 size = fctx.size()
679 714 fhash = pycompat.bytestr(hashlib.sha256(fctx.data()).hexdigest())
680 715
681 716 # an allocate call is required first to see if an upload is even required
682 717 # (Phab might already have it) and to determine if chunking is needed
683 718 allocateparams = {
684 719 b'name': fname,
685 720 b'contentLength': size,
686 721 b'contentHash': fhash,
687 722 }
688 723 filealloc = callconduit(ui, b'file.allocate', allocateparams)
689 724 fphid = filealloc[b'filePHID']
690 725
691 726 if filealloc[b'upload']:
692 727 ui.write(_(b'uploading %s\n') % bytes(fctx))
693 728 if not fphid:
694 729 uploadparams = {
695 730 b'name': fname,
696 731 b'data_base64': base64.b64encode(fctx.data()),
697 732 }
698 733 fphid = callconduit(ui, b'file.upload', uploadparams)
699 734 else:
700 735 uploadchunks(fctx, fphid)
701 736 else:
702 737 ui.debug(b'server already has %s\n' % bytes(fctx))
703 738
704 739 if not fphid:
705 740 raise error.Abort(b'Upload of %s failed.' % bytes(fctx))
706 741
707 742 return fphid
708 743
709 744
710 745 def addoldbinary(pchange, fctx, originalfname):
711 746 """add the metadata for the previous version of a binary file to the
712 747 phabchange for the new version
713 748 """
714 749 oldfctx = fctx.p1()[originalfname]
715 750 if fctx.cmp(oldfctx):
716 751 # Files differ, add the old one
717 752 pchange.metadata[b'old:file:size'] = oldfctx.size()
718 753 mimeguess, _enc = mimetypes.guess_type(
719 754 encoding.unifromlocal(oldfctx.path())
720 755 )
721 756 if mimeguess:
722 757 pchange.metadata[b'old:file:mime-type'] = pycompat.bytestr(
723 758 mimeguess
724 759 )
725 760 fphid = uploadfile(oldfctx)
726 761 pchange.metadata[b'old:binary-phid'] = fphid
727 762 else:
728 763 # If it's left as IMAGE/BINARY web UI might try to display it
729 764 pchange.fileType = DiffFileType.TEXT
730 765 pchange.copynewmetadatatoold()
731 766
732 767
733 768 def makebinary(pchange, fctx):
734 769 """populate the phabchange for a binary file"""
735 770 pchange.fileType = DiffFileType.BINARY
736 771 fphid = uploadfile(fctx)
737 772 pchange.metadata[b'new:binary-phid'] = fphid
738 773 pchange.metadata[b'new:file:size'] = fctx.size()
739 774 mimeguess, _enc = mimetypes.guess_type(encoding.unifromlocal(fctx.path()))
740 775 if mimeguess:
741 776 mimeguess = pycompat.bytestr(mimeguess)
742 777 pchange.metadata[b'new:file:mime-type'] = mimeguess
743 778 if mimeguess.startswith(b'image/'):
744 779 pchange.fileType = DiffFileType.IMAGE
745 780
746 781
747 782 # Copied from mercurial/patch.py
748 783 gitmode = {b'l': b'120000', b'x': b'100755', b'': b'100644'}
749 784
750 785
751 786 def notutf8(fctx):
752 787 """detect non-UTF-8 text files since Phabricator requires them to be marked
753 788 as binary
754 789 """
755 790 try:
756 791 fctx.data().decode('utf-8')
757 792 if fctx.parents():
758 793 fctx.p1().data().decode('utf-8')
759 794 return False
760 795 except UnicodeDecodeError:
761 796 fctx.repo().ui.write(
762 797 _(b'file %s detected as non-UTF-8, marked as binary\n')
763 798 % fctx.path()
764 799 )
765 800 return True
766 801
767 802
768 803 def addremoved(pdiff, ctx, removed):
769 804 """add removed files to the phabdiff. Shouldn't include moves"""
770 805 for fname in removed:
771 806 pchange = phabchange(
772 807 currentPath=fname, oldPath=fname, type=DiffChangeType.DELETE
773 808 )
774 809 pchange.addoldmode(gitmode[ctx.p1()[fname].flags()])
775 810 fctx = ctx.p1()[fname]
776 811 if not (fctx.isbinary() or notutf8(fctx)):
777 812 maketext(pchange, ctx, fname)
778 813
779 814 pdiff.addchange(pchange)
780 815
781 816
782 817 def addmodified(pdiff, ctx, modified):
783 818 """add modified files to the phabdiff"""
784 819 for fname in modified:
785 820 fctx = ctx[fname]
786 821 pchange = phabchange(currentPath=fname, oldPath=fname)
787 822 filemode = gitmode[ctx[fname].flags()]
788 823 originalmode = gitmode[ctx.p1()[fname].flags()]
789 824 if filemode != originalmode:
790 825 pchange.addoldmode(originalmode)
791 826 pchange.addnewmode(filemode)
792 827
793 828 if fctx.isbinary() or notutf8(fctx):
794 829 makebinary(pchange, fctx)
795 830 addoldbinary(pchange, fctx, fname)
796 831 else:
797 832 maketext(pchange, ctx, fname)
798 833
799 834 pdiff.addchange(pchange)
800 835
801 836
802 837 def addadded(pdiff, ctx, added, removed):
803 838 """add file adds to the phabdiff, both new files and copies/moves"""
804 839 # Keep track of files that've been recorded as moved/copied, so if there are
805 840 # additional copies we can mark them (moves get removed from removed)
806 841 copiedchanges = {}
807 842 movedchanges = {}
808 843 for fname in added:
809 844 fctx = ctx[fname]
810 845 pchange = phabchange(currentPath=fname)
811 846
812 847 filemode = gitmode[ctx[fname].flags()]
813 848 renamed = fctx.renamed()
814 849
815 850 if renamed:
816 851 originalfname = renamed[0]
817 852 originalmode = gitmode[ctx.p1()[originalfname].flags()]
818 853 pchange.oldPath = originalfname
819 854
820 855 if originalfname in removed:
821 856 origpchange = phabchange(
822 857 currentPath=originalfname,
823 858 oldPath=originalfname,
824 859 type=DiffChangeType.MOVE_AWAY,
825 860 awayPaths=[fname],
826 861 )
827 862 movedchanges[originalfname] = origpchange
828 863 removed.remove(originalfname)
829 864 pchange.type = DiffChangeType.MOVE_HERE
830 865 elif originalfname in movedchanges:
831 866 movedchanges[originalfname].type = DiffChangeType.MULTICOPY
832 867 movedchanges[originalfname].awayPaths.append(fname)
833 868 pchange.type = DiffChangeType.COPY_HERE
834 869 else: # pure copy
835 870 if originalfname not in copiedchanges:
836 871 origpchange = phabchange(
837 872 currentPath=originalfname, type=DiffChangeType.COPY_AWAY
838 873 )
839 874 copiedchanges[originalfname] = origpchange
840 875 else:
841 876 origpchange = copiedchanges[originalfname]
842 877 origpchange.awayPaths.append(fname)
843 878 pchange.type = DiffChangeType.COPY_HERE
844 879
845 880 if filemode != originalmode:
846 881 pchange.addoldmode(originalmode)
847 882 pchange.addnewmode(filemode)
848 883 else: # Brand-new file
849 884 pchange.addnewmode(gitmode[fctx.flags()])
850 885 pchange.type = DiffChangeType.ADD
851 886
852 887 if fctx.isbinary() or notutf8(fctx):
853 888 makebinary(pchange, fctx)
854 889 if renamed:
855 890 addoldbinary(pchange, fctx, originalfname)
856 891 else:
857 892 maketext(pchange, ctx, fname)
858 893
859 894 pdiff.addchange(pchange)
860 895
861 896 for _path, copiedchange in copiedchanges.items():
862 897 pdiff.addchange(copiedchange)
863 898 for _path, movedchange in movedchanges.items():
864 899 pdiff.addchange(movedchange)
865 900
866 901
867 902 def creatediff(ctx):
868 903 """create a Differential Diff"""
869 904 repo = ctx.repo()
870 905 repophid = getrepophid(repo)
871 906 # Create a "Differential Diff" via "differential.creatediff" API
872 907 pdiff = phabdiff(
873 908 sourceControlBaseRevision=b'%s' % ctx.p1().hex(),
874 909 branch=b'%s' % ctx.branch(),
875 910 )
876 911 modified, added, removed, _d, _u, _i, _c = ctx.p1().status(ctx)
877 912 # addadded will remove moved files from removed, so addremoved won't get
878 913 # them
879 914 addadded(pdiff, ctx, added, removed)
880 915 addmodified(pdiff, ctx, modified)
881 916 addremoved(pdiff, ctx, removed)
882 917 if repophid:
883 918 pdiff.repositoryPHID = repophid
884 919 diff = callconduit(
885 920 repo.ui,
886 921 b'differential.creatediff',
887 922 pycompat.byteskwargs(attr.asdict(pdiff)),
888 923 )
889 924 if not diff:
890 925 raise error.Abort(_(b'cannot create diff for %s') % ctx)
891 926 return diff
892 927
893 928
894 929 def writediffproperties(ctx, diff):
895 930 """write metadata to diff so patches could be applied losslessly"""
896 931 # creatediff returns with a diffid but query returns with an id
897 932 diffid = diff.get(b'diffid', diff.get(b'id'))
898 933 params = {
899 934 b'diff_id': diffid,
900 935 b'name': b'hg:meta',
901 936 b'data': templatefilters.json(
902 937 {
903 938 b'user': ctx.user(),
904 939 b'date': b'%d %d' % ctx.date(),
905 940 b'branch': ctx.branch(),
906 941 b'node': ctx.hex(),
907 942 b'parent': ctx.p1().hex(),
908 943 }
909 944 ),
910 945 }
911 946 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
912 947
913 948 params = {
914 949 b'diff_id': diffid,
915 950 b'name': b'local:commits',
916 951 b'data': templatefilters.json(
917 952 {
918 953 ctx.hex(): {
919 954 b'author': stringutil.person(ctx.user()),
920 955 b'authorEmail': stringutil.email(ctx.user()),
921 956 b'time': int(ctx.date()[0]),
922 957 b'commit': ctx.hex(),
923 958 b'parents': [ctx.p1().hex()],
924 959 b'branch': ctx.branch(),
925 960 },
926 961 }
927 962 ),
928 963 }
929 964 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
930 965
931 966
932 967 def createdifferentialrevision(
933 968 ctx,
934 969 revid=None,
935 970 parentrevphid=None,
936 971 oldnode=None,
937 972 olddiff=None,
938 973 actions=None,
939 974 comment=None,
940 975 ):
941 976 """create or update a Differential Revision
942 977
943 978 If revid is None, create a new Differential Revision, otherwise update
944 979 revid. If parentrevphid is not None, set it as a dependency.
945 980
946 981 If oldnode is not None, check if the patch content (without commit message
947 982 and metadata) has changed before creating another diff.
948 983
949 984 If actions is not None, they will be appended to the transaction.
950 985 """
951 986 repo = ctx.repo()
952 987 if oldnode:
953 988 diffopts = mdiff.diffopts(git=True, context=32767)
954 989 oldctx = repo.unfiltered()[oldnode]
955 990 neednewdiff = getdiff(ctx, diffopts) != getdiff(oldctx, diffopts)
956 991 else:
957 992 neednewdiff = True
958 993
959 994 transactions = []
960 995 if neednewdiff:
961 996 diff = creatediff(ctx)
962 997 transactions.append({b'type': b'update', b'value': diff[b'phid']})
963 998 if comment:
964 999 transactions.append({b'type': b'comment', b'value': comment})
965 1000 else:
966 1001 # Even if we don't need to upload a new diff because the patch content
967 1002 # does not change. We might still need to update its metadata so
968 1003 # pushers could know the correct node metadata.
969 1004 assert olddiff
970 1005 diff = olddiff
971 1006 writediffproperties(ctx, diff)
972 1007
973 1008 # Set the parent Revision every time, so commit re-ordering is picked-up
974 1009 if parentrevphid:
975 1010 transactions.append(
976 1011 {b'type': b'parents.set', b'value': [parentrevphid]}
977 1012 )
978 1013
979 1014 if actions:
980 1015 transactions += actions
981 1016
982 1017 # Parse commit message and update related fields.
983 1018 desc = ctx.description()
984 1019 info = callconduit(
985 1020 repo.ui, b'differential.parsecommitmessage', {b'corpus': desc}
986 1021 )
987 1022 for k, v in info[b'fields'].items():
988 1023 if k in [b'title', b'summary', b'testPlan']:
989 1024 transactions.append({b'type': k, b'value': v})
990 1025
991 1026 params = {b'transactions': transactions}
992 1027 if revid is not None:
993 1028 # Update an existing Differential Revision
994 1029 params[b'objectIdentifier'] = revid
995 1030
996 1031 revision = callconduit(repo.ui, b'differential.revision.edit', params)
997 1032 if not revision:
998 1033 raise error.Abort(_(b'cannot create revision for %s') % ctx)
999 1034
1000 1035 return revision, diff
1001 1036
1002 1037
1003 1038 def userphids(repo, names):
1004 1039 """convert user names to PHIDs"""
1005 1040 names = [name.lower() for name in names]
1006 1041 query = {b'constraints': {b'usernames': names}}
1007 1042 result = callconduit(repo.ui, b'user.search', query)
1008 1043 # username not found is not an error of the API. So check if we have missed
1009 1044 # some names here.
1010 1045 data = result[b'data']
1011 1046 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
1012 1047 unresolved = set(names) - resolved
1013 1048 if unresolved:
1014 1049 raise error.Abort(
1015 1050 _(b'unknown username: %s') % b' '.join(sorted(unresolved))
1016 1051 )
1017 1052 return [entry[b'phid'] for entry in data]
1018 1053
1019 1054
1020 1055 @vcrcommand(
1021 1056 b'phabsend',
1022 1057 [
1023 1058 (b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
1024 1059 (b'', b'amend', True, _(b'update commit messages')),
1025 1060 (b'', b'reviewer', [], _(b'specify reviewers')),
1026 1061 (b'', b'blocker', [], _(b'specify blocking reviewers')),
1027 1062 (
1028 1063 b'm',
1029 1064 b'comment',
1030 1065 b'',
1031 1066 _(b'add a comment to Revisions with new/updated Diffs'),
1032 1067 ),
1033 1068 (b'', b'confirm', None, _(b'ask for confirmation before sending')),
1034 1069 ],
1035 1070 _(b'REV [OPTIONS]'),
1036 1071 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1037 1072 )
1038 1073 def phabsend(ui, repo, *revs, **opts):
1039 1074 """upload changesets to Phabricator
1040 1075
1041 1076 If there are multiple revisions specified, they will be send as a stack
1042 1077 with a linear dependencies relationship using the order specified by the
1043 1078 revset.
1044 1079
1045 1080 For the first time uploading changesets, local tags will be created to
1046 1081 maintain the association. After the first time, phabsend will check
1047 1082 obsstore and tags information so it can figure out whether to update an
1048 1083 existing Differential Revision, or create a new one.
1049 1084
1050 1085 If --amend is set, update commit messages so they have the
1051 1086 ``Differential Revision`` URL, remove related tags. This is similar to what
1052 1087 arcanist will do, and is more desired in author-push workflows. Otherwise,
1053 1088 use local tags to record the ``Differential Revision`` association.
1054 1089
1055 1090 The --confirm option lets you confirm changesets before sending them. You
1056 1091 can also add following to your configuration file to make it default
1057 1092 behaviour::
1058 1093
1059 1094 [phabsend]
1060 1095 confirm = true
1061 1096
1062 1097 phabsend will check obsstore and the above association to decide whether to
1063 1098 update an existing Differential Revision, or create a new one.
1064 1099 """
1065 1100 opts = pycompat.byteskwargs(opts)
1066 1101 revs = list(revs) + opts.get(b'rev', [])
1067 1102 revs = scmutil.revrange(repo, revs)
1068 1103 revs.sort() # ascending order to preserve topological parent/child in phab
1069 1104
1070 1105 if not revs:
1071 1106 raise error.Abort(_(b'phabsend requires at least one changeset'))
1072 1107 if opts.get(b'amend'):
1073 1108 cmdutil.checkunfinished(repo)
1074 1109
1075 1110 # {newnode: (oldnode, olddiff, olddrev}
1076 1111 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
1077 1112
1078 1113 confirm = ui.configbool(b'phabsend', b'confirm')
1079 1114 confirm |= bool(opts.get(b'confirm'))
1080 1115 if confirm:
1081 1116 confirmed = _confirmbeforesend(repo, revs, oldmap)
1082 1117 if not confirmed:
1083 1118 raise error.Abort(_(b'phabsend cancelled'))
1084 1119
1085 1120 actions = []
1086 1121 reviewers = opts.get(b'reviewer', [])
1087 1122 blockers = opts.get(b'blocker', [])
1088 1123 phids = []
1089 1124 if reviewers:
1090 1125 phids.extend(userphids(repo, reviewers))
1091 1126 if blockers:
1092 1127 phids.extend(
1093 1128 map(lambda phid: b'blocking(%s)' % phid, userphids(repo, blockers))
1094 1129 )
1095 1130 if phids:
1096 1131 actions.append({b'type': b'reviewers.add', b'value': phids})
1097 1132
1098 1133 drevids = [] # [int]
1099 1134 diffmap = {} # {newnode: diff}
1100 1135
1101 1136 # Send patches one by one so we know their Differential Revision PHIDs and
1102 1137 # can provide dependency relationship
1103 1138 lastrevphid = None
1104 1139 for rev in revs:
1105 1140 ui.debug(b'sending rev %d\n' % rev)
1106 1141 ctx = repo[rev]
1107 1142
1108 1143 # Get Differential Revision ID
1109 1144 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
1110 1145 if oldnode != ctx.node() or opts.get(b'amend'):
1111 1146 # Create or update Differential Revision
1112 1147 revision, diff = createdifferentialrevision(
1113 1148 ctx,
1114 1149 revid,
1115 1150 lastrevphid,
1116 1151 oldnode,
1117 1152 olddiff,
1118 1153 actions,
1119 1154 opts.get(b'comment'),
1120 1155 )
1121 1156 diffmap[ctx.node()] = diff
1122 1157 newrevid = int(revision[b'object'][b'id'])
1123 1158 newrevphid = revision[b'object'][b'phid']
1124 1159 if revid:
1125 1160 action = b'updated'
1126 1161 else:
1127 1162 action = b'created'
1128 1163
1129 1164 # Create a local tag to note the association, if commit message
1130 1165 # does not have it already
1131 1166 m = _differentialrevisiondescre.search(ctx.description())
1132 1167 if not m or int(m.group('id')) != newrevid:
1133 1168 tagname = b'D%d' % newrevid
1134 1169 tags.tag(
1135 1170 repo,
1136 1171 tagname,
1137 1172 ctx.node(),
1138 1173 message=None,
1139 1174 user=None,
1140 1175 date=None,
1141 1176 local=True,
1142 1177 )
1143 1178 else:
1144 1179 # Nothing changed. But still set "newrevphid" so the next revision
1145 1180 # could depend on this one and "newrevid" for the summary line.
1146 1181 newrevphid = querydrev(repo, b'%d' % revid)[0][b'phid']
1147 1182 newrevid = revid
1148 1183 action = b'skipped'
1149 1184
1150 1185 actiondesc = ui.label(
1151 1186 {
1152 1187 b'created': _(b'created'),
1153 1188 b'skipped': _(b'skipped'),
1154 1189 b'updated': _(b'updated'),
1155 1190 }[action],
1156 1191 b'phabricator.action.%s' % action,
1157 1192 )
1158 1193 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
1159 1194 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
1160 1195 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
1161 1196 ui.write(
1162 1197 _(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc, desc)
1163 1198 )
1164 1199 drevids.append(newrevid)
1165 1200 lastrevphid = newrevphid
1166 1201
1167 1202 # Update commit messages and remove tags
1168 1203 if opts.get(b'amend'):
1169 1204 unfi = repo.unfiltered()
1170 1205 drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
1171 1206 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
1172 1207 wnode = unfi[b'.'].node()
1173 1208 mapping = {} # {oldnode: [newnode]}
1174 1209 for i, rev in enumerate(revs):
1175 1210 old = unfi[rev]
1176 1211 drevid = drevids[i]
1177 1212 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
1178 1213 newdesc = getdescfromdrev(drev)
1179 1214 # Make sure commit message contain "Differential Revision"
1180 1215 if old.description() != newdesc:
1181 1216 if old.phase() == phases.public:
1182 1217 ui.warn(
1183 1218 _(b"warning: not updating public commit %s\n")
1184 1219 % scmutil.formatchangeid(old)
1185 1220 )
1186 1221 continue
1187 1222 parents = [
1188 1223 mapping.get(old.p1().node(), (old.p1(),))[0],
1189 1224 mapping.get(old.p2().node(), (old.p2(),))[0],
1190 1225 ]
1191 1226 new = context.metadataonlyctx(
1192 1227 repo,
1193 1228 old,
1194 1229 parents=parents,
1195 1230 text=newdesc,
1196 1231 user=old.user(),
1197 1232 date=old.date(),
1198 1233 extra=old.extra(),
1199 1234 )
1200 1235
1201 1236 newnode = new.commit()
1202 1237
1203 1238 mapping[old.node()] = [newnode]
1204 1239 # Update diff property
1205 1240 # If it fails just warn and keep going, otherwise the DREV
1206 1241 # associations will be lost
1207 1242 try:
1208 1243 writediffproperties(unfi[newnode], diffmap[old.node()])
1209 1244 except util.urlerr.urlerror:
1210 1245 ui.warnnoi18n(
1211 1246 b'Failed to update metadata for D%d\n' % drevid
1212 1247 )
1213 1248 # Remove local tags since it's no longer necessary
1214 1249 tagname = b'D%d' % drevid
1215 1250 if tagname in repo.tags():
1216 1251 tags.tag(
1217 1252 repo,
1218 1253 tagname,
1219 1254 nullid,
1220 1255 message=None,
1221 1256 user=None,
1222 1257 date=None,
1223 1258 local=True,
1224 1259 )
1225 1260 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
1226 1261 if wnode in mapping:
1227 1262 unfi.setparents(mapping[wnode][0])
1228 1263
1229 1264
1230 1265 # Map from "hg:meta" keys to header understood by "hg import". The order is
1231 1266 # consistent with "hg export" output.
1232 1267 _metanamemap = util.sortdict(
1233 1268 [
1234 1269 (b'user', b'User'),
1235 1270 (b'date', b'Date'),
1236 1271 (b'branch', b'Branch'),
1237 1272 (b'node', b'Node ID'),
1238 1273 (b'parent', b'Parent '),
1239 1274 ]
1240 1275 )
1241 1276
1242 1277
1243 1278 def _confirmbeforesend(repo, revs, oldmap):
1244 1279 url, token = readurltoken(repo.ui)
1245 1280 ui = repo.ui
1246 1281 for rev in revs:
1247 1282 ctx = repo[rev]
1248 1283 desc = ctx.description().splitlines()[0]
1249 1284 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
1250 1285 if drevid:
1251 1286 drevdesc = ui.label(b'D%d' % drevid, b'phabricator.drev')
1252 1287 else:
1253 1288 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
1254 1289
1255 1290 ui.write(
1256 1291 _(b'%s - %s: %s\n')
1257 1292 % (
1258 1293 drevdesc,
1259 1294 ui.label(bytes(ctx), b'phabricator.node'),
1260 1295 ui.label(desc, b'phabricator.desc'),
1261 1296 )
1262 1297 )
1263 1298
1264 1299 if ui.promptchoice(
1265 1300 _(b'Send the above changes to %s (yn)?$$ &Yes $$ &No') % url
1266 1301 ):
1267 1302 return False
1268 1303
1269 1304 return True
1270 1305
1271 1306
1272 1307 _knownstatusnames = {
1273 1308 b'accepted',
1274 1309 b'needsreview',
1275 1310 b'needsrevision',
1276 1311 b'closed',
1277 1312 b'abandoned',
1278 1313 b'changesplanned',
1279 1314 }
1280 1315
1281 1316
1282 1317 def _getstatusname(drev):
1283 1318 """get normalized status name from a Differential Revision"""
1284 1319 return drev[b'statusName'].replace(b' ', b'').lower()
1285 1320
1286 1321
1287 1322 # Small language to specify differential revisions. Support symbols: (), :X,
1288 1323 # +, and -.
1289 1324
1290 1325 _elements = {
1291 1326 # token-type: binding-strength, primary, prefix, infix, suffix
1292 1327 b'(': (12, None, (b'group', 1, b')'), None, None),
1293 1328 b':': (8, None, (b'ancestors', 8), None, None),
1294 1329 b'&': (5, None, None, (b'and_', 5), None),
1295 1330 b'+': (4, None, None, (b'add', 4), None),
1296 1331 b'-': (4, None, None, (b'sub', 4), None),
1297 1332 b')': (0, None, None, None, None),
1298 1333 b'symbol': (0, b'symbol', None, None, None),
1299 1334 b'end': (0, None, None, None, None),
1300 1335 }
1301 1336
1302 1337
1303 1338 def _tokenize(text):
1304 1339 view = memoryview(text) # zero-copy slice
1305 1340 special = b'():+-& '
1306 1341 pos = 0
1307 1342 length = len(text)
1308 1343 while pos < length:
1309 1344 symbol = b''.join(
1310 1345 itertools.takewhile(
1311 1346 lambda ch: ch not in special, pycompat.iterbytestr(view[pos:])
1312 1347 )
1313 1348 )
1314 1349 if symbol:
1315 1350 yield (b'symbol', symbol, pos)
1316 1351 pos += len(symbol)
1317 1352 else: # special char, ignore space
1318 1353 if text[pos : pos + 1] != b' ':
1319 1354 yield (text[pos : pos + 1], None, pos)
1320 1355 pos += 1
1321 1356 yield (b'end', None, pos)
1322 1357
1323 1358
1324 1359 def _parse(text):
1325 1360 tree, pos = parser.parser(_elements).parse(_tokenize(text))
1326 1361 if pos != len(text):
1327 1362 raise error.ParseError(b'invalid token', pos)
1328 1363 return tree
1329 1364
1330 1365
1331 1366 def _parsedrev(symbol):
1332 1367 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
1333 1368 if symbol.startswith(b'D') and symbol[1:].isdigit():
1334 1369 return int(symbol[1:])
1335 1370 if symbol.isdigit():
1336 1371 return int(symbol)
1337 1372
1338 1373
1339 1374 def _prefetchdrevs(tree):
1340 1375 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
1341 1376 drevs = set()
1342 1377 ancestordrevs = set()
1343 1378 op = tree[0]
1344 1379 if op == b'symbol':
1345 1380 r = _parsedrev(tree[1])
1346 1381 if r:
1347 1382 drevs.add(r)
1348 1383 elif op == b'ancestors':
1349 1384 r, a = _prefetchdrevs(tree[1])
1350 1385 drevs.update(r)
1351 1386 ancestordrevs.update(r)
1352 1387 ancestordrevs.update(a)
1353 1388 else:
1354 1389 for t in tree[1:]:
1355 1390 r, a = _prefetchdrevs(t)
1356 1391 drevs.update(r)
1357 1392 ancestordrevs.update(a)
1358 1393 return drevs, ancestordrevs
1359 1394
1360 1395
1361 1396 def querydrev(repo, spec):
1362 1397 """return a list of "Differential Revision" dicts
1363 1398
1364 1399 spec is a string using a simple query language, see docstring in phabread
1365 1400 for details.
1366 1401
1367 1402 A "Differential Revision dict" looks like:
1368 1403
1369 1404 {
1370 1405 "id": "2",
1371 1406 "phid": "PHID-DREV-672qvysjcczopag46qty",
1372 1407 "title": "example",
1373 1408 "uri": "https://phab.example.com/D2",
1374 1409 "dateCreated": "1499181406",
1375 1410 "dateModified": "1499182103",
1376 1411 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
1377 1412 "status": "0",
1378 1413 "statusName": "Needs Review",
1379 1414 "properties": [],
1380 1415 "branch": null,
1381 1416 "summary": "",
1382 1417 "testPlan": "",
1383 1418 "lineCount": "2",
1384 1419 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
1385 1420 "diffs": [
1386 1421 "3",
1387 1422 "4",
1388 1423 ],
1389 1424 "commits": [],
1390 1425 "reviewers": [],
1391 1426 "ccs": [],
1392 1427 "hashes": [],
1393 1428 "auxiliary": {
1394 1429 "phabricator:projects": [],
1395 1430 "phabricator:depends-on": [
1396 1431 "PHID-DREV-gbapp366kutjebt7agcd"
1397 1432 ]
1398 1433 },
1399 1434 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
1400 1435 "sourcePath": null
1401 1436 }
1402 1437 """
1403 1438
1404 1439 def fetch(params):
1405 1440 """params -> single drev or None"""
1406 1441 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
1407 1442 if key in prefetched:
1408 1443 return prefetched[key]
1409 1444 drevs = callconduit(repo.ui, b'differential.query', params)
1410 1445 # Fill prefetched with the result
1411 1446 for drev in drevs:
1412 1447 prefetched[drev[b'phid']] = drev
1413 1448 prefetched[int(drev[b'id'])] = drev
1414 1449 if key not in prefetched:
1415 1450 raise error.Abort(
1416 1451 _(b'cannot get Differential Revision %r') % params
1417 1452 )
1418 1453 return prefetched[key]
1419 1454
1420 1455 def getstack(topdrevids):
1421 1456 """given a top, get a stack from the bottom, [id] -> [id]"""
1422 1457 visited = set()
1423 1458 result = []
1424 1459 queue = [{b'ids': [i]} for i in topdrevids]
1425 1460 while queue:
1426 1461 params = queue.pop()
1427 1462 drev = fetch(params)
1428 1463 if drev[b'id'] in visited:
1429 1464 continue
1430 1465 visited.add(drev[b'id'])
1431 1466 result.append(int(drev[b'id']))
1432 1467 auxiliary = drev.get(b'auxiliary', {})
1433 1468 depends = auxiliary.get(b'phabricator:depends-on', [])
1434 1469 for phid in depends:
1435 1470 queue.append({b'phids': [phid]})
1436 1471 result.reverse()
1437 1472 return smartset.baseset(result)
1438 1473
1439 1474 # Initialize prefetch cache
1440 1475 prefetched = {} # {id or phid: drev}
1441 1476
1442 1477 tree = _parse(spec)
1443 1478 drevs, ancestordrevs = _prefetchdrevs(tree)
1444 1479
1445 1480 # developer config: phabricator.batchsize
1446 1481 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
1447 1482
1448 1483 # Prefetch Differential Revisions in batch
1449 1484 tofetch = set(drevs)
1450 1485 for r in ancestordrevs:
1451 1486 tofetch.update(range(max(1, r - batchsize), r + 1))
1452 1487 if drevs:
1453 1488 fetch({b'ids': list(tofetch)})
1454 1489 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
1455 1490
1456 1491 # Walk through the tree, return smartsets
1457 1492 def walk(tree):
1458 1493 op = tree[0]
1459 1494 if op == b'symbol':
1460 1495 drev = _parsedrev(tree[1])
1461 1496 if drev:
1462 1497 return smartset.baseset([drev])
1463 1498 elif tree[1] in _knownstatusnames:
1464 1499 drevs = [
1465 1500 r
1466 1501 for r in validids
1467 1502 if _getstatusname(prefetched[r]) == tree[1]
1468 1503 ]
1469 1504 return smartset.baseset(drevs)
1470 1505 else:
1471 1506 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
1472 1507 elif op in {b'and_', b'add', b'sub'}:
1473 1508 assert len(tree) == 3
1474 1509 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
1475 1510 elif op == b'group':
1476 1511 return walk(tree[1])
1477 1512 elif op == b'ancestors':
1478 1513 return getstack(walk(tree[1]))
1479 1514 else:
1480 1515 raise error.ProgrammingError(b'illegal tree: %r' % tree)
1481 1516
1482 1517 return [prefetched[r] for r in walk(tree)]
1483 1518
1484 1519
1485 1520 def getdescfromdrev(drev):
1486 1521 """get description (commit message) from "Differential Revision"
1487 1522
1488 1523 This is similar to differential.getcommitmessage API. But we only care
1489 1524 about limited fields: title, summary, test plan, and URL.
1490 1525 """
1491 1526 title = drev[b'title']
1492 1527 summary = drev[b'summary'].rstrip()
1493 1528 testplan = drev[b'testPlan'].rstrip()
1494 1529 if testplan:
1495 1530 testplan = b'Test Plan:\n%s' % testplan
1496 1531 uri = b'Differential Revision: %s' % drev[b'uri']
1497 1532 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
1498 1533
1499 1534
1500 1535 def getdiffmeta(diff):
1501 1536 """get commit metadata (date, node, user, p1) from a diff object
1502 1537
1503 1538 The metadata could be "hg:meta", sent by phabsend, like:
1504 1539
1505 1540 "properties": {
1506 1541 "hg:meta": {
1507 1542 "date": "1499571514 25200",
1508 1543 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
1509 1544 "user": "Foo Bar <foo@example.com>",
1510 1545 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
1511 1546 }
1512 1547 }
1513 1548
1514 1549 Or converted from "local:commits", sent by "arc", like:
1515 1550
1516 1551 "properties": {
1517 1552 "local:commits": {
1518 1553 "98c08acae292b2faf60a279b4189beb6cff1414d": {
1519 1554 "author": "Foo Bar",
1520 1555 "time": 1499546314,
1521 1556 "branch": "default",
1522 1557 "tag": "",
1523 1558 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
1524 1559 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
1525 1560 "local": "1000",
1526 1561 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
1527 1562 "summary": "...",
1528 1563 "message": "...",
1529 1564 "authorEmail": "foo@example.com"
1530 1565 }
1531 1566 }
1532 1567 }
1533 1568
1534 1569 Note: metadata extracted from "local:commits" will lose time zone
1535 1570 information.
1536 1571 """
1537 1572 props = diff.get(b'properties') or {}
1538 1573 meta = props.get(b'hg:meta')
1539 1574 if not meta:
1540 1575 if props.get(b'local:commits'):
1541 1576 commit = sorted(props[b'local:commits'].values())[0]
1542 1577 meta = {}
1543 1578 if b'author' in commit and b'authorEmail' in commit:
1544 1579 meta[b'user'] = b'%s <%s>' % (
1545 1580 commit[b'author'],
1546 1581 commit[b'authorEmail'],
1547 1582 )
1548 1583 if b'time' in commit:
1549 1584 meta[b'date'] = b'%d 0' % int(commit[b'time'])
1550 1585 if b'branch' in commit:
1551 1586 meta[b'branch'] = commit[b'branch']
1552 1587 node = commit.get(b'commit', commit.get(b'rev'))
1553 1588 if node:
1554 1589 meta[b'node'] = node
1555 1590 if len(commit.get(b'parents', ())) >= 1:
1556 1591 meta[b'parent'] = commit[b'parents'][0]
1557 1592 else:
1558 1593 meta = {}
1559 1594 if b'date' not in meta and b'dateCreated' in diff:
1560 1595 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
1561 1596 if b'branch' not in meta and diff.get(b'branch'):
1562 1597 meta[b'branch'] = diff[b'branch']
1563 1598 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
1564 1599 meta[b'parent'] = diff[b'sourceControlBaseRevision']
1565 1600 return meta
1566 1601
1567 1602
1568 1603 def readpatch(repo, drevs, write):
1569 1604 """generate plain-text patch readable by 'hg import'
1570 1605
1571 1606 write is usually ui.write. drevs is what "querydrev" returns, results of
1572 1607 "differential.query".
1573 1608 """
1574 1609 # Prefetch hg:meta property for all diffs
1575 1610 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
1576 1611 diffs = callconduit(repo.ui, b'differential.querydiffs', {b'ids': diffids})
1577 1612
1578 1613 # Generate patch for each drev
1579 1614 for drev in drevs:
1580 1615 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
1581 1616
1582 1617 diffid = max(int(v) for v in drev[b'diffs'])
1583 1618 body = callconduit(
1584 1619 repo.ui, b'differential.getrawdiff', {b'diffID': diffid}
1585 1620 )
1586 1621 desc = getdescfromdrev(drev)
1587 1622 header = b'# HG changeset patch\n'
1588 1623
1589 1624 # Try to preserve metadata from hg:meta property. Write hg patch
1590 1625 # headers that can be read by the "import" command. See patchheadermap
1591 1626 # and extract in mercurial/patch.py for supported headers.
1592 1627 meta = getdiffmeta(diffs[b'%d' % diffid])
1593 1628 for k in _metanamemap.keys():
1594 1629 if k in meta:
1595 1630 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
1596 1631
1597 1632 content = b'%s%s\n%s' % (header, desc, body)
1598 1633 write(content)
1599 1634
1600 1635
1601 1636 @vcrcommand(
1602 1637 b'phabread',
1603 1638 [(b'', b'stack', False, _(b'read dependencies'))],
1604 1639 _(b'DREVSPEC [OPTIONS]'),
1605 1640 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1606 1641 )
1607 1642 def phabread(ui, repo, spec, **opts):
1608 1643 """print patches from Phabricator suitable for importing
1609 1644
1610 1645 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
1611 1646 the number ``123``. It could also have common operators like ``+``, ``-``,
1612 1647 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
1613 1648 select a stack.
1614 1649
1615 1650 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
1616 1651 could be used to filter patches by status. For performance reason, they
1617 1652 only represent a subset of non-status selections and cannot be used alone.
1618 1653
1619 1654 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
1620 1655 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
1621 1656 stack up to D9.
1622 1657
1623 1658 If --stack is given, follow dependencies information and read all patches.
1624 1659 It is equivalent to the ``:`` operator.
1625 1660 """
1626 1661 opts = pycompat.byteskwargs(opts)
1627 1662 if opts.get(b'stack'):
1628 1663 spec = b':(%s)' % spec
1629 1664 drevs = querydrev(repo, spec)
1630 1665 readpatch(repo, drevs, ui.write)
1631 1666
1632 1667
1633 1668 @vcrcommand(
1634 1669 b'phabupdate',
1635 1670 [
1636 1671 (b'', b'accept', False, _(b'accept revisions')),
1637 1672 (b'', b'reject', False, _(b'reject revisions')),
1638 1673 (b'', b'abandon', False, _(b'abandon revisions')),
1639 1674 (b'', b'reclaim', False, _(b'reclaim revisions')),
1640 1675 (b'm', b'comment', b'', _(b'comment on the last revision')),
1641 1676 ],
1642 1677 _(b'DREVSPEC [OPTIONS]'),
1643 1678 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1644 1679 )
1645 1680 def phabupdate(ui, repo, spec, **opts):
1646 1681 """update Differential Revision in batch
1647 1682
1648 1683 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
1649 1684 """
1650 1685 opts = pycompat.byteskwargs(opts)
1651 1686 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1652 1687 if len(flags) > 1:
1653 1688 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1654 1689
1655 1690 actions = []
1656 1691 for f in flags:
1657 1692 actions.append({b'type': f, b'value': True})
1658 1693
1659 1694 drevs = querydrev(repo, spec)
1660 1695 for i, drev in enumerate(drevs):
1661 1696 if i + 1 == len(drevs) and opts.get(b'comment'):
1662 1697 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1663 1698 if actions:
1664 1699 params = {
1665 1700 b'objectIdentifier': drev[b'phid'],
1666 1701 b'transactions': actions,
1667 1702 }
1668 1703 callconduit(ui, b'differential.revision.edit', params)
1669 1704
1670 1705
1671 1706 @eh.templatekeyword(b'phabreview', requires={b'ctx'})
1672 1707 def template_review(context, mapping):
1673 1708 """:phabreview: Object describing the review for this changeset.
1674 1709 Has attributes `url` and `id`.
1675 1710 """
1676 1711 ctx = context.resource(mapping, b'ctx')
1677 1712 m = _differentialrevisiondescre.search(ctx.description())
1678 1713 if m:
1679 1714 return templateutil.hybriddict(
1680 1715 {b'url': m.group('url'), b'id': b"D%s" % m.group('id'),}
1681 1716 )
1682 1717 else:
1683 1718 tags = ctx.repo().nodetags(ctx.node())
1684 1719 for t in tags:
1685 1720 if _differentialrevisiontagre.match(t):
1686 1721 url = ctx.repo().ui.config(b'phabricator', b'url')
1687 1722 if not url.endswith(b'/'):
1688 1723 url += b'/'
1689 1724 url += t
1690 1725
1691 1726 return templateutil.hybriddict({b'url': url, b'id': t,})
1692 1727 return None
1693 1728
1694 1729
1695 1730 @eh.templatekeyword(b'phabstatus', requires={b'ctx', b'repo', b'ui'})
1696 1731 def template_status(context, mapping):
1697 1732 """:phabstatus: String. Status of Phabricator differential.
1698 1733 """
1699 1734 ctx = context.resource(mapping, b'ctx')
1700 1735 repo = context.resource(mapping, b'repo')
1701 1736 ui = context.resource(mapping, b'ui')
1702 1737
1703 1738 rev = ctx.rev()
1704 1739 try:
1705 1740 drevid = getdrevmap(repo, [rev])[rev]
1706 1741 except KeyError:
1707 1742 return None
1708 1743 drevs = callconduit(ui, b'differential.query', {b'ids': [drevid]})
1709 1744 for drev in drevs:
1710 1745 if int(drev[b'id']) == drevid:
1711 1746 return templateutil.hybriddict(
1712 1747 {b'url': drev[b'uri'], b'status': drev[b'statusName'],}
1713 1748 )
1714 1749 return None
1715 1750
1716 1751
1717 1752 @show.showview(b'phabstatus', csettopic=b'work')
1718 1753 def phabstatusshowview(ui, repo, displayer):
1719 1754 """Phabricator differiential status"""
1720 1755 revs = repo.revs('sort(_underway(), topo)')
1721 1756 drevmap = getdrevmap(repo, revs)
1722 1757 unknownrevs, drevids, revsbydrevid = [], set([]), {}
1723 1758 for rev, drevid in pycompat.iteritems(drevmap):
1724 1759 if drevid is not None:
1725 1760 drevids.add(drevid)
1726 1761 revsbydrevid.setdefault(drevid, set([])).add(rev)
1727 1762 else:
1728 1763 unknownrevs.append(rev)
1729 1764
1730 1765 drevs = callconduit(ui, b'differential.query', {b'ids': list(drevids)})
1731 1766 drevsbyrev = {}
1732 1767 for drev in drevs:
1733 1768 for rev in revsbydrevid[int(drev[b'id'])]:
1734 1769 drevsbyrev[rev] = drev
1735 1770
1736 1771 def phabstatus(ctx):
1737 1772 drev = drevsbyrev[ctx.rev()]
1738 1773 status = ui.label(
1739 1774 b'%(statusName)s' % drev,
1740 1775 b'phabricator.status.%s' % _getstatusname(drev),
1741 1776 )
1742 1777 ui.write(b"\n%s %s\n" % (drev[b'uri'], status))
1743 1778
1744 1779 revs -= smartset.baseset(unknownrevs)
1745 1780 revdag = graphmod.dagwalker(repo, revs)
1746 1781
1747 1782 ui.setconfig(b'experimental', b'graphshorten', True)
1748 1783 displayer._exthook = phabstatus
1749 1784 nodelen = show.longestshortest(repo, revs)
1750 1785 logcmdutil.displaygraph(
1751 1786 ui,
1752 1787 repo,
1753 1788 revdag,
1754 1789 displayer,
1755 1790 graphmod.asciiedges,
1756 1791 props={b'nodelen': nodelen},
1757 1792 )
@@ -1,214 +1,246 b''
1 1 #require vcr
2 2 $ cat >> $HGRCPATH <<EOF
3 3 > [extensions]
4 4 > phabricator =
5 5 > EOF
6 6 $ hg init repo
7 7 $ cd repo
8 8 $ cat >> .hg/hgrc <<EOF
9 9 > [phabricator]
10 10 > url = https://phab.mercurial-scm.org/
11 11 > callsign = HG
12 12 >
13 13 > [auth]
14 14 > hgphab.schemes = https
15 15 > hgphab.prefix = phab.mercurial-scm.org
16 16 > # When working on the extension and making phabricator interaction
17 17 > # changes, edit this to be a real phabricator token. When done, edit
18 18 > # it back. The VCR transcripts will be auto-sanitised to replace your real
19 19 > # token with this value.
20 20 > hgphab.phabtoken = cli-hahayouwish
21 21 > EOF
22 22 $ VCR="$TESTDIR/phabricator"
23 23
24 24 Error is handled reasonably. We override the phabtoken here so that
25 25 when you're developing changes to phabricator.py you can edit the
26 26 above config and have a real token in the test but not have to edit
27 27 this test.
28 28 $ hg phabread --config auth.hgphab.phabtoken=cli-notavalidtoken \
29 29 > --test-vcr "$VCR/phabread-conduit-error.json" D4480 | head
30 30 abort: Conduit Error (ERR-INVALID-AUTH): API token "cli-notavalidtoken" has the wrong length. API tokens should be 32 characters long.
31 31
32 32 Basic phabread:
33 33 $ hg phabread --test-vcr "$VCR/phabread-4480.json" D4480 | head
34 34 # HG changeset patch
35 35 # Date 1536771503 0
36 36 # Parent a5de21c9e3703f8e8eb064bd7d893ff2f703c66a
37 37 exchangev2: start to implement pull with wire protocol v2
38 38
39 39 Wire protocol version 2 will take a substantially different
40 40 approach to exchange than version 1 (at least as far as pulling
41 41 is concerned).
42 42
43 43 This commit establishes a new exchangev2 module for holding
44 44
45 45 phabupdate with an accept:
46 46 $ hg phabupdate --accept D4564 \
47 47 > -m 'I think I like where this is headed. Will read rest of series later.'\
48 48 > --test-vcr "$VCR/accept-4564.json"
49 49 abort: Conduit Error (ERR-CONDUIT-CORE): Validation errors:
50 50 - You can not accept this revision because it has already been closed. Only open revisions can be accepted.
51 51 [255]
52 52 $ hg phabupdate --accept D7913 -m 'LGTM' --test-vcr "$VCR/accept-7913.json"
53 53
54 54 Create a differential diff:
55 55 $ HGENCODING=utf-8; export HGENCODING
56 56 $ echo alpha > alpha
57 57 $ hg ci --addremove -m 'create alpha for phabricator test €'
58 58 adding alpha
59 59 $ hg phabsend -r . --test-vcr "$VCR/phabsend-create-alpha.json"
60 60 D7915 - created - d386117f30e6: create alpha for phabricator test \xe2\x82\xac (esc)
61 61 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/d386117f30e6-24ffe649-phabsend.hg
62 62 $ echo more >> alpha
63 63 $ HGEDITOR=true hg ci --amend
64 64 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/347bf67801e5-3bf313e4-amend.hg
65 65 $ echo beta > beta
66 66 $ hg ci --addremove -m 'create beta for phabricator test'
67 67 adding beta
68 68 $ hg phabsend -r ".^::" --test-vcr "$VCR/phabsend-update-alpha-create-beta.json"
69 69 D7915 - updated - c44b38f24a45: create alpha for phabricator test \xe2\x82\xac (esc)
70 70 D7916 - created - 9e6901f21d5b: create beta for phabricator test
71 71 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/9e6901f21d5b-1fcd4f0e-phabsend.hg
72 72 $ unset HGENCODING
73 73
74 74 The amend won't explode after posting a public commit. The local tag is left
75 75 behind to identify it.
76 76
77 77 $ echo 'public change' > beta
78 78 $ hg ci -m 'create public change for phabricator testing'
79 79 $ hg phase --public .
80 80 $ echo 'draft change' > alpha
81 81 $ hg ci -m 'create draft change for phabricator testing'
82 82 $ hg phabsend --amend -r '.^::' --test-vcr "$VCR/phabsend-create-public.json"
83 83 D7917 - created - 7b4185ab5d16: create public change for phabricator testing
84 84 D7918 - created - 251c1c333fc6: create draft change for phabricator testing
85 85 warning: not updating public commit 2:7b4185ab5d16
86 86 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/251c1c333fc6-41cb7c3b-phabsend.hg
87 87 $ hg tags -v
88 88 tip 3:3244dc4a3334
89 89 D7917 2:7b4185ab5d16 local
90 90
91 91 $ hg debugcallconduit user.search --test-vcr "$VCR/phab-conduit.json" <<EOF
92 92 > {
93 93 > "constraints": {
94 94 > "isBot": true
95 95 > }
96 96 > }
97 97 > EOF
98 98 {
99 99 "cursor": {
100 100 "after": null,
101 101 "before": null,
102 102 "limit": 100,
103 103 "order": null
104 104 },
105 105 "data": [],
106 106 "maps": {},
107 107 "query": {
108 108 "queryKey": null
109 109 }
110 110 }
111 111
112 112 Template keywords
113 113 $ hg log -T'{rev} {phabreview|json}\n'
114 114 3 {"id": "D7918", "url": "https://phab.mercurial-scm.org/D7918"}
115 115 2 {"id": "D7917", "url": "https://phab.mercurial-scm.org/D7917"}
116 116 1 {"id": "D7916", "url": "https://phab.mercurial-scm.org/D7916"}
117 117 0 {"id": "D7915", "url": "https://phab.mercurial-scm.org/D7915"}
118 118
119 119 $ hg log -T'{rev} {if(phabreview, "{phabreview.url} {phabreview.id}")}\n'
120 120 3 https://phab.mercurial-scm.org/D7918 D7918
121 121 2 https://phab.mercurial-scm.org/D7917 D7917
122 122 1 https://phab.mercurial-scm.org/D7916 D7916
123 123 0 https://phab.mercurial-scm.org/D7915 D7915
124 124
125 125 Commenting when phabsending:
126 126 $ echo comment > comment
127 127 $ hg ci --addremove -m "create comment for phabricator test"
128 128 adding comment
129 129 $ hg phabsend -r . -m "For default branch" --test-vcr "$VCR/phabsend-comment-created.json"
130 130 D7919 - created - d5dddca9023d: create comment for phabricator test
131 131 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/d5dddca9023d-adf673ba-phabsend.hg
132 132 $ echo comment2 >> comment
133 133 $ hg ci --amend
134 134 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/f7db812bbe1d-8fcded77-amend.hg
135 135 $ hg phabsend -r . -m "Address review comments" --test-vcr "$VCR/phabsend-comment-updated.json"
136 136 D7919 - updated - 1849d7828727: create comment for phabricator test
137 137
138 138 Phabsending a skipped commit:
139 139 $ hg phabsend --no-amend -r . --test-vcr "$VCR/phabsend-skipped.json"
140 140 D7919 - skipped - 1849d7828727: create comment for phabricator test
141 141
142 142 Phabreading a DREV with a local:commits time as a string:
143 143 $ hg phabread --test-vcr "$VCR/phabread-str-time.json" D1285
144 144 # HG changeset patch
145 145 # User Pulkit Goyal <7895pulkit@gmail.com>
146 146 # Date 1509404054 -19800
147 147 # Node ID 44fc1c1f1774a76423b9c732af6938435099bcc5
148 148 # Parent 8feef8ef8389a3b544e0a74624f1efc3a8d85d35
149 149 repoview: add a new attribute _visibilityexceptions and related API
150 150
151 151 Currently we don't have a defined way in core to make some hidden revisions
152 152 visible in filtered repo. Extensions to achieve the purpose of unhiding some
153 153 hidden commits, wrap repoview.pinnedrevs() function.
154 154
155 155 To make the above task simple and have well defined API, this patch adds a new
156 156 attribute '_visibilityexceptions' to repoview class which will contains
157 157 the hidden revs which should be exception.
158 158 This will allow to set different exceptions for different repoview objects
159 159 backed by the same unfiltered repo.
160 160
161 161 This patch also adds API to add revs to the attribute set and get them.
162 162
163 163 Thanks to Jun for suggesting the use of repoview class instead of localrepo.
164 164
165 165 Differential Revision: https://phab.mercurial-scm.org/D1285
166 166 diff --git a/mercurial/repoview.py b/mercurial/repoview.py
167 167 --- a/mercurial/repoview.py
168 168 +++ b/mercurial/repoview.py
169 169 @@ * @@ (glob)
170 170 subclasses of `localrepo`. Eg: `bundlerepo` or `statichttprepo`.
171 171 """
172 172
173 173 + # hidden revs which should be visible
174 174 + _visibilityexceptions = set()
175 175 +
176 176 def __init__(self, repo, filtername):
177 177 object.__setattr__(self, r'_unfilteredrepo', repo)
178 178 object.__setattr__(self, r'filtername', filtername)
179 179 @@ -231,6 +234,14 @@
180 180 return self
181 181 return self.unfiltered().filtered(name)
182 182
183 183 + def addvisibilityexceptions(self, revs):
184 184 + """adds hidden revs which should be visible to set of exceptions"""
185 185 + self._visibilityexceptions.update(revs)
186 186 +
187 187 + def getvisibilityexceptions(self):
188 188 + """returns the set of hidden revs which should be visible"""
189 189 + return self._visibilityexceptions
190 190 +
191 191 # everything access are forwarded to the proxied repo
192 192 def __getattr__(self, attr):
193 193 return getattr(self._unfilteredrepo, attr)
194 194 diff --git a/mercurial/localrepo.py b/mercurial/localrepo.py
195 195 --- a/mercurial/localrepo.py
196 196 +++ b/mercurial/localrepo.py
197 197 @@ -570,6 +570,14 @@
198 198 def close(self):
199 199 self._writecaches()
200 200
201 201 + def addvisibilityexceptions(self, exceptions):
202 202 + # should be called on a filtered repository
203 203 + pass
204 204 +
205 205 + def getvisibilityexceptions(self):
206 206 + # should be called on a filtered repository
207 207 + return set()
208 208 +
209 209 def _loadextensions(self):
210 210 extensions.loadall(self.ui)
211 211
212 212
213 A bad .arcconfig doesn't error out
214 $ echo 'garbage' > .arcconfig
215 $ hg config phabricator --debug
216 invalid JSON in $TESTTMP/repo/.arcconfig
217 read config from: */.hgrc (glob)
218 $TESTTMP/repo/.hg/hgrc:*: phabricator.url=https://phab.mercurial-scm.org/ (glob)
219 $TESTTMP/repo/.hg/hgrc:*: phabricator.callsign=HG (glob)
220
221 The .arcconfig content overrides global config
222 $ cat >> $HGRCPATH << EOF
223 > [phabricator]
224 > url = global
225 > callsign = global
226 > EOF
227 $ cp $TESTDIR/../.arcconfig .
228 $ mv .hg/hgrc .hg/hgrc.bak
229 $ hg config phabricator --debug
230 read config from: */.hgrc (glob)
231 */.hgrc:*: phabricator.url=global (glob)
232 $TESTTMP/repo/.arcconfig: phabricator.callsign=HG
233
234 But it doesn't override local config
235 $ cat >> .hg/hgrc << EOF
236 > [phabricator]
237 > url = local
238 > callsign = local
239 > EOF
240 $ hg config phabricator --debug
241 read config from: */.hgrc (glob)
242 $TESTTMP/repo/.hg/hgrc:*: phabricator.url=local (glob)
243 $TESTTMP/repo/.hg/hgrc:*: phabricator.callsign=local (glob)
244 $ mv .hg/hgrc.bak .hg/hgrc
213 245
214 246 $ cd ..
General Comments 0
You need to be logged in to leave comments. Login now