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