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