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