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