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