##// END OF EJS Templates
phabricator: fallback to reading metadata from diff for phabread...
Ian Moody -
r42442:a4f7dceb default
parent child Browse files
Show More
@@ -1,1043 +1,1052 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 contextlib
45 45 import itertools
46 46 import json
47 47 import operator
48 48 import re
49 49
50 50 from mercurial.node import bin, nullid
51 51 from mercurial.i18n import _
52 52 from mercurial import (
53 53 cmdutil,
54 54 context,
55 55 encoding,
56 56 error,
57 57 httpconnection as httpconnectionmod,
58 58 mdiff,
59 59 obsutil,
60 60 parser,
61 61 patch,
62 62 phases,
63 63 pycompat,
64 64 registrar,
65 65 scmutil,
66 66 smartset,
67 67 tags,
68 68 templatefilters,
69 69 templateutil,
70 70 url as urlmod,
71 71 util,
72 72 )
73 73 from mercurial.utils import (
74 74 procutil,
75 75 stringutil,
76 76 )
77 77
78 78 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
79 79 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
80 80 # be specifying the version(s) of Mercurial they are tested with, or
81 81 # leave the attribute unspecified.
82 82 testedwith = 'ships-with-hg-core'
83 83
84 84 cmdtable = {}
85 85 command = registrar.command(cmdtable)
86 86
87 87 configtable = {}
88 88 configitem = registrar.configitem(configtable)
89 89
90 90 # developer config: phabricator.batchsize
91 91 configitem(b'phabricator', b'batchsize',
92 92 default=12,
93 93 )
94 94 configitem(b'phabricator', b'callsign',
95 95 default=None,
96 96 )
97 97 configitem(b'phabricator', b'curlcmd',
98 98 default=None,
99 99 )
100 100 # developer config: phabricator.repophid
101 101 configitem(b'phabricator', b'repophid',
102 102 default=None,
103 103 )
104 104 configitem(b'phabricator', b'url',
105 105 default=None,
106 106 )
107 107 configitem(b'phabsend', b'confirm',
108 108 default=False,
109 109 )
110 110
111 111 colortable = {
112 112 b'phabricator.action.created': b'green',
113 113 b'phabricator.action.skipped': b'magenta',
114 114 b'phabricator.action.updated': b'magenta',
115 115 b'phabricator.desc': b'',
116 116 b'phabricator.drev': b'bold',
117 117 b'phabricator.node': b'',
118 118 }
119 119
120 120 _VCR_FLAGS = [
121 121 (b'', b'test-vcr', b'',
122 122 _(b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
123 123 b', otherwise will mock all http requests using the specified vcr file.'
124 124 b' (ADVANCED)'
125 125 )),
126 126 ]
127 127
128 128 def vcrcommand(name, flags, spec, helpcategory=None):
129 129 fullflags = flags + _VCR_FLAGS
130 130 def decorate(fn):
131 131 def inner(*args, **kwargs):
132 132 cassette = pycompat.fsdecode(kwargs.pop(r'test_vcr', None))
133 133 if cassette:
134 134 import hgdemandimport
135 135 with hgdemandimport.deactivated():
136 136 import vcr as vcrmod
137 137 import vcr.stubs as stubs
138 138 vcr = vcrmod.VCR(
139 139 serializer=r'json',
140 140 custom_patches=[
141 141 (urlmod, r'httpconnection',
142 142 stubs.VCRHTTPConnection),
143 143 (urlmod, r'httpsconnection',
144 144 stubs.VCRHTTPSConnection),
145 145 ])
146 146 with vcr.use_cassette(cassette):
147 147 return fn(*args, **kwargs)
148 148 return fn(*args, **kwargs)
149 149 inner.__name__ = fn.__name__
150 150 inner.__doc__ = fn.__doc__
151 151 return command(name, fullflags, spec, helpcategory=helpcategory)(inner)
152 152 return decorate
153 153
154 154 def urlencodenested(params):
155 155 """like urlencode, but works with nested parameters.
156 156
157 157 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
158 158 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
159 159 urlencode. Note: the encoding is consistent with PHP's http_build_query.
160 160 """
161 161 flatparams = util.sortdict()
162 162 def process(prefix, obj):
163 163 if isinstance(obj, bool):
164 164 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
165 165 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
166 166 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
167 167 if items is None:
168 168 flatparams[prefix] = obj
169 169 else:
170 170 for k, v in items(obj):
171 171 if prefix:
172 172 process(b'%s[%s]' % (prefix, k), v)
173 173 else:
174 174 process(k, v)
175 175 process(b'', params)
176 176 return util.urlreq.urlencode(flatparams)
177 177
178 178 def readurltoken(repo):
179 179 """return conduit url, token and make sure they exist
180 180
181 181 Currently read from [auth] config section. In the future, it might
182 182 make sense to read from .arcconfig and .arcrc as well.
183 183 """
184 184 url = repo.ui.config(b'phabricator', b'url')
185 185 if not url:
186 186 raise error.Abort(_(b'config %s.%s is required')
187 187 % (b'phabricator', b'url'))
188 188
189 189 res = httpconnectionmod.readauthforuri(repo.ui, url, util.url(url).user)
190 190 token = None
191 191
192 192 if res:
193 193 group, auth = res
194 194
195 195 repo.ui.debug(b"using auth.%s.* for authentication\n" % group)
196 196
197 197 token = auth.get(b'phabtoken')
198 198
199 199 if not token:
200 200 raise error.Abort(_(b'Can\'t find conduit token associated to %s')
201 201 % (url,))
202 202
203 203 return url, token
204 204
205 205 def callconduit(repo, name, params):
206 206 """call Conduit API, params is a dict. return json.loads result, or None"""
207 207 host, token = readurltoken(repo)
208 208 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
209 209 repo.ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
210 210 params = params.copy()
211 211 params[b'api.token'] = token
212 212 data = urlencodenested(params)
213 213 curlcmd = repo.ui.config(b'phabricator', b'curlcmd')
214 214 if curlcmd:
215 215 sin, sout = procutil.popen2(b'%s -d @- %s'
216 216 % (curlcmd, procutil.shellquote(url)))
217 217 sin.write(data)
218 218 sin.close()
219 219 body = sout.read()
220 220 else:
221 221 urlopener = urlmod.opener(repo.ui, authinfo)
222 222 request = util.urlreq.request(pycompat.strurl(url), data=data)
223 223 with contextlib.closing(urlopener.open(request)) as rsp:
224 224 body = rsp.read()
225 225 repo.ui.debug(b'Conduit Response: %s\n' % body)
226 226 parsed = pycompat.rapply(
227 227 lambda x: encoding.unitolocal(x) if isinstance(x, pycompat.unicode)
228 228 else x,
229 229 json.loads(body)
230 230 )
231 231 if parsed.get(b'error_code'):
232 232 msg = (_(b'Conduit Error (%s): %s')
233 233 % (parsed[b'error_code'], parsed[b'error_info']))
234 234 raise error.Abort(msg)
235 235 return parsed[b'result']
236 236
237 237 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'))
238 238 def debugcallconduit(ui, repo, name):
239 239 """call Conduit API
240 240
241 241 Call parameters are read from stdin as a JSON blob. Result will be written
242 242 to stdout as a JSON blob.
243 243 """
244 244 # json.loads only accepts bytes from 3.6+
245 245 rawparams = encoding.unifromlocal(ui.fin.read())
246 246 # json.loads only returns unicode strings
247 247 params = pycompat.rapply(lambda x:
248 248 encoding.unitolocal(x) if isinstance(x, pycompat.unicode) else x,
249 249 json.loads(rawparams)
250 250 )
251 251 # json.dumps only accepts unicode strings
252 252 result = pycompat.rapply(lambda x:
253 253 encoding.unifromlocal(x) if isinstance(x, bytes) else x,
254 254 callconduit(repo, name, params)
255 255 )
256 256 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
257 257 ui.write(b'%s\n' % encoding.unitolocal(s))
258 258
259 259 def getrepophid(repo):
260 260 """given callsign, return repository PHID or None"""
261 261 # developer config: phabricator.repophid
262 262 repophid = repo.ui.config(b'phabricator', b'repophid')
263 263 if repophid:
264 264 return repophid
265 265 callsign = repo.ui.config(b'phabricator', b'callsign')
266 266 if not callsign:
267 267 return None
268 268 query = callconduit(repo, b'diffusion.repository.search',
269 269 {b'constraints': {b'callsigns': [callsign]}})
270 270 if len(query[b'data']) == 0:
271 271 return None
272 272 repophid = query[b'data'][0][b'phid']
273 273 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
274 274 return repophid
275 275
276 276 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
277 277 _differentialrevisiondescre = re.compile(
278 278 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M)
279 279
280 280 def getoldnodedrevmap(repo, nodelist):
281 281 """find previous nodes that has been sent to Phabricator
282 282
283 283 return {node: (oldnode, Differential diff, Differential Revision ID)}
284 284 for node in nodelist with known previous sent versions, or associated
285 285 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
286 286 be ``None``.
287 287
288 288 Examines commit messages like "Differential Revision:" to get the
289 289 association information.
290 290
291 291 If such commit message line is not found, examines all precursors and their
292 292 tags. Tags with format like "D1234" are considered a match and the node
293 293 with that tag, and the number after "D" (ex. 1234) will be returned.
294 294
295 295 The ``old node``, if not None, is guaranteed to be the last diff of
296 296 corresponding Differential Revision, and exist in the repo.
297 297 """
298 298 unfi = repo.unfiltered()
299 299 nodemap = unfi.changelog.nodemap
300 300
301 301 result = {} # {node: (oldnode?, lastdiff?, drev)}
302 302 toconfirm = {} # {node: (force, {precnode}, drev)}
303 303 for node in nodelist:
304 304 ctx = unfi[node]
305 305 # For tags like "D123", put them into "toconfirm" to verify later
306 306 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
307 307 for n in precnodes:
308 308 if n in nodemap:
309 309 for tag in unfi.nodetags(n):
310 310 m = _differentialrevisiontagre.match(tag)
311 311 if m:
312 312 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
313 313 continue
314 314
315 315 # Check commit message
316 316 m = _differentialrevisiondescre.search(ctx.description())
317 317 if m:
318 318 toconfirm[node] = (1, set(precnodes), int(m.group(r'id')))
319 319
320 320 # Double check if tags are genuine by collecting all old nodes from
321 321 # Phabricator, and expect precursors overlap with it.
322 322 if toconfirm:
323 323 drevs = [drev for force, precs, drev in toconfirm.values()]
324 324 alldiffs = callconduit(unfi, b'differential.querydiffs',
325 325 {b'revisionIDs': drevs})
326 326 getnode = lambda d: bin(
327 327 getdiffmeta(d).get(b'node', b'')) or None
328 328 for newnode, (force, precset, drev) in toconfirm.items():
329 329 diffs = [d for d in alldiffs.values()
330 330 if int(d[b'revisionID']) == drev]
331 331
332 332 # "precursors" as known by Phabricator
333 333 phprecset = set(getnode(d) for d in diffs)
334 334
335 335 # Ignore if precursors (Phabricator and local repo) do not overlap,
336 336 # and force is not set (when commit message says nothing)
337 337 if not force and not bool(phprecset & precset):
338 338 tagname = b'D%d' % drev
339 339 tags.tag(repo, tagname, nullid, message=None, user=None,
340 340 date=None, local=True)
341 341 unfi.ui.warn(_(b'D%s: local tag removed - does not match '
342 342 b'Differential history\n') % drev)
343 343 continue
344 344
345 345 # Find the last node using Phabricator metadata, and make sure it
346 346 # exists in the repo
347 347 oldnode = lastdiff = None
348 348 if diffs:
349 349 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
350 350 oldnode = getnode(lastdiff)
351 351 if oldnode and oldnode not in nodemap:
352 352 oldnode = None
353 353
354 354 result[newnode] = (oldnode, lastdiff, drev)
355 355
356 356 return result
357 357
358 358 def getdiff(ctx, diffopts):
359 359 """plain-text diff without header (user, commit message, etc)"""
360 360 output = util.stringio()
361 361 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
362 362 None, opts=diffopts):
363 363 output.write(chunk)
364 364 return output.getvalue()
365 365
366 366 def creatediff(ctx):
367 367 """create a Differential Diff"""
368 368 repo = ctx.repo()
369 369 repophid = getrepophid(repo)
370 370 # Create a "Differential Diff" via "differential.createrawdiff" API
371 371 params = {b'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
372 372 if repophid:
373 373 params[b'repositoryPHID'] = repophid
374 374 diff = callconduit(repo, b'differential.createrawdiff', params)
375 375 if not diff:
376 376 raise error.Abort(_(b'cannot create diff for %s') % ctx)
377 377 return diff
378 378
379 379 def writediffproperties(ctx, diff):
380 380 """write metadata to diff so patches could be applied losslessly"""
381 381 params = {
382 382 b'diff_id': diff[b'id'],
383 383 b'name': b'hg:meta',
384 384 b'data': templatefilters.json({
385 385 b'user': ctx.user(),
386 386 b'date': b'%d %d' % ctx.date(),
387 387 b'branch': ctx.branch(),
388 388 b'node': ctx.hex(),
389 389 b'parent': ctx.p1().hex(),
390 390 }),
391 391 }
392 392 callconduit(ctx.repo(), b'differential.setdiffproperty', params)
393 393
394 394 params = {
395 395 b'diff_id': diff[b'id'],
396 396 b'name': b'local:commits',
397 397 b'data': templatefilters.json({
398 398 ctx.hex(): {
399 399 b'author': stringutil.person(ctx.user()),
400 400 b'authorEmail': stringutil.email(ctx.user()),
401 401 b'time': int(ctx.date()[0]),
402 402 b'commit': ctx.hex(),
403 403 b'parents': [ctx.p1().hex()],
404 404 b'branch': ctx.branch(),
405 405 },
406 406 }),
407 407 }
408 408 callconduit(ctx.repo(), b'differential.setdiffproperty', params)
409 409
410 410 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
411 411 olddiff=None, actions=None):
412 412 """create or update a Differential Revision
413 413
414 414 If revid is None, create a new Differential Revision, otherwise update
415 415 revid. If parentrevid is not None, set it as a dependency.
416 416
417 417 If oldnode is not None, check if the patch content (without commit message
418 418 and metadata) has changed before creating another diff.
419 419
420 420 If actions is not None, they will be appended to the transaction.
421 421 """
422 422 repo = ctx.repo()
423 423 if oldnode:
424 424 diffopts = mdiff.diffopts(git=True, context=32767)
425 425 oldctx = repo.unfiltered()[oldnode]
426 426 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
427 427 else:
428 428 neednewdiff = True
429 429
430 430 transactions = []
431 431 if neednewdiff:
432 432 diff = creatediff(ctx)
433 433 transactions.append({b'type': b'update', b'value': diff[b'phid']})
434 434 else:
435 435 # Even if we don't need to upload a new diff because the patch content
436 436 # does not change. We might still need to update its metadata so
437 437 # pushers could know the correct node metadata.
438 438 assert olddiff
439 439 diff = olddiff
440 440 writediffproperties(ctx, diff)
441 441
442 442 # Use a temporary summary to set dependency. There might be better ways but
443 443 # I cannot find them for now. But do not do that if we are updating an
444 444 # existing revision (revid is not None) since that introduces visible
445 445 # churns (someone edited "Summary" twice) on the web page.
446 446 if parentrevid and revid is None:
447 447 summary = b'Depends on D%d' % parentrevid
448 448 transactions += [{b'type': b'summary', b'value': summary},
449 449 {b'type': b'summary', b'value': b' '}]
450 450
451 451 if actions:
452 452 transactions += actions
453 453
454 454 # Parse commit message and update related fields.
455 455 desc = ctx.description()
456 456 info = callconduit(repo, b'differential.parsecommitmessage',
457 457 {b'corpus': desc})
458 458 for k, v in info[b'fields'].items():
459 459 if k in [b'title', b'summary', b'testPlan']:
460 460 transactions.append({b'type': k, b'value': v})
461 461
462 462 params = {b'transactions': transactions}
463 463 if revid is not None:
464 464 # Update an existing Differential Revision
465 465 params[b'objectIdentifier'] = revid
466 466
467 467 revision = callconduit(repo, b'differential.revision.edit', params)
468 468 if not revision:
469 469 raise error.Abort(_(b'cannot create revision for %s') % ctx)
470 470
471 471 return revision, diff
472 472
473 473 def userphids(repo, names):
474 474 """convert user names to PHIDs"""
475 475 names = [name.lower() for name in names]
476 476 query = {b'constraints': {b'usernames': names}}
477 477 result = callconduit(repo, b'user.search', query)
478 478 # username not found is not an error of the API. So check if we have missed
479 479 # some names here.
480 480 data = result[b'data']
481 481 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
482 482 unresolved = set(names) - resolved
483 483 if unresolved:
484 484 raise error.Abort(_(b'unknown username: %s')
485 485 % b' '.join(sorted(unresolved)))
486 486 return [entry[b'phid'] for entry in data]
487 487
488 488 @vcrcommand(b'phabsend',
489 489 [(b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
490 490 (b'', b'amend', True, _(b'update commit messages')),
491 491 (b'', b'reviewer', [], _(b'specify reviewers')),
492 492 (b'', b'confirm', None, _(b'ask for confirmation before sending'))],
493 493 _(b'REV [OPTIONS]'),
494 494 helpcategory=command.CATEGORY_IMPORT_EXPORT)
495 495 def phabsend(ui, repo, *revs, **opts):
496 496 """upload changesets to Phabricator
497 497
498 498 If there are multiple revisions specified, they will be send as a stack
499 499 with a linear dependencies relationship using the order specified by the
500 500 revset.
501 501
502 502 For the first time uploading changesets, local tags will be created to
503 503 maintain the association. After the first time, phabsend will check
504 504 obsstore and tags information so it can figure out whether to update an
505 505 existing Differential Revision, or create a new one.
506 506
507 507 If --amend is set, update commit messages so they have the
508 508 ``Differential Revision`` URL, remove related tags. This is similar to what
509 509 arcanist will do, and is more desired in author-push workflows. Otherwise,
510 510 use local tags to record the ``Differential Revision`` association.
511 511
512 512 The --confirm option lets you confirm changesets before sending them. You
513 513 can also add following to your configuration file to make it default
514 514 behaviour::
515 515
516 516 [phabsend]
517 517 confirm = true
518 518
519 519 phabsend will check obsstore and the above association to decide whether to
520 520 update an existing Differential Revision, or create a new one.
521 521 """
522 522 opts = pycompat.byteskwargs(opts)
523 523 revs = list(revs) + opts.get(b'rev', [])
524 524 revs = scmutil.revrange(repo, revs)
525 525
526 526 if not revs:
527 527 raise error.Abort(_(b'phabsend requires at least one changeset'))
528 528 if opts.get(b'amend'):
529 529 cmdutil.checkunfinished(repo)
530 530
531 531 # {newnode: (oldnode, olddiff, olddrev}
532 532 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
533 533
534 534 confirm = ui.configbool(b'phabsend', b'confirm')
535 535 confirm |= bool(opts.get(b'confirm'))
536 536 if confirm:
537 537 confirmed = _confirmbeforesend(repo, revs, oldmap)
538 538 if not confirmed:
539 539 raise error.Abort(_(b'phabsend cancelled'))
540 540
541 541 actions = []
542 542 reviewers = opts.get(b'reviewer', [])
543 543 if reviewers:
544 544 phids = userphids(repo, reviewers)
545 545 actions.append({b'type': b'reviewers.add', b'value': phids})
546 546
547 547 drevids = [] # [int]
548 548 diffmap = {} # {newnode: diff}
549 549
550 550 # Send patches one by one so we know their Differential Revision IDs and
551 551 # can provide dependency relationship
552 552 lastrevid = None
553 553 for rev in revs:
554 554 ui.debug(b'sending rev %d\n' % rev)
555 555 ctx = repo[rev]
556 556
557 557 # Get Differential Revision ID
558 558 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
559 559 if oldnode != ctx.node() or opts.get(b'amend'):
560 560 # Create or update Differential Revision
561 561 revision, diff = createdifferentialrevision(
562 562 ctx, revid, lastrevid, oldnode, olddiff, actions)
563 563 diffmap[ctx.node()] = diff
564 564 newrevid = int(revision[b'object'][b'id'])
565 565 if revid:
566 566 action = b'updated'
567 567 else:
568 568 action = b'created'
569 569
570 570 # Create a local tag to note the association, if commit message
571 571 # does not have it already
572 572 m = _differentialrevisiondescre.search(ctx.description())
573 573 if not m or int(m.group(r'id')) != newrevid:
574 574 tagname = b'D%d' % newrevid
575 575 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
576 576 date=None, local=True)
577 577 else:
578 578 # Nothing changed. But still set "newrevid" so the next revision
579 579 # could depend on this one.
580 580 newrevid = revid
581 581 action = b'skipped'
582 582
583 583 actiondesc = ui.label(
584 584 {b'created': _(b'created'),
585 585 b'skipped': _(b'skipped'),
586 586 b'updated': _(b'updated')}[action],
587 587 b'phabricator.action.%s' % action)
588 588 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
589 589 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
590 590 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
591 591 ui.write(_(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc,
592 592 desc))
593 593 drevids.append(newrevid)
594 594 lastrevid = newrevid
595 595
596 596 # Update commit messages and remove tags
597 597 if opts.get(b'amend'):
598 598 unfi = repo.unfiltered()
599 599 drevs = callconduit(repo, b'differential.query', {b'ids': drevids})
600 600 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
601 601 wnode = unfi[b'.'].node()
602 602 mapping = {} # {oldnode: [newnode]}
603 603 for i, rev in enumerate(revs):
604 604 old = unfi[rev]
605 605 drevid = drevids[i]
606 606 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
607 607 newdesc = getdescfromdrev(drev)
608 608 # Make sure commit message contain "Differential Revision"
609 609 if old.description() != newdesc:
610 610 if old.phase() == phases.public:
611 611 ui.warn(_("warning: not updating public commit %s\n")
612 612 % scmutil.formatchangeid(old))
613 613 continue
614 614 parents = [
615 615 mapping.get(old.p1().node(), (old.p1(),))[0],
616 616 mapping.get(old.p2().node(), (old.p2(),))[0],
617 617 ]
618 618 new = context.metadataonlyctx(
619 619 repo, old, parents=parents, text=newdesc,
620 620 user=old.user(), date=old.date(), extra=old.extra())
621 621
622 622 newnode = new.commit()
623 623
624 624 mapping[old.node()] = [newnode]
625 625 # Update diff property
626 626 writediffproperties(unfi[newnode], diffmap[old.node()])
627 627 # Remove local tags since it's no longer necessary
628 628 tagname = b'D%d' % drevid
629 629 if tagname in repo.tags():
630 630 tags.tag(repo, tagname, nullid, message=None, user=None,
631 631 date=None, local=True)
632 632 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
633 633 if wnode in mapping:
634 634 unfi.setparents(mapping[wnode][0])
635 635
636 636 # Map from "hg:meta" keys to header understood by "hg import". The order is
637 637 # consistent with "hg export" output.
638 638 _metanamemap = util.sortdict([(b'user', b'User'), (b'date', b'Date'),
639 639 (b'node', b'Node ID'), (b'parent', b'Parent ')])
640 640
641 641 def _confirmbeforesend(repo, revs, oldmap):
642 642 url, token = readurltoken(repo)
643 643 ui = repo.ui
644 644 for rev in revs:
645 645 ctx = repo[rev]
646 646 desc = ctx.description().splitlines()[0]
647 647 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
648 648 if drevid:
649 649 drevdesc = ui.label(b'D%s' % drevid, b'phabricator.drev')
650 650 else:
651 651 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
652 652
653 653 ui.write(_(b'%s - %s: %s\n')
654 654 % (drevdesc,
655 655 ui.label(bytes(ctx), b'phabricator.node'),
656 656 ui.label(desc, b'phabricator.desc')))
657 657
658 658 if ui.promptchoice(_(b'Send the above changes to %s (yn)?'
659 659 b'$$ &Yes $$ &No') % url):
660 660 return False
661 661
662 662 return True
663 663
664 664 _knownstatusnames = {b'accepted', b'needsreview', b'needsrevision', b'closed',
665 665 b'abandoned'}
666 666
667 667 def _getstatusname(drev):
668 668 """get normalized status name from a Differential Revision"""
669 669 return drev[b'statusName'].replace(b' ', b'').lower()
670 670
671 671 # Small language to specify differential revisions. Support symbols: (), :X,
672 672 # +, and -.
673 673
674 674 _elements = {
675 675 # token-type: binding-strength, primary, prefix, infix, suffix
676 676 b'(': (12, None, (b'group', 1, b')'), None, None),
677 677 b':': (8, None, (b'ancestors', 8), None, None),
678 678 b'&': (5, None, None, (b'and_', 5), None),
679 679 b'+': (4, None, None, (b'add', 4), None),
680 680 b'-': (4, None, None, (b'sub', 4), None),
681 681 b')': (0, None, None, None, None),
682 682 b'symbol': (0, b'symbol', None, None, None),
683 683 b'end': (0, None, None, None, None),
684 684 }
685 685
686 686 def _tokenize(text):
687 687 view = memoryview(text) # zero-copy slice
688 688 special = b'():+-& '
689 689 pos = 0
690 690 length = len(text)
691 691 while pos < length:
692 692 symbol = b''.join(itertools.takewhile(lambda ch: ch not in special,
693 693 pycompat.iterbytestr(view[pos:])))
694 694 if symbol:
695 695 yield (b'symbol', symbol, pos)
696 696 pos += len(symbol)
697 697 else: # special char, ignore space
698 698 if text[pos] != b' ':
699 699 yield (text[pos], None, pos)
700 700 pos += 1
701 701 yield (b'end', None, pos)
702 702
703 703 def _parse(text):
704 704 tree, pos = parser.parser(_elements).parse(_tokenize(text))
705 705 if pos != len(text):
706 706 raise error.ParseError(b'invalid token', pos)
707 707 return tree
708 708
709 709 def _parsedrev(symbol):
710 710 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
711 711 if symbol.startswith(b'D') and symbol[1:].isdigit():
712 712 return int(symbol[1:])
713 713 if symbol.isdigit():
714 714 return int(symbol)
715 715
716 716 def _prefetchdrevs(tree):
717 717 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
718 718 drevs = set()
719 719 ancestordrevs = set()
720 720 op = tree[0]
721 721 if op == b'symbol':
722 722 r = _parsedrev(tree[1])
723 723 if r:
724 724 drevs.add(r)
725 725 elif op == b'ancestors':
726 726 r, a = _prefetchdrevs(tree[1])
727 727 drevs.update(r)
728 728 ancestordrevs.update(r)
729 729 ancestordrevs.update(a)
730 730 else:
731 731 for t in tree[1:]:
732 732 r, a = _prefetchdrevs(t)
733 733 drevs.update(r)
734 734 ancestordrevs.update(a)
735 735 return drevs, ancestordrevs
736 736
737 737 def querydrev(repo, spec):
738 738 """return a list of "Differential Revision" dicts
739 739
740 740 spec is a string using a simple query language, see docstring in phabread
741 741 for details.
742 742
743 743 A "Differential Revision dict" looks like:
744 744
745 745 {
746 746 "id": "2",
747 747 "phid": "PHID-DREV-672qvysjcczopag46qty",
748 748 "title": "example",
749 749 "uri": "https://phab.example.com/D2",
750 750 "dateCreated": "1499181406",
751 751 "dateModified": "1499182103",
752 752 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
753 753 "status": "0",
754 754 "statusName": "Needs Review",
755 755 "properties": [],
756 756 "branch": null,
757 757 "summary": "",
758 758 "testPlan": "",
759 759 "lineCount": "2",
760 760 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
761 761 "diffs": [
762 762 "3",
763 763 "4",
764 764 ],
765 765 "commits": [],
766 766 "reviewers": [],
767 767 "ccs": [],
768 768 "hashes": [],
769 769 "auxiliary": {
770 770 "phabricator:projects": [],
771 771 "phabricator:depends-on": [
772 772 "PHID-DREV-gbapp366kutjebt7agcd"
773 773 ]
774 774 },
775 775 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
776 776 "sourcePath": null
777 777 }
778 778 """
779 779 def fetch(params):
780 780 """params -> single drev or None"""
781 781 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
782 782 if key in prefetched:
783 783 return prefetched[key]
784 784 drevs = callconduit(repo, b'differential.query', params)
785 785 # Fill prefetched with the result
786 786 for drev in drevs:
787 787 prefetched[drev[b'phid']] = drev
788 788 prefetched[int(drev[b'id'])] = drev
789 789 if key not in prefetched:
790 790 raise error.Abort(_(b'cannot get Differential Revision %r')
791 791 % params)
792 792 return prefetched[key]
793 793
794 794 def getstack(topdrevids):
795 795 """given a top, get a stack from the bottom, [id] -> [id]"""
796 796 visited = set()
797 797 result = []
798 798 queue = [{b'ids': [i]} for i in topdrevids]
799 799 while queue:
800 800 params = queue.pop()
801 801 drev = fetch(params)
802 802 if drev[b'id'] in visited:
803 803 continue
804 804 visited.add(drev[b'id'])
805 805 result.append(int(drev[b'id']))
806 806 auxiliary = drev.get(b'auxiliary', {})
807 807 depends = auxiliary.get(b'phabricator:depends-on', [])
808 808 for phid in depends:
809 809 queue.append({b'phids': [phid]})
810 810 result.reverse()
811 811 return smartset.baseset(result)
812 812
813 813 # Initialize prefetch cache
814 814 prefetched = {} # {id or phid: drev}
815 815
816 816 tree = _parse(spec)
817 817 drevs, ancestordrevs = _prefetchdrevs(tree)
818 818
819 819 # developer config: phabricator.batchsize
820 820 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
821 821
822 822 # Prefetch Differential Revisions in batch
823 823 tofetch = set(drevs)
824 824 for r in ancestordrevs:
825 825 tofetch.update(range(max(1, r - batchsize), r + 1))
826 826 if drevs:
827 827 fetch({b'ids': list(tofetch)})
828 828 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
829 829
830 830 # Walk through the tree, return smartsets
831 831 def walk(tree):
832 832 op = tree[0]
833 833 if op == b'symbol':
834 834 drev = _parsedrev(tree[1])
835 835 if drev:
836 836 return smartset.baseset([drev])
837 837 elif tree[1] in _knownstatusnames:
838 838 drevs = [r for r in validids
839 839 if _getstatusname(prefetched[r]) == tree[1]]
840 840 return smartset.baseset(drevs)
841 841 else:
842 842 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
843 843 elif op in {b'and_', b'add', b'sub'}:
844 844 assert len(tree) == 3
845 845 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
846 846 elif op == b'group':
847 847 return walk(tree[1])
848 848 elif op == b'ancestors':
849 849 return getstack(walk(tree[1]))
850 850 else:
851 851 raise error.ProgrammingError(b'illegal tree: %r' % tree)
852 852
853 853 return [prefetched[r] for r in walk(tree)]
854 854
855 855 def getdescfromdrev(drev):
856 856 """get description (commit message) from "Differential Revision"
857 857
858 858 This is similar to differential.getcommitmessage API. But we only care
859 859 about limited fields: title, summary, test plan, and URL.
860 860 """
861 861 title = drev[b'title']
862 862 summary = drev[b'summary'].rstrip()
863 863 testplan = drev[b'testPlan'].rstrip()
864 864 if testplan:
865 865 testplan = b'Test Plan:\n%s' % testplan
866 866 uri = b'Differential Revision: %s' % drev[b'uri']
867 867 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
868 868
869 869 def getdiffmeta(diff):
870 870 """get commit metadata (date, node, user, p1) from a diff object
871 871
872 872 The metadata could be "hg:meta", sent by phabsend, like:
873 873
874 874 "properties": {
875 875 "hg:meta": {
876 876 "date": "1499571514 25200",
877 877 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
878 878 "user": "Foo Bar <foo@example.com>",
879 879 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
880 880 }
881 881 }
882 882
883 883 Or converted from "local:commits", sent by "arc", like:
884 884
885 885 "properties": {
886 886 "local:commits": {
887 887 "98c08acae292b2faf60a279b4189beb6cff1414d": {
888 888 "author": "Foo Bar",
889 889 "time": 1499546314,
890 890 "branch": "default",
891 891 "tag": "",
892 892 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
893 893 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
894 894 "local": "1000",
895 895 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
896 896 "summary": "...",
897 897 "message": "...",
898 898 "authorEmail": "foo@example.com"
899 899 }
900 900 }
901 901 }
902 902
903 903 Note: metadata extracted from "local:commits" will lose time zone
904 904 information.
905 905 """
906 906 props = diff.get(b'properties') or {}
907 907 meta = props.get(b'hg:meta')
908 if not meta and props.get(b'local:commits'):
908 if not meta:
909 if props.get(b'local:commits'):
909 910 commit = sorted(props[b'local:commits'].values())[0]
910 911 meta = {}
911 912 if b'author' in commit and b'authorEmail' in commit:
912 913 meta[b'user'] = b'%s <%s>' % (commit[b'author'],
913 914 commit[b'authorEmail'])
914 915 if b'time' in commit:
915 916 meta[b'date'] = b'%d 0' % commit[b'time']
916 917 if b'branch' in commit:
917 918 meta[b'branch'] = commit[b'branch']
918 919 node = commit.get(b'commit', commit.get(b'rev'))
919 920 if node:
920 921 meta[b'node'] = node
921 922 if len(commit.get(b'parents', ())) >= 1:
922 923 meta[b'parent'] = commit[b'parents'][0]
923 return meta or {}
924 else:
925 meta = {}
926 if b'date' not in meta and b'dateCreated' in diff:
927 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
928 if b'branch' not in meta and diff.get(b'branch'):
929 meta[b'branch'] = diff[b'branch']
930 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
931 meta[b'parent'] = diff[b'sourceControlBaseRevision']
932 return meta
924 933
925 934 def readpatch(repo, drevs, write):
926 935 """generate plain-text patch readable by 'hg import'
927 936
928 937 write is usually ui.write. drevs is what "querydrev" returns, results of
929 938 "differential.query".
930 939 """
931 940 # Prefetch hg:meta property for all diffs
932 941 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
933 942 diffs = callconduit(repo, b'differential.querydiffs', {b'ids': diffids})
934 943
935 944 # Generate patch for each drev
936 945 for drev in drevs:
937 946 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
938 947
939 948 diffid = max(int(v) for v in drev[b'diffs'])
940 949 body = callconduit(repo, b'differential.getrawdiff',
941 950 {b'diffID': diffid})
942 951 desc = getdescfromdrev(drev)
943 952 header = b'# HG changeset patch\n'
944 953
945 954 # Try to preserve metadata from hg:meta property. Write hg patch
946 955 # headers that can be read by the "import" command. See patchheadermap
947 956 # and extract in mercurial/patch.py for supported headers.
948 957 meta = getdiffmeta(diffs[b'%d' % diffid])
949 958 for k in _metanamemap.keys():
950 959 if k in meta:
951 960 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
952 961
953 962 content = b'%s%s\n%s' % (header, desc, body)
954 963 write(content)
955 964
956 965 @vcrcommand(b'phabread',
957 966 [(b'', b'stack', False, _(b'read dependencies'))],
958 967 _(b'DREVSPEC [OPTIONS]'),
959 968 helpcategory=command.CATEGORY_IMPORT_EXPORT)
960 969 def phabread(ui, repo, spec, **opts):
961 970 """print patches from Phabricator suitable for importing
962 971
963 972 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
964 973 the number ``123``. It could also have common operators like ``+``, ``-``,
965 974 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
966 975 select a stack.
967 976
968 977 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
969 978 could be used to filter patches by status. For performance reason, they
970 979 only represent a subset of non-status selections and cannot be used alone.
971 980
972 981 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
973 982 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
974 983 stack up to D9.
975 984
976 985 If --stack is given, follow dependencies information and read all patches.
977 986 It is equivalent to the ``:`` operator.
978 987 """
979 988 opts = pycompat.byteskwargs(opts)
980 989 if opts.get(b'stack'):
981 990 spec = b':(%s)' % spec
982 991 drevs = querydrev(repo, spec)
983 992 readpatch(repo, drevs, ui.write)
984 993
985 994 @vcrcommand(b'phabupdate',
986 995 [(b'', b'accept', False, _(b'accept revisions')),
987 996 (b'', b'reject', False, _(b'reject revisions')),
988 997 (b'', b'abandon', False, _(b'abandon revisions')),
989 998 (b'', b'reclaim', False, _(b'reclaim revisions')),
990 999 (b'm', b'comment', b'', _(b'comment on the last revision')),
991 1000 ], _(b'DREVSPEC [OPTIONS]'),
992 1001 helpcategory=command.CATEGORY_IMPORT_EXPORT)
993 1002 def phabupdate(ui, repo, spec, **opts):
994 1003 """update Differential Revision in batch
995 1004
996 1005 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
997 1006 """
998 1007 opts = pycompat.byteskwargs(opts)
999 1008 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1000 1009 if len(flags) > 1:
1001 1010 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1002 1011
1003 1012 actions = []
1004 1013 for f in flags:
1005 1014 actions.append({b'type': f, b'value': b'true'})
1006 1015
1007 1016 drevs = querydrev(repo, spec)
1008 1017 for i, drev in enumerate(drevs):
1009 1018 if i + 1 == len(drevs) and opts.get(b'comment'):
1010 1019 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1011 1020 if actions:
1012 1021 params = {b'objectIdentifier': drev[b'phid'],
1013 1022 b'transactions': actions}
1014 1023 callconduit(repo, b'differential.revision.edit', params)
1015 1024
1016 1025 templatekeyword = registrar.templatekeyword()
1017 1026
1018 1027 @templatekeyword(b'phabreview', requires={b'ctx'})
1019 1028 def template_review(context, mapping):
1020 1029 """:phabreview: Object describing the review for this changeset.
1021 1030 Has attributes `url` and `id`.
1022 1031 """
1023 1032 ctx = context.resource(mapping, b'ctx')
1024 1033 m = _differentialrevisiondescre.search(ctx.description())
1025 1034 if m:
1026 1035 return templateutil.hybriddict({
1027 1036 b'url': m.group(r'url'),
1028 1037 b'id': b"D%s" % m.group(r'id'),
1029 1038 })
1030 1039 else:
1031 1040 tags = ctx.repo().nodetags(ctx.node())
1032 1041 for t in tags:
1033 1042 if _differentialrevisiontagre.match(t):
1034 1043 url = ctx.repo().ui.config(b'phabricator', b'url')
1035 1044 if not url.endswith(b'/'):
1036 1045 url += b'/'
1037 1046 url += t
1038 1047
1039 1048 return templateutil.hybriddict({
1040 1049 b'url': url,
1041 1050 b'id': t,
1042 1051 })
1043 1052 return None
@@ -1,121 +1,121 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, and make sure to also edit your VCR transcripts to match
19 19 > # whatever value you put here.
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 # Date 1536771503 0
36 # Parent a5de21c9e3703f8e8eb064bd7d893ff2f703c66a
35 37 exchangev2: start to implement pull with wire protocol v2
36 38
37 39 Wire protocol version 2 will take a substantially different
38 40 approach to exchange than version 1 (at least as far as pulling
39 41 is concerned).
40 42
41 43 This commit establishes a new exchangev2 module for holding
42 code related to exchange using wire protocol v2. I could have
43 added things to the existing exchange module. But it is already
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
50 50 Create a differential diff:
51 51 $ HGENCODING=utf-8; export HGENCODING
52 52 $ echo alpha > alpha
53 53 $ hg ci --addremove -m 'create alpha for phabricator test €'
54 54 adding alpha
55 55 $ hg phabsend -r . --test-vcr "$VCR/phabsend-create-alpha.json"
56 56 D6054 - created - d386117f30e6: create alpha for phabricator test \xe2\x82\xac (esc)
57 57 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/d386117f30e6-24ffe649-phabsend.hg
58 58 $ echo more >> alpha
59 59 $ HGEDITOR=true hg ci --amend
60 60 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/cb03845d6dd9-870f61a6-amend.hg
61 61 $ echo beta > beta
62 62 $ hg ci --addremove -m 'create beta for phabricator test'
63 63 adding beta
64 64 $ hg phabsend -r ".^::" --test-vcr "$VCR/phabsend-update-alpha-create-beta.json"
65 65 D6054 - updated - 939d862f0318: create alpha for phabricator test \xe2\x82\xac (esc)
66 66 D6055 - created - f55f947ed0f8: create beta for phabricator test
67 67 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/f55f947ed0f8-0d1e502e-phabsend.hg
68 68 $ unset HGENCODING
69 69
70 70 The amend won't explode after posting a public commit. The local tag is left
71 71 behind to identify it.
72 72
73 73 $ echo 'public change' > beta
74 74 $ hg ci -m 'create public change for phabricator testing'
75 75 $ hg phase --public .
76 76 $ echo 'draft change' > alpha
77 77 $ hg ci -m 'create draft change for phabricator testing'
78 78 $ hg phabsend --amend -r '.^::' --test-vcr "$VCR/phabsend-create-public.json"
79 79 D5544 - created - a56e5ebd77e6: create public change for phabricator testing
80 80 D5545 - created - 6a0ade3e3ec2: create draft change for phabricator testing
81 81 warning: not updating public commit 2:a56e5ebd77e6
82 82 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/6a0ade3e3ec2-aca7d23c-phabsend.hg
83 83 $ hg tags -v
84 84 tip 3:90532860b5e1
85 85 D5544 2:a56e5ebd77e6 local
86 86
87 87 $ hg debugcallconduit user.search --test-vcr "$VCR/phab-conduit.json" <<EOF
88 88 > {
89 89 > "constraints": {
90 90 > "isBot": true
91 91 > }
92 92 > }
93 93 > EOF
94 94 {
95 95 "cursor": {
96 96 "after": null,
97 97 "before": null,
98 98 "limit": 100,
99 99 "order": null
100 100 },
101 101 "data": [],
102 102 "maps": {},
103 103 "query": {
104 104 "queryKey": null
105 105 }
106 106 }
107 107
108 108 Template keywords
109 109 $ hg log -T'{rev} {phabreview|json}\n'
110 110 3 {"id": "D5545", "url": "https://phab.mercurial-scm.org/D5545"}
111 111 2 {"id": "D5544", "url": "https://phab.mercurial-scm.org/D5544"}
112 112 1 {"id": "D6055", "url": "https://phab.mercurial-scm.org/D6055"}
113 113 0 {"id": "D6054", "url": "https://phab.mercurial-scm.org/D6054"}
114 114
115 115 $ hg log -T'{rev} {if(phabreview, "{phabreview.url} {phabreview.id}")}\n'
116 116 3 https://phab.mercurial-scm.org/D5545 D5545
117 117 2 https://phab.mercurial-scm.org/D5544 D5544
118 118 1 https://phab.mercurial-scm.org/D6055 D6055
119 119 0 https://phab.mercurial-scm.org/D6054 D6054
120 120
121 121 $ cd ..
General Comments 0
You need to be logged in to leave comments. Login now