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