##// END OF EJS Templates
phabricator: include commit (node) and parent in the local:commits metadata...
Ian Moody -
r42441:9421d7e1 default
parent child Browse files
Show More
@@ -1,1041 +1,1043 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 b'commit': ctx.hex(),
403 b'parents': [ctx.p1().hex()],
402 404 b'branch': ctx.branch(),
403 405 },
404 406 }),
405 407 }
406 408 callconduit(ctx.repo(), b'differential.setdiffproperty', params)
407 409
408 410 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
409 411 olddiff=None, actions=None):
410 412 """create or update a Differential Revision
411 413
412 414 If revid is None, create a new Differential Revision, otherwise update
413 415 revid. If parentrevid is not None, set it as a dependency.
414 416
415 417 If oldnode is not None, check if the patch content (without commit message
416 418 and metadata) has changed before creating another diff.
417 419
418 420 If actions is not None, they will be appended to the transaction.
419 421 """
420 422 repo = ctx.repo()
421 423 if oldnode:
422 424 diffopts = mdiff.diffopts(git=True, context=32767)
423 425 oldctx = repo.unfiltered()[oldnode]
424 426 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
425 427 else:
426 428 neednewdiff = True
427 429
428 430 transactions = []
429 431 if neednewdiff:
430 432 diff = creatediff(ctx)
431 433 transactions.append({b'type': b'update', b'value': diff[b'phid']})
432 434 else:
433 435 # Even if we don't need to upload a new diff because the patch content
434 436 # does not change. We might still need to update its metadata so
435 437 # pushers could know the correct node metadata.
436 438 assert olddiff
437 439 diff = olddiff
438 440 writediffproperties(ctx, diff)
439 441
440 442 # Use a temporary summary to set dependency. There might be better ways but
441 443 # I cannot find them for now. But do not do that if we are updating an
442 444 # existing revision (revid is not None) since that introduces visible
443 445 # churns (someone edited "Summary" twice) on the web page.
444 446 if parentrevid and revid is None:
445 447 summary = b'Depends on D%d' % parentrevid
446 448 transactions += [{b'type': b'summary', b'value': summary},
447 449 {b'type': b'summary', b'value': b' '}]
448 450
449 451 if actions:
450 452 transactions += actions
451 453
452 454 # Parse commit message and update related fields.
453 455 desc = ctx.description()
454 456 info = callconduit(repo, b'differential.parsecommitmessage',
455 457 {b'corpus': desc})
456 458 for k, v in info[b'fields'].items():
457 459 if k in [b'title', b'summary', b'testPlan']:
458 460 transactions.append({b'type': k, b'value': v})
459 461
460 462 params = {b'transactions': transactions}
461 463 if revid is not None:
462 464 # Update an existing Differential Revision
463 465 params[b'objectIdentifier'] = revid
464 466
465 467 revision = callconduit(repo, b'differential.revision.edit', params)
466 468 if not revision:
467 469 raise error.Abort(_(b'cannot create revision for %s') % ctx)
468 470
469 471 return revision, diff
470 472
471 473 def userphids(repo, names):
472 474 """convert user names to PHIDs"""
473 475 names = [name.lower() for name in names]
474 476 query = {b'constraints': {b'usernames': names}}
475 477 result = callconduit(repo, b'user.search', query)
476 478 # username not found is not an error of the API. So check if we have missed
477 479 # some names here.
478 480 data = result[b'data']
479 481 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
480 482 unresolved = set(names) - resolved
481 483 if unresolved:
482 484 raise error.Abort(_(b'unknown username: %s')
483 485 % b' '.join(sorted(unresolved)))
484 486 return [entry[b'phid'] for entry in data]
485 487
486 488 @vcrcommand(b'phabsend',
487 489 [(b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
488 490 (b'', b'amend', True, _(b'update commit messages')),
489 491 (b'', b'reviewer', [], _(b'specify reviewers')),
490 492 (b'', b'confirm', None, _(b'ask for confirmation before sending'))],
491 493 _(b'REV [OPTIONS]'),
492 494 helpcategory=command.CATEGORY_IMPORT_EXPORT)
493 495 def phabsend(ui, repo, *revs, **opts):
494 496 """upload changesets to Phabricator
495 497
496 498 If there are multiple revisions specified, they will be send as a stack
497 499 with a linear dependencies relationship using the order specified by the
498 500 revset.
499 501
500 502 For the first time uploading changesets, local tags will be created to
501 503 maintain the association. After the first time, phabsend will check
502 504 obsstore and tags information so it can figure out whether to update an
503 505 existing Differential Revision, or create a new one.
504 506
505 507 If --amend is set, update commit messages so they have the
506 508 ``Differential Revision`` URL, remove related tags. This is similar to what
507 509 arcanist will do, and is more desired in author-push workflows. Otherwise,
508 510 use local tags to record the ``Differential Revision`` association.
509 511
510 512 The --confirm option lets you confirm changesets before sending them. You
511 513 can also add following to your configuration file to make it default
512 514 behaviour::
513 515
514 516 [phabsend]
515 517 confirm = true
516 518
517 519 phabsend will check obsstore and the above association to decide whether to
518 520 update an existing Differential Revision, or create a new one.
519 521 """
520 522 opts = pycompat.byteskwargs(opts)
521 523 revs = list(revs) + opts.get(b'rev', [])
522 524 revs = scmutil.revrange(repo, revs)
523 525
524 526 if not revs:
525 527 raise error.Abort(_(b'phabsend requires at least one changeset'))
526 528 if opts.get(b'amend'):
527 529 cmdutil.checkunfinished(repo)
528 530
529 531 # {newnode: (oldnode, olddiff, olddrev}
530 532 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
531 533
532 534 confirm = ui.configbool(b'phabsend', b'confirm')
533 535 confirm |= bool(opts.get(b'confirm'))
534 536 if confirm:
535 537 confirmed = _confirmbeforesend(repo, revs, oldmap)
536 538 if not confirmed:
537 539 raise error.Abort(_(b'phabsend cancelled'))
538 540
539 541 actions = []
540 542 reviewers = opts.get(b'reviewer', [])
541 543 if reviewers:
542 544 phids = userphids(repo, reviewers)
543 545 actions.append({b'type': b'reviewers.add', b'value': phids})
544 546
545 547 drevids = [] # [int]
546 548 diffmap = {} # {newnode: diff}
547 549
548 550 # Send patches one by one so we know their Differential Revision IDs and
549 551 # can provide dependency relationship
550 552 lastrevid = None
551 553 for rev in revs:
552 554 ui.debug(b'sending rev %d\n' % rev)
553 555 ctx = repo[rev]
554 556
555 557 # Get Differential Revision ID
556 558 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
557 559 if oldnode != ctx.node() or opts.get(b'amend'):
558 560 # Create or update Differential Revision
559 561 revision, diff = createdifferentialrevision(
560 562 ctx, revid, lastrevid, oldnode, olddiff, actions)
561 563 diffmap[ctx.node()] = diff
562 564 newrevid = int(revision[b'object'][b'id'])
563 565 if revid:
564 566 action = b'updated'
565 567 else:
566 568 action = b'created'
567 569
568 570 # Create a local tag to note the association, if commit message
569 571 # does not have it already
570 572 m = _differentialrevisiondescre.search(ctx.description())
571 573 if not m or int(m.group(r'id')) != newrevid:
572 574 tagname = b'D%d' % newrevid
573 575 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
574 576 date=None, local=True)
575 577 else:
576 578 # Nothing changed. But still set "newrevid" so the next revision
577 579 # could depend on this one.
578 580 newrevid = revid
579 581 action = b'skipped'
580 582
581 583 actiondesc = ui.label(
582 584 {b'created': _(b'created'),
583 585 b'skipped': _(b'skipped'),
584 586 b'updated': _(b'updated')}[action],
585 587 b'phabricator.action.%s' % action)
586 588 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
587 589 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
588 590 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
589 591 ui.write(_(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc,
590 592 desc))
591 593 drevids.append(newrevid)
592 594 lastrevid = newrevid
593 595
594 596 # Update commit messages and remove tags
595 597 if opts.get(b'amend'):
596 598 unfi = repo.unfiltered()
597 599 drevs = callconduit(repo, b'differential.query', {b'ids': drevids})
598 600 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
599 601 wnode = unfi[b'.'].node()
600 602 mapping = {} # {oldnode: [newnode]}
601 603 for i, rev in enumerate(revs):
602 604 old = unfi[rev]
603 605 drevid = drevids[i]
604 606 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
605 607 newdesc = getdescfromdrev(drev)
606 608 # Make sure commit message contain "Differential Revision"
607 609 if old.description() != newdesc:
608 610 if old.phase() == phases.public:
609 611 ui.warn(_("warning: not updating public commit %s\n")
610 612 % scmutil.formatchangeid(old))
611 613 continue
612 614 parents = [
613 615 mapping.get(old.p1().node(), (old.p1(),))[0],
614 616 mapping.get(old.p2().node(), (old.p2(),))[0],
615 617 ]
616 618 new = context.metadataonlyctx(
617 619 repo, old, parents=parents, text=newdesc,
618 620 user=old.user(), date=old.date(), extra=old.extra())
619 621
620 622 newnode = new.commit()
621 623
622 624 mapping[old.node()] = [newnode]
623 625 # Update diff property
624 626 writediffproperties(unfi[newnode], diffmap[old.node()])
625 627 # Remove local tags since it's no longer necessary
626 628 tagname = b'D%d' % drevid
627 629 if tagname in repo.tags():
628 630 tags.tag(repo, tagname, nullid, message=None, user=None,
629 631 date=None, local=True)
630 632 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
631 633 if wnode in mapping:
632 634 unfi.setparents(mapping[wnode][0])
633 635
634 636 # Map from "hg:meta" keys to header understood by "hg import". The order is
635 637 # consistent with "hg export" output.
636 638 _metanamemap = util.sortdict([(b'user', b'User'), (b'date', b'Date'),
637 639 (b'node', b'Node ID'), (b'parent', b'Parent ')])
638 640
639 641 def _confirmbeforesend(repo, revs, oldmap):
640 642 url, token = readurltoken(repo)
641 643 ui = repo.ui
642 644 for rev in revs:
643 645 ctx = repo[rev]
644 646 desc = ctx.description().splitlines()[0]
645 647 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
646 648 if drevid:
647 649 drevdesc = ui.label(b'D%s' % drevid, b'phabricator.drev')
648 650 else:
649 651 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
650 652
651 653 ui.write(_(b'%s - %s: %s\n')
652 654 % (drevdesc,
653 655 ui.label(bytes(ctx), b'phabricator.node'),
654 656 ui.label(desc, b'phabricator.desc')))
655 657
656 658 if ui.promptchoice(_(b'Send the above changes to %s (yn)?'
657 659 b'$$ &Yes $$ &No') % url):
658 660 return False
659 661
660 662 return True
661 663
662 664 _knownstatusnames = {b'accepted', b'needsreview', b'needsrevision', b'closed',
663 665 b'abandoned'}
664 666
665 667 def _getstatusname(drev):
666 668 """get normalized status name from a Differential Revision"""
667 669 return drev[b'statusName'].replace(b' ', b'').lower()
668 670
669 671 # Small language to specify differential revisions. Support symbols: (), :X,
670 672 # +, and -.
671 673
672 674 _elements = {
673 675 # token-type: binding-strength, primary, prefix, infix, suffix
674 676 b'(': (12, None, (b'group', 1, b')'), None, None),
675 677 b':': (8, None, (b'ancestors', 8), None, None),
676 678 b'&': (5, None, None, (b'and_', 5), None),
677 679 b'+': (4, None, None, (b'add', 4), None),
678 680 b'-': (4, None, None, (b'sub', 4), None),
679 681 b')': (0, None, None, None, None),
680 682 b'symbol': (0, b'symbol', None, None, None),
681 683 b'end': (0, None, None, None, None),
682 684 }
683 685
684 686 def _tokenize(text):
685 687 view = memoryview(text) # zero-copy slice
686 688 special = b'():+-& '
687 689 pos = 0
688 690 length = len(text)
689 691 while pos < length:
690 692 symbol = b''.join(itertools.takewhile(lambda ch: ch not in special,
691 693 pycompat.iterbytestr(view[pos:])))
692 694 if symbol:
693 695 yield (b'symbol', symbol, pos)
694 696 pos += len(symbol)
695 697 else: # special char, ignore space
696 698 if text[pos] != b' ':
697 699 yield (text[pos], None, pos)
698 700 pos += 1
699 701 yield (b'end', None, pos)
700 702
701 703 def _parse(text):
702 704 tree, pos = parser.parser(_elements).parse(_tokenize(text))
703 705 if pos != len(text):
704 706 raise error.ParseError(b'invalid token', pos)
705 707 return tree
706 708
707 709 def _parsedrev(symbol):
708 710 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
709 711 if symbol.startswith(b'D') and symbol[1:].isdigit():
710 712 return int(symbol[1:])
711 713 if symbol.isdigit():
712 714 return int(symbol)
713 715
714 716 def _prefetchdrevs(tree):
715 717 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
716 718 drevs = set()
717 719 ancestordrevs = set()
718 720 op = tree[0]
719 721 if op == b'symbol':
720 722 r = _parsedrev(tree[1])
721 723 if r:
722 724 drevs.add(r)
723 725 elif op == b'ancestors':
724 726 r, a = _prefetchdrevs(tree[1])
725 727 drevs.update(r)
726 728 ancestordrevs.update(r)
727 729 ancestordrevs.update(a)
728 730 else:
729 731 for t in tree[1:]:
730 732 r, a = _prefetchdrevs(t)
731 733 drevs.update(r)
732 734 ancestordrevs.update(a)
733 735 return drevs, ancestordrevs
734 736
735 737 def querydrev(repo, spec):
736 738 """return a list of "Differential Revision" dicts
737 739
738 740 spec is a string using a simple query language, see docstring in phabread
739 741 for details.
740 742
741 743 A "Differential Revision dict" looks like:
742 744
743 745 {
744 746 "id": "2",
745 747 "phid": "PHID-DREV-672qvysjcczopag46qty",
746 748 "title": "example",
747 749 "uri": "https://phab.example.com/D2",
748 750 "dateCreated": "1499181406",
749 751 "dateModified": "1499182103",
750 752 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
751 753 "status": "0",
752 754 "statusName": "Needs Review",
753 755 "properties": [],
754 756 "branch": null,
755 757 "summary": "",
756 758 "testPlan": "",
757 759 "lineCount": "2",
758 760 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
759 761 "diffs": [
760 762 "3",
761 763 "4",
762 764 ],
763 765 "commits": [],
764 766 "reviewers": [],
765 767 "ccs": [],
766 768 "hashes": [],
767 769 "auxiliary": {
768 770 "phabricator:projects": [],
769 771 "phabricator:depends-on": [
770 772 "PHID-DREV-gbapp366kutjebt7agcd"
771 773 ]
772 774 },
773 775 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
774 776 "sourcePath": null
775 777 }
776 778 """
777 779 def fetch(params):
778 780 """params -> single drev or None"""
779 781 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
780 782 if key in prefetched:
781 783 return prefetched[key]
782 784 drevs = callconduit(repo, b'differential.query', params)
783 785 # Fill prefetched with the result
784 786 for drev in drevs:
785 787 prefetched[drev[b'phid']] = drev
786 788 prefetched[int(drev[b'id'])] = drev
787 789 if key not in prefetched:
788 790 raise error.Abort(_(b'cannot get Differential Revision %r')
789 791 % params)
790 792 return prefetched[key]
791 793
792 794 def getstack(topdrevids):
793 795 """given a top, get a stack from the bottom, [id] -> [id]"""
794 796 visited = set()
795 797 result = []
796 798 queue = [{b'ids': [i]} for i in topdrevids]
797 799 while queue:
798 800 params = queue.pop()
799 801 drev = fetch(params)
800 802 if drev[b'id'] in visited:
801 803 continue
802 804 visited.add(drev[b'id'])
803 805 result.append(int(drev[b'id']))
804 806 auxiliary = drev.get(b'auxiliary', {})
805 807 depends = auxiliary.get(b'phabricator:depends-on', [])
806 808 for phid in depends:
807 809 queue.append({b'phids': [phid]})
808 810 result.reverse()
809 811 return smartset.baseset(result)
810 812
811 813 # Initialize prefetch cache
812 814 prefetched = {} # {id or phid: drev}
813 815
814 816 tree = _parse(spec)
815 817 drevs, ancestordrevs = _prefetchdrevs(tree)
816 818
817 819 # developer config: phabricator.batchsize
818 820 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
819 821
820 822 # Prefetch Differential Revisions in batch
821 823 tofetch = set(drevs)
822 824 for r in ancestordrevs:
823 825 tofetch.update(range(max(1, r - batchsize), r + 1))
824 826 if drevs:
825 827 fetch({b'ids': list(tofetch)})
826 828 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
827 829
828 830 # Walk through the tree, return smartsets
829 831 def walk(tree):
830 832 op = tree[0]
831 833 if op == b'symbol':
832 834 drev = _parsedrev(tree[1])
833 835 if drev:
834 836 return smartset.baseset([drev])
835 837 elif tree[1] in _knownstatusnames:
836 838 drevs = [r for r in validids
837 839 if _getstatusname(prefetched[r]) == tree[1]]
838 840 return smartset.baseset(drevs)
839 841 else:
840 842 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
841 843 elif op in {b'and_', b'add', b'sub'}:
842 844 assert len(tree) == 3
843 845 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
844 846 elif op == b'group':
845 847 return walk(tree[1])
846 848 elif op == b'ancestors':
847 849 return getstack(walk(tree[1]))
848 850 else:
849 851 raise error.ProgrammingError(b'illegal tree: %r' % tree)
850 852
851 853 return [prefetched[r] for r in walk(tree)]
852 854
853 855 def getdescfromdrev(drev):
854 856 """get description (commit message) from "Differential Revision"
855 857
856 858 This is similar to differential.getcommitmessage API. But we only care
857 859 about limited fields: title, summary, test plan, and URL.
858 860 """
859 861 title = drev[b'title']
860 862 summary = drev[b'summary'].rstrip()
861 863 testplan = drev[b'testPlan'].rstrip()
862 864 if testplan:
863 865 testplan = b'Test Plan:\n%s' % testplan
864 866 uri = b'Differential Revision: %s' % drev[b'uri']
865 867 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
866 868
867 869 def getdiffmeta(diff):
868 870 """get commit metadata (date, node, user, p1) from a diff object
869 871
870 872 The metadata could be "hg:meta", sent by phabsend, like:
871 873
872 874 "properties": {
873 875 "hg:meta": {
874 876 "date": "1499571514 25200",
875 877 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
876 878 "user": "Foo Bar <foo@example.com>",
877 879 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
878 880 }
879 881 }
880 882
881 883 Or converted from "local:commits", sent by "arc", like:
882 884
883 885 "properties": {
884 886 "local:commits": {
885 887 "98c08acae292b2faf60a279b4189beb6cff1414d": {
886 888 "author": "Foo Bar",
887 889 "time": 1499546314,
888 890 "branch": "default",
889 891 "tag": "",
890 892 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
891 893 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
892 894 "local": "1000",
893 895 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
894 896 "summary": "...",
895 897 "message": "...",
896 898 "authorEmail": "foo@example.com"
897 899 }
898 900 }
899 901 }
900 902
901 903 Note: metadata extracted from "local:commits" will lose time zone
902 904 information.
903 905 """
904 906 props = diff.get(b'properties') or {}
905 907 meta = props.get(b'hg:meta')
906 908 if not meta and props.get(b'local:commits'):
907 909 commit = sorted(props[b'local:commits'].values())[0]
908 910 meta = {}
909 911 if b'author' in commit and b'authorEmail' in commit:
910 912 meta[b'user'] = b'%s <%s>' % (commit[b'author'],
911 913 commit[b'authorEmail'])
912 914 if b'time' in commit:
913 915 meta[b'date'] = b'%d 0' % commit[b'time']
914 916 if b'branch' in commit:
915 917 meta[b'branch'] = commit[b'branch']
916 918 node = commit.get(b'commit', commit.get(b'rev'))
917 919 if node:
918 920 meta[b'node'] = node
919 921 if len(commit.get(b'parents', ())) >= 1:
920 922 meta[b'parent'] = commit[b'parents'][0]
921 923 return meta or {}
922 924
923 925 def readpatch(repo, drevs, write):
924 926 """generate plain-text patch readable by 'hg import'
925 927
926 928 write is usually ui.write. drevs is what "querydrev" returns, results of
927 929 "differential.query".
928 930 """
929 931 # Prefetch hg:meta property for all diffs
930 932 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
931 933 diffs = callconduit(repo, b'differential.querydiffs', {b'ids': diffids})
932 934
933 935 # Generate patch for each drev
934 936 for drev in drevs:
935 937 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
936 938
937 939 diffid = max(int(v) for v in drev[b'diffs'])
938 940 body = callconduit(repo, b'differential.getrawdiff',
939 941 {b'diffID': diffid})
940 942 desc = getdescfromdrev(drev)
941 943 header = b'# HG changeset patch\n'
942 944
943 945 # Try to preserve metadata from hg:meta property. Write hg patch
944 946 # headers that can be read by the "import" command. See patchheadermap
945 947 # and extract in mercurial/patch.py for supported headers.
946 948 meta = getdiffmeta(diffs[b'%d' % diffid])
947 949 for k in _metanamemap.keys():
948 950 if k in meta:
949 951 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
950 952
951 953 content = b'%s%s\n%s' % (header, desc, body)
952 954 write(content)
953 955
954 956 @vcrcommand(b'phabread',
955 957 [(b'', b'stack', False, _(b'read dependencies'))],
956 958 _(b'DREVSPEC [OPTIONS]'),
957 959 helpcategory=command.CATEGORY_IMPORT_EXPORT)
958 960 def phabread(ui, repo, spec, **opts):
959 961 """print patches from Phabricator suitable for importing
960 962
961 963 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
962 964 the number ``123``. It could also have common operators like ``+``, ``-``,
963 965 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
964 966 select a stack.
965 967
966 968 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
967 969 could be used to filter patches by status. For performance reason, they
968 970 only represent a subset of non-status selections and cannot be used alone.
969 971
970 972 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
971 973 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
972 974 stack up to D9.
973 975
974 976 If --stack is given, follow dependencies information and read all patches.
975 977 It is equivalent to the ``:`` operator.
976 978 """
977 979 opts = pycompat.byteskwargs(opts)
978 980 if opts.get(b'stack'):
979 981 spec = b':(%s)' % spec
980 982 drevs = querydrev(repo, spec)
981 983 readpatch(repo, drevs, ui.write)
982 984
983 985 @vcrcommand(b'phabupdate',
984 986 [(b'', b'accept', False, _(b'accept revisions')),
985 987 (b'', b'reject', False, _(b'reject revisions')),
986 988 (b'', b'abandon', False, _(b'abandon revisions')),
987 989 (b'', b'reclaim', False, _(b'reclaim revisions')),
988 990 (b'm', b'comment', b'', _(b'comment on the last revision')),
989 991 ], _(b'DREVSPEC [OPTIONS]'),
990 992 helpcategory=command.CATEGORY_IMPORT_EXPORT)
991 993 def phabupdate(ui, repo, spec, **opts):
992 994 """update Differential Revision in batch
993 995
994 996 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
995 997 """
996 998 opts = pycompat.byteskwargs(opts)
997 999 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
998 1000 if len(flags) > 1:
999 1001 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1000 1002
1001 1003 actions = []
1002 1004 for f in flags:
1003 1005 actions.append({b'type': f, b'value': b'true'})
1004 1006
1005 1007 drevs = querydrev(repo, spec)
1006 1008 for i, drev in enumerate(drevs):
1007 1009 if i + 1 == len(drevs) and opts.get(b'comment'):
1008 1010 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1009 1011 if actions:
1010 1012 params = {b'objectIdentifier': drev[b'phid'],
1011 1013 b'transactions': actions}
1012 1014 callconduit(repo, b'differential.revision.edit', params)
1013 1015
1014 1016 templatekeyword = registrar.templatekeyword()
1015 1017
1016 1018 @templatekeyword(b'phabreview', requires={b'ctx'})
1017 1019 def template_review(context, mapping):
1018 1020 """:phabreview: Object describing the review for this changeset.
1019 1021 Has attributes `url` and `id`.
1020 1022 """
1021 1023 ctx = context.resource(mapping, b'ctx')
1022 1024 m = _differentialrevisiondescre.search(ctx.description())
1023 1025 if m:
1024 1026 return templateutil.hybriddict({
1025 1027 b'url': m.group(r'url'),
1026 1028 b'id': b"D%s" % m.group(r'id'),
1027 1029 })
1028 1030 else:
1029 1031 tags = ctx.repo().nodetags(ctx.node())
1030 1032 for t in tags:
1031 1033 if _differentialrevisiontagre.match(t):
1032 1034 url = ctx.repo().ui.config(b'phabricator', b'url')
1033 1035 if not url.endswith(b'/'):
1034 1036 url += b'/'
1035 1037 url += t
1036 1038
1037 1039 return templateutil.hybriddict({
1038 1040 b'url': url,
1039 1041 b'id': t,
1040 1042 })
1041 1043 return None
General Comments 0
You need to be logged in to leave comments. Login now