##// END OF EJS Templates
phabricator: verify local tags before trusting them...
Jun Wu -
r33443:e48082e0 default
parent child Browse files
Show More
@@ -1,524 +1,549
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
8 8
9 9 This extension provides a ``phabsend`` command which sends a stack of
10 10 changesets to Phabricator without amending commit messages, and a ``phabread``
11 11 command which prints a stack of revisions in a format suitable
12 12 for :hg:`import`.
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 # API token. Get it from https://$HOST/conduit/login/
25 25 token = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
26 26
27 27 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
28 28 # callsign is "FOO".
29 29 callsign = FOO
30 30
31 31 """
32 32
33 33 from __future__ import absolute_import
34 34
35 35 import json
36 36 import re
37 37
38 from mercurial.node import bin, nullid
38 39 from mercurial.i18n import _
39 40 from mercurial import (
40 41 encoding,
41 42 error,
42 43 mdiff,
43 44 obsolete,
44 45 patch,
45 46 registrar,
46 47 scmutil,
47 48 tags,
48 49 url as urlmod,
49 50 util,
50 51 )
51 52
52 53 cmdtable = {}
53 54 command = registrar.command(cmdtable)
54 55
55 56 def urlencodenested(params):
56 57 """like urlencode, but works with nested parameters.
57 58
58 59 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
59 60 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
60 61 urlencode. Note: the encoding is consistent with PHP's http_build_query.
61 62 """
62 63 flatparams = util.sortdict()
63 64 def process(prefix, obj):
64 65 items = {list: enumerate, dict: lambda x: x.items()}.get(type(obj))
65 66 if items is None:
66 67 flatparams[prefix] = obj
67 68 else:
68 69 for k, v in items(obj):
69 70 if prefix:
70 71 process('%s[%s]' % (prefix, k), v)
71 72 else:
72 73 process(k, v)
73 74 process('', params)
74 75 return util.urlreq.urlencode(flatparams)
75 76
76 77 def readurltoken(repo):
77 78 """return conduit url, token and make sure they exist
78 79
79 80 Currently read from [phabricator] config section. In the future, it might
80 81 make sense to read from .arcconfig and .arcrc as well.
81 82 """
82 83 values = []
83 84 section = 'phabricator'
84 85 for name in ['url', 'token']:
85 86 value = repo.ui.config(section, name)
86 87 if not value:
87 88 raise error.Abort(_('config %s.%s is required') % (section, name))
88 89 values.append(value)
89 90 return values
90 91
91 92 def callconduit(repo, name, params):
92 93 """call Conduit API, params is a dict. return json.loads result, or None"""
93 94 host, token = readurltoken(repo)
94 95 url, authinfo = util.url('/'.join([host, 'api', name])).authinfo()
95 96 urlopener = urlmod.opener(repo.ui, authinfo)
96 97 repo.ui.debug('Conduit Call: %s %s\n' % (url, params))
97 98 params = params.copy()
98 99 params['api.token'] = token
99 100 request = util.urlreq.request(url, data=urlencodenested(params))
100 101 body = urlopener.open(request).read()
101 102 repo.ui.debug('Conduit Response: %s\n' % body)
102 103 parsed = json.loads(body)
103 104 if parsed.get(r'error_code'):
104 105 msg = (_('Conduit Error (%s): %s')
105 106 % (parsed[r'error_code'], parsed[r'error_info']))
106 107 raise error.Abort(msg)
107 108 return parsed[r'result']
108 109
109 110 @command('debugcallconduit', [], _('METHOD'))
110 111 def debugcallconduit(ui, repo, name):
111 112 """call Conduit API
112 113
113 114 Call parameters are read from stdin as a JSON blob. Result will be written
114 115 to stdout as a JSON blob.
115 116 """
116 117 params = json.loads(ui.fin.read())
117 118 result = callconduit(repo, name, params)
118 119 s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': '))
119 120 ui.write('%s\n' % s)
120 121
121 122 def getrepophid(repo):
122 123 """given callsign, return repository PHID or None"""
123 124 # developer config: phabricator.repophid
124 125 repophid = repo.ui.config('phabricator', 'repophid')
125 126 if repophid:
126 127 return repophid
127 128 callsign = repo.ui.config('phabricator', 'callsign')
128 129 if not callsign:
129 130 return None
130 131 query = callconduit(repo, 'diffusion.repository.search',
131 132 {'constraints': {'callsigns': [callsign]}})
132 133 if len(query[r'data']) == 0:
133 134 return None
134 135 repophid = encoding.strtolocal(query[r'data'][0][r'phid'])
135 136 repo.ui.setconfig('phabricator', 'repophid', repophid)
136 137 return repophid
137 138
138 139 _differentialrevisiontagre = re.compile('\AD([1-9][0-9]*)\Z')
139 140 _differentialrevisiondescre = re.compile(
140 141 '^Differential Revision:.*D([1-9][0-9]*)$', re.M)
141 142
142 143 def getoldnodedrevmap(repo, nodelist):
143 144 """find previous nodes that has been sent to Phabricator
144 145
145 146 return {node: (oldnode or None, Differential Revision ID)}
146 147 for node in nodelist with known previous sent versions, or associated
147 148 Differential Revision IDs.
148 149
149 150 Examines all precursors and their tags. Tags with format like "D1234" are
150 151 considered a match and the node with that tag, and the number after "D"
151 152 (ex. 1234) will be returned.
152 153
153 154 If tags are not found, examine commit message. The "Differential Revision:"
154 155 line could associate this changeset to a Differential Revision.
155 156 """
156 157 url, token = readurltoken(repo)
157 158 unfi = repo.unfiltered()
158 159 nodemap = unfi.changelog.nodemap
159 160
160 161 result = {} # {node: (oldnode or None, drev)}
162 toconfirm = {} # {node: (oldnode, {precnode}, drev)}
161 163 for node in nodelist:
162 164 ctx = unfi[node]
163 # Check tags like "D123"
164 for n in obsolete.allprecursors(unfi.obsstore, [node]):
165 # For tags like "D123", put them into "toconfirm" to verify later
166 precnodes = list(obsolete.allprecursors(unfi.obsstore, [node]))
167 for n in precnodes:
165 168 if n in nodemap:
166 169 for tag in unfi.nodetags(n):
167 170 m = _differentialrevisiontagre.match(tag)
168 171 if m:
169 result[node] = (n, int(m.group(1)))
172 toconfirm[node] = (n, set(precnodes), int(m.group(1)))
170 173 continue
171 174
172 175 # Check commit message
173 176 m = _differentialrevisiondescre.search(ctx.description())
174 177 if m:
175 178 result[node] = (None, int(m.group(1)))
176 179
180 # Double check if tags are genuine by collecting all old nodes from
181 # Phabricator, and expect precursors overlap with it.
182 if toconfirm:
183 confirmed = {} # {drev: {oldnode}}
184 drevs = [drev for n, precs, drev in toconfirm.values()]
185 diffs = callconduit(unfi, 'differential.querydiffs',
186 {'revisionIDs': drevs})
187 for diff in diffs.values():
188 drev = int(diff[r'revisionID'])
189 oldnode = bin(encoding.unitolocal(getdiffmeta(diff).get(r'node')))
190 if node:
191 confirmed.setdefault(drev, set()).add(oldnode)
192 for newnode, (oldnode, precset, drev) in toconfirm.items():
193 if bool(precset & confirmed.get(drev, set())):
194 result[newnode] = (oldnode, drev)
195 else:
196 tagname = 'D%d' % drev
197 tags.tag(repo, tagname, nullid, message=None, user=None,
198 date=None, local=True)
199 unfi.ui.warn(_('D%s: local tag removed - does not match '
200 'Differential history\n') % drev)
201
177 202 return result
178 203
179 204 def getdiff(ctx, diffopts):
180 205 """plain-text diff without header (user, commit message, etc)"""
181 206 output = util.stringio()
182 207 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
183 208 None, opts=diffopts):
184 209 output.write(chunk)
185 210 return output.getvalue()
186 211
187 212 def creatediff(ctx):
188 213 """create a Differential Diff"""
189 214 repo = ctx.repo()
190 215 repophid = getrepophid(repo)
191 216 # Create a "Differential Diff" via "differential.createrawdiff" API
192 217 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
193 218 if repophid:
194 219 params['repositoryPHID'] = repophid
195 220 diff = callconduit(repo, 'differential.createrawdiff', params)
196 221 if not diff:
197 222 raise error.Abort(_('cannot create diff for %s') % ctx)
198 223 return diff
199 224
200 225 def writediffproperties(ctx, diff):
201 226 """write metadata to diff so patches could be applied losslessly"""
202 227 params = {
203 228 'diff_id': diff[r'id'],
204 229 'name': 'hg:meta',
205 230 'data': json.dumps({
206 231 'user': ctx.user(),
207 232 'date': '%d %d' % ctx.date(),
208 233 'node': ctx.hex(),
209 234 'parent': ctx.p1().hex(),
210 235 }),
211 236 }
212 237 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
213 238
214 239 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None):
215 240 """create or update a Differential Revision
216 241
217 242 If revid is None, create a new Differential Revision, otherwise update
218 243 revid. If parentrevid is not None, set it as a dependency.
219 244
220 245 If oldnode is not None, check if the patch content (without commit message
221 246 and metadata) has changed before creating another diff.
222 247 """
223 248 repo = ctx.repo()
224 249 if oldnode:
225 250 diffopts = mdiff.diffopts(git=True, context=1)
226 251 oldctx = repo.unfiltered()[oldnode]
227 252 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
228 253 else:
229 254 neednewdiff = True
230 255
231 256 transactions = []
232 257 if neednewdiff:
233 258 diff = creatediff(ctx)
234 259 writediffproperties(ctx, diff)
235 260 transactions.append({'type': 'update', 'value': diff[r'phid']})
236 261
237 262 # Use a temporary summary to set dependency. There might be better ways but
238 263 # I cannot find them for now. But do not do that if we are updating an
239 264 # existing revision (revid is not None) since that introduces visible
240 265 # churns (someone edited "Summary" twice) on the web page.
241 266 if parentrevid and revid is None:
242 267 summary = 'Depends on D%s' % parentrevid
243 268 transactions += [{'type': 'summary', 'value': summary},
244 269 {'type': 'summary', 'value': ' '}]
245 270
246 271 # Parse commit message and update related fields.
247 272 desc = ctx.description()
248 273 info = callconduit(repo, 'differential.parsecommitmessage',
249 274 {'corpus': desc})
250 275 for k, v in info[r'fields'].items():
251 276 if k in ['title', 'summary', 'testPlan']:
252 277 transactions.append({'type': k, 'value': v})
253 278
254 279 params = {'transactions': transactions}
255 280 if revid is not None:
256 281 # Update an existing Differential Revision
257 282 params['objectIdentifier'] = revid
258 283
259 284 revision = callconduit(repo, 'differential.revision.edit', params)
260 285 if not revision:
261 286 raise error.Abort(_('cannot create revision for %s') % ctx)
262 287
263 288 return revision
264 289
265 290 @command('phabsend',
266 291 [('r', 'rev', [], _('revisions to send'), _('REV'))],
267 292 _('REV [OPTIONS]'))
268 293 def phabsend(ui, repo, *revs, **opts):
269 294 """upload changesets to Phabricator
270 295
271 296 If there are multiple revisions specified, they will be send as a stack
272 297 with a linear dependencies relationship using the order specified by the
273 298 revset.
274 299
275 300 For the first time uploading changesets, local tags will be created to
276 301 maintain the association. After the first time, phabsend will check
277 302 obsstore and tags information so it can figure out whether to update an
278 303 existing Differential Revision, or create a new one.
279 304 """
280 305 revs = list(revs) + opts.get('rev', [])
281 306 revs = scmutil.revrange(repo, revs)
282 307
283 308 if not revs:
284 309 raise error.Abort(_('phabsend requires at least one changeset'))
285 310
286 311 oldnodedrev = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
287 312
288 313 # Send patches one by one so we know their Differential Revision IDs and
289 314 # can provide dependency relationship
290 315 lastrevid = None
291 316 for rev in revs:
292 317 ui.debug('sending rev %d\n' % rev)
293 318 ctx = repo[rev]
294 319
295 320 # Get Differential Revision ID
296 321 oldnode, revid = oldnodedrev.get(ctx.node(), (None, None))
297 322 if oldnode != ctx.node():
298 323 # Create or update Differential Revision
299 324 revision = createdifferentialrevision(ctx, revid, lastrevid,
300 325 oldnode)
301 326 newrevid = int(revision[r'object'][r'id'])
302 327 if revid:
303 328 action = _('updated')
304 329 else:
305 330 action = _('created')
306 331
307 332 # Create a local tag to note the association
308 333 tagname = 'D%d' % newrevid
309 334 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
310 335 date=None, local=True)
311 336 else:
312 337 # Nothing changed. But still set "newrevid" so the next revision
313 338 # could depend on this one.
314 339 newrevid = revid
315 340 action = _('skipped')
316 341
317 342 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
318 343 ctx.description().split('\n')[0]))
319 344 lastrevid = newrevid
320 345
321 346 # Map from "hg:meta" keys to header understood by "hg import". The order is
322 347 # consistent with "hg export" output.
323 348 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
324 349 (r'node', 'Node ID'), (r'parent', 'Parent ')])
325 350
326 351 def querydrev(repo, params, stack=False):
327 352 """return a list of "Differential Revision" dicts
328 353
329 354 params is the input of "differential.query" API, and is expected to match
330 355 just a single Differential Revision.
331 356
332 357 A "Differential Revision dict" looks like:
333 358
334 359 {
335 360 "id": "2",
336 361 "phid": "PHID-DREV-672qvysjcczopag46qty",
337 362 "title": "example",
338 363 "uri": "https://phab.example.com/D2",
339 364 "dateCreated": "1499181406",
340 365 "dateModified": "1499182103",
341 366 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
342 367 "status": "0",
343 368 "statusName": "Needs Review",
344 369 "properties": [],
345 370 "branch": null,
346 371 "summary": "",
347 372 "testPlan": "",
348 373 "lineCount": "2",
349 374 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
350 375 "diffs": [
351 376 "3",
352 377 "4",
353 378 ],
354 379 "commits": [],
355 380 "reviewers": [],
356 381 "ccs": [],
357 382 "hashes": [],
358 383 "auxiliary": {
359 384 "phabricator:projects": [],
360 385 "phabricator:depends-on": [
361 386 "PHID-DREV-gbapp366kutjebt7agcd"
362 387 ]
363 388 },
364 389 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
365 390 "sourcePath": null
366 391 }
367 392
368 393 If stack is True, return a list of "Differential Revision dict"s in an
369 394 order that the latter ones depend on the former ones. Otherwise, return a
370 395 list of a unique "Differential Revision dict".
371 396 """
372 397 prefetched = {} # {id or phid: drev}
373 398 def fetch(params):
374 399 """params -> single drev or None"""
375 400 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
376 401 if key in prefetched:
377 402 return prefetched[key]
378 403 # Otherwise, send the request. If we're fetching a stack, be smarter
379 404 # and fetch more ids in one batch, even if it could be unnecessary.
380 405 batchparams = params
381 406 if stack and len(params.get(r'ids', [])) == 1:
382 407 i = int(params[r'ids'][0])
383 408 # developer config: phabricator.batchsize
384 409 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
385 410 batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
386 411 drevs = callconduit(repo, 'differential.query', batchparams)
387 412 # Fill prefetched with the result
388 413 for drev in drevs:
389 414 prefetched[drev[r'phid']] = drev
390 415 prefetched[int(drev[r'id'])] = drev
391 416 if key not in prefetched:
392 417 raise error.Abort(_('cannot get Differential Revision %r') % params)
393 418 return prefetched[key]
394 419
395 420 visited = set()
396 421 result = []
397 422 queue = [params]
398 423 while queue:
399 424 params = queue.pop()
400 425 drev = fetch(params)
401 426 if drev[r'id'] in visited:
402 427 continue
403 428 visited.add(drev[r'id'])
404 429 result.append(drev)
405 430 if stack:
406 431 auxiliary = drev.get(r'auxiliary', {})
407 432 depends = auxiliary.get(r'phabricator:depends-on', [])
408 433 for phid in depends:
409 434 queue.append({'phids': [phid]})
410 435 result.reverse()
411 436 return result
412 437
413 438 def getdescfromdrev(drev):
414 439 """get description (commit message) from "Differential Revision"
415 440
416 441 This is similar to differential.getcommitmessage API. But we only care
417 442 about limited fields: title, summary, test plan, and URL.
418 443 """
419 444 title = drev[r'title']
420 445 summary = drev[r'summary'].rstrip()
421 446 testplan = drev[r'testPlan'].rstrip()
422 447 if testplan:
423 448 testplan = 'Test Plan:\n%s' % testplan
424 449 uri = 'Differential Revision: %s' % drev[r'uri']
425 450 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
426 451
427 452 def getdiffmeta(diff):
428 453 """get commit metadata (date, node, user, p1) from a diff object
429 454
430 455 The metadata could be "hg:meta", sent by phabsend, like:
431 456
432 457 "properties": {
433 458 "hg:meta": {
434 459 "date": "1499571514 25200",
435 460 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
436 461 "user": "Foo Bar <foo@example.com>",
437 462 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
438 463 }
439 464 }
440 465
441 466 Or converted from "local:commits", sent by "arc", like:
442 467
443 468 "properties": {
444 469 "local:commits": {
445 470 "98c08acae292b2faf60a279b4189beb6cff1414d": {
446 471 "author": "Foo Bar",
447 472 "time": 1499546314,
448 473 "branch": "default",
449 474 "tag": "",
450 475 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
451 476 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
452 477 "local": "1000",
453 478 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
454 479 "summary": "...",
455 480 "message": "...",
456 481 "authorEmail": "foo@example.com"
457 482 }
458 483 }
459 484 }
460 485
461 486 Note: metadata extracted from "local:commits" will lose time zone
462 487 information.
463 488 """
464 489 props = diff.get(r'properties') or {}
465 490 meta = props.get(r'hg:meta')
466 491 if not meta and props.get(r'local:commits'):
467 492 commit = sorted(props[r'local:commits'].values())[0]
468 493 meta = {
469 494 r'date': r'%d 0' % commit[r'time'],
470 495 r'node': commit[r'rev'],
471 496 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
472 497 }
473 498 if len(commit.get(r'parents', ())) >= 1:
474 499 meta[r'parent'] = commit[r'parents'][0]
475 500 return meta or {}
476 501
477 502 def readpatch(repo, params, write, stack=False):
478 503 """generate plain-text patch readable by 'hg import'
479 504
480 505 write is usually ui.write. params is passed to "differential.query". If
481 506 stack is True, also write dependent patches.
482 507 """
483 508 # Differential Revisions
484 509 drevs = querydrev(repo, params, stack)
485 510
486 511 # Prefetch hg:meta property for all diffs
487 512 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
488 513 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
489 514
490 515 # Generate patch for each drev
491 516 for drev in drevs:
492 517 repo.ui.note(_('reading D%s\n') % drev[r'id'])
493 518
494 519 diffid = max(int(v) for v in drev[r'diffs'])
495 520 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
496 521 desc = getdescfromdrev(drev)
497 522 header = '# HG changeset patch\n'
498 523
499 524 # Try to preserve metadata from hg:meta property. Write hg patch
500 525 # headers that can be read by the "import" command. See patchheadermap
501 526 # and extract in mercurial/patch.py for supported headers.
502 527 meta = getdiffmeta(diffs[str(diffid)])
503 528 for k in _metanamemap.keys():
504 529 if k in meta:
505 530 header += '# %s %s\n' % (_metanamemap[k], meta[k])
506 531
507 532 write(('%s%s\n%s') % (header, desc, body))
508 533
509 534 @command('phabread',
510 535 [('', 'stack', False, _('read dependencies'))],
511 536 _('REVID [OPTIONS]'))
512 537 def phabread(ui, repo, revid, **opts):
513 538 """print patches from Phabricator suitable for importing
514 539
515 540 REVID could be a Differential Revision identity, like ``D123``, or just the
516 541 number ``123``, or a full URL like ``https://phab.example.com/D123``.
517 542
518 543 If --stack is given, follow dependencies information and read all patches.
519 544 """
520 545 try:
521 546 revid = int(revid.split('/')[-1].replace('D', ''))
522 547 except ValueError:
523 548 raise error.Abort(_('invalid Revision ID: %s') % revid)
524 549 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
General Comments 0
You need to be logged in to leave comments. Login now