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