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