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