##// END OF EJS Templates
phabricator: rework phabread to reduce memory usage and round-trips...
Jun Wu -
r33267:dba9f886 default
parent child Browse files
Show More
@@ -1,378 +1,433 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 _summaryre = re.compile('^Summary:\s*', re.M)
311 311
312 312 # Map from "hg:meta" keys to header understood by "hg import". The order is
313 313 # consistent with "hg export" output.
314 314 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
315 315 (r'node', 'Node ID'), (r'parent', 'Parent ')])
316 316
317 def readpatch(repo, params, recursive=False):
317 def querydrev(repo, params, stack=False):
318 """return a list of "Differential Revision" dicts
319
320 params is the input of "differential.query" API, and is expected to match
321 just a single Differential Revision.
322
323 A "Differential Revision dict" looks like:
324
325 {
326 "id": "2",
327 "phid": "PHID-DREV-672qvysjcczopag46qty",
328 "title": "example",
329 "uri": "https://phab.example.com/D2",
330 "dateCreated": "1499181406",
331 "dateModified": "1499182103",
332 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
333 "status": "0",
334 "statusName": "Needs Review",
335 "properties": [],
336 "branch": null,
337 "summary": "",
338 "testPlan": "",
339 "lineCount": "2",
340 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
341 "diffs": [
342 "3",
343 "4",
344 ],
345 "commits": [],
346 "reviewers": [],
347 "ccs": [],
348 "hashes": [],
349 "auxiliary": {
350 "phabricator:projects": [],
351 "phabricator:depends-on": [
352 "PHID-DREV-gbapp366kutjebt7agcd"
353 ]
354 },
355 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
356 "sourcePath": null
357 }
358
359 If stack is True, return a list of "Differential Revision dict"s in an
360 order that the latter ones depend on the former ones. Otherwise, return a
361 list of a unique "Differential Revision dict".
362 """
363 result = []
364 queue = [params]
365 while queue:
366 params = queue.pop()
367 drevs = callconduit(repo, 'differential.query', params)
368 if len(drevs) != 1:
369 raise error.Abort(_('cannot get Differential Revision %r') % params)
370 drev = drevs[0]
371 result.append(drev)
372 if stack:
373 auxiliary = drev.get(r'auxiliary', {})
374 depends = auxiliary.get(r'phabricator:depends-on', [])
375 for phid in depends:
376 queue.append({'phids': [phid]})
377 result.reverse()
378 return result
379
380 def readpatch(repo, params, write, stack=False):
318 381 """generate plain-text patch readable by 'hg import'
319 382
320 params is passed to "differential.query". If recursive is True, also return
321 dependent patches.
383 write is usually ui.write. params is passed to "differential.query". If
384 stack is True, also write dependent patches.
322 385 """
323 386 # Differential Revisions
324 drevs = callconduit(repo, 'differential.query', params)
325 if len(drevs) == 1:
326 drev = drevs[0]
327 else:
328 raise error.Abort(_('cannot get Differential Revision %r') % params)
329
330 repo.ui.note(_('reading D%s\n') % drev[r'id'])
387 drevs = querydrev(repo, params, stack)
331 388
332 diffid = max(int(v) for v in drev[r'diffs'])
333 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
334 desc = callconduit(repo, 'differential.getcommitmessage',
335 {'revision_id': drev[r'id']})
336 header = '# HG changeset patch\n'
389 # Prefetch hg:meta property for all diffs
390 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
391 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
337 392
338 # Remove potential empty "Summary:"
339 desc = _summaryre.sub('', desc)
393 # Generate patch for each drev
394 for drev in drevs:
395 repo.ui.note(_('reading D%s\n') % drev[r'id'])
340 396
341 # Try to preserve metadata from hg:meta property. Write hg patch headers
342 # that can be read by the "import" command. See patchheadermap and extract
343 # in mercurial/patch.py for supported headers.
344 diffs = callconduit(repo, 'differential.querydiffs', {'ids': [diffid]})
345 props = diffs[str(diffid)][r'properties'] # could be empty list or dict
346 if props and r'hg:meta' in props:
347 meta = props[r'hg:meta']
348 for k in _metanamemap.keys():
349 if k in meta:
350 header += '# %s %s\n' % (_metanamemap[k], meta[k])
397 diffid = max(int(v) for v in drev[r'diffs'])
398 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
399 desc = callconduit(repo, 'differential.getcommitmessage',
400 {'revision_id': drev[r'id']})
401 header = '# HG changeset patch\n'
402
403 # Remove potential empty "Summary:"
404 desc = _summaryre.sub('', desc)
351 405
352 patch = ('%s%s\n%s') % (header, desc, body)
406 # Try to preserve metadata from hg:meta property. Write hg patch
407 # headers that can be read by the "import" command. See patchheadermap
408 # and extract in mercurial/patch.py for supported headers.
409 props = diffs[str(diffid)][r'properties'] # could be empty list or dict
410 if props and r'hg:meta' in props:
411 meta = props[r'hg:meta']
412 for k in _metanamemap.keys():
413 if k in meta:
414 header += '# %s %s\n' % (_metanamemap[k], meta[k])
353 415
354 # Check dependencies
355 if recursive:
356 auxiliary = drev.get(r'auxiliary', {})
357 depends = auxiliary.get(r'phabricator:depends-on', [])
358 for phid in depends:
359 patch = readpatch(repo, {'phids': [phid]}, recursive=True) + patch
360 return patch
416 write(('%s%s\n%s') % (header, desc, body))
361 417
362 418 @command('phabread',
363 419 [('', 'stack', False, _('read dependencies'))],
364 420 _('REVID [OPTIONS]'))
365 421 def phabread(ui, repo, revid, **opts):
366 422 """print patches from Phabricator suitable for importing
367 423
368 424 REVID could be a Differential Revision identity, like ``D123``, or just the
369 425 number ``123``, or a full URL like ``https://phab.example.com/D123``.
370 426
371 427 If --stack is given, follow dependencies information and read all patches.
372 428 """
373 429 try:
374 430 revid = int(revid.split('/')[-1].replace('D', ''))
375 431 except ValueError:
376 432 raise error.Abort(_('invalid Revision ID: %s') % revid)
377 patch = readpatch(repo, {'ids': [revid]}, recursive=opts.get('stack'))
378 ui.write(patch)
433 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
General Comments 0
You need to be logged in to leave comments. Login now