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