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