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