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