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