##// END OF EJS Templates
phabricator: try to fetch differential revisions in batch...
Jun Wu -
r33269:ead67493 default
parent child Browse files
Show More
@@ -1,441 +1,461 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 142 def getmapping(ctx):
143 143 """return (node, associated Differential Revision ID) or (None, None)
144 144
145 145 Examines all precursors and their tags. Tags with format like "D1234" are
146 146 considered a match and the node with that tag, and the number after "D"
147 147 (ex. 1234) will be returned.
148 148
149 149 If tags are not found, examine commit message. The "Differential Revision:"
150 150 line could associate this changeset to a Differential Revision.
151 151 """
152 152 unfi = ctx.repo().unfiltered()
153 153 nodemap = unfi.changelog.nodemap
154 154
155 155 # Check tags like "D123"
156 156 for n in obsolete.allprecursors(unfi.obsstore, [ctx.node()]):
157 157 if n in nodemap:
158 158 for tag in unfi.nodetags(n):
159 159 m = _differentialrevisiontagre.match(tag)
160 160 if m:
161 161 return n, int(m.group(1))
162 162
163 163 # Check commit message
164 164 m = _differentialrevisiondescre.search(ctx.description())
165 165 if m:
166 166 return None, int(m.group(1))
167 167
168 168 return None, None
169 169
170 170 def getdiff(ctx, diffopts):
171 171 """plain-text diff without header (user, commit message, etc)"""
172 172 output = util.stringio()
173 173 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
174 174 None, opts=diffopts):
175 175 output.write(chunk)
176 176 return output.getvalue()
177 177
178 178 def creatediff(ctx):
179 179 """create a Differential Diff"""
180 180 repo = ctx.repo()
181 181 repophid = getrepophid(repo)
182 182 # Create a "Differential Diff" via "differential.createrawdiff" API
183 183 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
184 184 if repophid:
185 185 params['repositoryPHID'] = repophid
186 186 diff = callconduit(repo, 'differential.createrawdiff', params)
187 187 if not diff:
188 188 raise error.Abort(_('cannot create diff for %s') % ctx)
189 189 return diff
190 190
191 191 def writediffproperties(ctx, diff):
192 192 """write metadata to diff so patches could be applied losslessly"""
193 193 params = {
194 194 'diff_id': diff[r'id'],
195 195 'name': 'hg:meta',
196 196 'data': json.dumps({
197 197 'user': ctx.user(),
198 198 'date': '%d %d' % ctx.date(),
199 199 'node': ctx.hex(),
200 200 'parent': ctx.p1().hex(),
201 201 }),
202 202 }
203 203 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
204 204
205 205 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None):
206 206 """create or update a Differential Revision
207 207
208 208 If revid is None, create a new Differential Revision, otherwise update
209 209 revid. If parentrevid is not None, set it as a dependency.
210 210
211 211 If oldnode is not None, check if the patch content (without commit message
212 212 and metadata) has changed before creating another diff.
213 213 """
214 214 repo = ctx.repo()
215 215 if oldnode:
216 216 diffopts = mdiff.diffopts(git=True, context=1)
217 217 oldctx = repo.unfiltered()[oldnode]
218 218 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
219 219 else:
220 220 neednewdiff = True
221 221
222 222 transactions = []
223 223 if neednewdiff:
224 224 diff = creatediff(ctx)
225 225 writediffproperties(ctx, diff)
226 226 transactions.append({'type': 'update', 'value': diff[r'phid']})
227 227
228 228 # Use a temporary summary to set dependency. There might be better ways but
229 229 # I cannot find them for now. But do not do that if we are updating an
230 230 # existing revision (revid is not None) since that introduces visible
231 231 # churns (someone edited "Summary" twice) on the web page.
232 232 if parentrevid and revid is None:
233 233 summary = 'Depends on D%s' % parentrevid
234 234 transactions += [{'type': 'summary', 'value': summary},
235 235 {'type': 'summary', 'value': ' '}]
236 236
237 237 # Parse commit message and update related fields.
238 238 desc = ctx.description()
239 239 info = callconduit(repo, 'differential.parsecommitmessage',
240 240 {'corpus': desc})
241 241 for k, v in info[r'fields'].items():
242 242 if k in ['title', 'summary', 'testPlan']:
243 243 transactions.append({'type': k, 'value': v})
244 244
245 245 params = {'transactions': transactions}
246 246 if revid is not None:
247 247 # Update an existing Differential Revision
248 248 params['objectIdentifier'] = revid
249 249
250 250 revision = callconduit(repo, 'differential.revision.edit', params)
251 251 if not revision:
252 252 raise error.Abort(_('cannot create revision for %s') % ctx)
253 253
254 254 return revision
255 255
256 256 @command('phabsend',
257 257 [('r', 'rev', [], _('revisions to send'), _('REV'))],
258 258 _('REV [OPTIONS]'))
259 259 def phabsend(ui, repo, *revs, **opts):
260 260 """upload changesets to Phabricator
261 261
262 262 If there are multiple revisions specified, they will be send as a stack
263 263 with a linear dependencies relationship using the order specified by the
264 264 revset.
265 265
266 266 For the first time uploading changesets, local tags will be created to
267 267 maintain the association. After the first time, phabsend will check
268 268 obsstore and tags information so it can figure out whether to update an
269 269 existing Differential Revision, or create a new one.
270 270 """
271 271 revs = list(revs) + opts.get('rev', [])
272 272 revs = scmutil.revrange(repo, revs)
273 273
274 274 if not revs:
275 275 raise error.Abort(_('phabsend requires at least one changeset'))
276 276
277 277 # Send patches one by one so we know their Differential Revision IDs and
278 278 # can provide dependency relationship
279 279 lastrevid = None
280 280 for rev in revs:
281 281 ui.debug('sending rev %d\n' % rev)
282 282 ctx = repo[rev]
283 283
284 284 # Get Differential Revision ID
285 285 oldnode, revid = getmapping(ctx)
286 286 if oldnode != ctx.node():
287 287 # Create or update Differential Revision
288 288 revision = createdifferentialrevision(ctx, revid, lastrevid,
289 289 oldnode)
290 290 newrevid = int(revision[r'object'][r'id'])
291 291 if revid:
292 292 action = _('updated')
293 293 else:
294 294 action = _('created')
295 295
296 296 # Create a local tag to note the association
297 297 tagname = 'D%d' % newrevid
298 298 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
299 299 date=None, local=True)
300 300 else:
301 301 # Nothing changed. But still set "newrevid" so the next revision
302 302 # could depend on this one.
303 303 newrevid = revid
304 304 action = _('skipped')
305 305
306 306 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
307 307 ctx.description().split('\n')[0]))
308 308 lastrevid = newrevid
309 309
310 310 # Map from "hg:meta" keys to header understood by "hg import". The order is
311 311 # consistent with "hg export" output.
312 312 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
313 313 (r'node', 'Node ID'), (r'parent', 'Parent ')])
314 314
315 315 def querydrev(repo, params, stack=False):
316 316 """return a list of "Differential Revision" dicts
317 317
318 318 params is the input of "differential.query" API, and is expected to match
319 319 just a single Differential Revision.
320 320
321 321 A "Differential Revision dict" looks like:
322 322
323 323 {
324 324 "id": "2",
325 325 "phid": "PHID-DREV-672qvysjcczopag46qty",
326 326 "title": "example",
327 327 "uri": "https://phab.example.com/D2",
328 328 "dateCreated": "1499181406",
329 329 "dateModified": "1499182103",
330 330 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
331 331 "status": "0",
332 332 "statusName": "Needs Review",
333 333 "properties": [],
334 334 "branch": null,
335 335 "summary": "",
336 336 "testPlan": "",
337 337 "lineCount": "2",
338 338 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
339 339 "diffs": [
340 340 "3",
341 341 "4",
342 342 ],
343 343 "commits": [],
344 344 "reviewers": [],
345 345 "ccs": [],
346 346 "hashes": [],
347 347 "auxiliary": {
348 348 "phabricator:projects": [],
349 349 "phabricator:depends-on": [
350 350 "PHID-DREV-gbapp366kutjebt7agcd"
351 351 ]
352 352 },
353 353 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
354 354 "sourcePath": null
355 355 }
356 356
357 357 If stack is True, return a list of "Differential Revision dict"s in an
358 358 order that the latter ones depend on the former ones. Otherwise, return a
359 359 list of a unique "Differential Revision dict".
360 360 """
361 prefetched = {} # {id or phid: drev}
362 def fetch(params):
363 """params -> single drev or None"""
364 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
365 if key in prefetched:
366 return prefetched[key]
367 # Otherwise, send the request. If we're fetching a stack, be smarter
368 # and fetch more ids in one batch, even if it could be unnecessary.
369 batchparams = params
370 if stack and len(params.get(r'ids', [])) == 1:
371 i = int(params[r'ids'][0])
372 # developer config: phabricator.batchsize
373 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
374 batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
375 drevs = callconduit(repo, 'differential.query', batchparams)
376 # Fill prefetched with the result
377 for drev in drevs:
378 prefetched[drev[r'phid']] = drev
379 prefetched[int(drev[r'id'])] = drev
380 if key not in prefetched:
381 raise error.Abort(_('cannot get Differential Revision %r') % params)
382 return prefetched[key]
383
361 384 result = []
362 385 queue = [params]
363 386 while queue:
364 387 params = queue.pop()
365 drevs = callconduit(repo, 'differential.query', params)
366 if len(drevs) != 1:
367 raise error.Abort(_('cannot get Differential Revision %r') % params)
368 drev = drevs[0]
388 drev = fetch(params)
369 389 result.append(drev)
370 390 if stack:
371 391 auxiliary = drev.get(r'auxiliary', {})
372 392 depends = auxiliary.get(r'phabricator:depends-on', [])
373 393 for phid in depends:
374 394 queue.append({'phids': [phid]})
375 395 result.reverse()
376 396 return result
377 397
378 398 def getdescfromdrev(drev):
379 399 """get description (commit message) from "Differential Revision"
380 400
381 401 This is similar to differential.getcommitmessage API. But we only care
382 402 about limited fields: title, summary, test plan, and URL.
383 403 """
384 404 title = drev[r'title']
385 405 summary = drev[r'summary'].rstrip()
386 406 testplan = drev[r'testPlan'].rstrip()
387 407 if testplan:
388 408 testplan = 'Test Plan:\n%s' % testplan
389 409 uri = 'Differential Revision: %s' % drev[r'uri']
390 410 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
391 411
392 412 def readpatch(repo, params, write, stack=False):
393 413 """generate plain-text patch readable by 'hg import'
394 414
395 415 write is usually ui.write. params is passed to "differential.query". If
396 416 stack is True, also write dependent patches.
397 417 """
398 418 # Differential Revisions
399 419 drevs = querydrev(repo, params, stack)
400 420
401 421 # Prefetch hg:meta property for all diffs
402 422 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
403 423 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
404 424
405 425 # Generate patch for each drev
406 426 for drev in drevs:
407 427 repo.ui.note(_('reading D%s\n') % drev[r'id'])
408 428
409 429 diffid = max(int(v) for v in drev[r'diffs'])
410 430 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
411 431 desc = getdescfromdrev(drev)
412 432 header = '# HG changeset patch\n'
413 433
414 434 # Try to preserve metadata from hg:meta property. Write hg patch
415 435 # headers that can be read by the "import" command. See patchheadermap
416 436 # and extract in mercurial/patch.py for supported headers.
417 437 props = diffs[str(diffid)][r'properties'] # could be empty list or dict
418 438 if props and r'hg:meta' in props:
419 439 meta = props[r'hg:meta']
420 440 for k in _metanamemap.keys():
421 441 if k in meta:
422 442 header += '# %s %s\n' % (_metanamemap[k], meta[k])
423 443
424 444 write(('%s%s\n%s') % (header, desc, body))
425 445
426 446 @command('phabread',
427 447 [('', 'stack', False, _('read dependencies'))],
428 448 _('REVID [OPTIONS]'))
429 449 def phabread(ui, repo, revid, **opts):
430 450 """print patches from Phabricator suitable for importing
431 451
432 452 REVID could be a Differential Revision identity, like ``D123``, or just the
433 453 number ``123``, or a full URL like ``https://phab.example.com/D123``.
434 454
435 455 If --stack is given, follow dependencies information and read all patches.
436 456 """
437 457 try:
438 458 revid = int(revid.split('/')[-1].replace('D', ''))
439 459 except ValueError:
440 460 raise error.Abort(_('invalid Revision ID: %s') % revid)
441 461 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
General Comments 0
You need to be logged in to leave comments. Login now