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