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