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