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