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