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