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