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