##// END OF EJS Templates
phabricator: respect metadata sent by arc...
Jun Wu -
r33441:de7c6ec2 default
parent child Browse files
Show More
@@ -1,465 +1,513 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 361 prefetched = {} # {id or phid: drev}
362 362 def fetch(params):
363 363 """params -> single drev or None"""
364 364 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
365 365 if key in prefetched:
366 366 return prefetched[key]
367 367 # Otherwise, send the request. If we're fetching a stack, be smarter
368 368 # and fetch more ids in one batch, even if it could be unnecessary.
369 369 batchparams = params
370 370 if stack and len(params.get(r'ids', [])) == 1:
371 371 i = int(params[r'ids'][0])
372 372 # developer config: phabricator.batchsize
373 373 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
374 374 batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
375 375 drevs = callconduit(repo, 'differential.query', batchparams)
376 376 # Fill prefetched with the result
377 377 for drev in drevs:
378 378 prefetched[drev[r'phid']] = drev
379 379 prefetched[int(drev[r'id'])] = drev
380 380 if key not in prefetched:
381 381 raise error.Abort(_('cannot get Differential Revision %r') % params)
382 382 return prefetched[key]
383 383
384 384 visited = set()
385 385 result = []
386 386 queue = [params]
387 387 while queue:
388 388 params = queue.pop()
389 389 drev = fetch(params)
390 390 if drev[r'id'] in visited:
391 391 continue
392 392 visited.add(drev[r'id'])
393 393 result.append(drev)
394 394 if stack:
395 395 auxiliary = drev.get(r'auxiliary', {})
396 396 depends = auxiliary.get(r'phabricator:depends-on', [])
397 397 for phid in depends:
398 398 queue.append({'phids': [phid]})
399 399 result.reverse()
400 400 return result
401 401
402 402 def getdescfromdrev(drev):
403 403 """get description (commit message) from "Differential Revision"
404 404
405 405 This is similar to differential.getcommitmessage API. But we only care
406 406 about limited fields: title, summary, test plan, and URL.
407 407 """
408 408 title = drev[r'title']
409 409 summary = drev[r'summary'].rstrip()
410 410 testplan = drev[r'testPlan'].rstrip()
411 411 if testplan:
412 412 testplan = 'Test Plan:\n%s' % testplan
413 413 uri = 'Differential Revision: %s' % drev[r'uri']
414 414 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
415 415
416 def getdiffmeta(diff):
417 """get commit metadata (date, node, user, p1) from a diff object
418
419 The metadata could be "hg:meta", sent by phabsend, like:
420
421 "properties": {
422 "hg:meta": {
423 "date": "1499571514 25200",
424 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
425 "user": "Foo Bar <foo@example.com>",
426 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
427 }
428 }
429
430 Or converted from "local:commits", sent by "arc", like:
431
432 "properties": {
433 "local:commits": {
434 "98c08acae292b2faf60a279b4189beb6cff1414d": {
435 "author": "Foo Bar",
436 "time": 1499546314,
437 "branch": "default",
438 "tag": "",
439 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
440 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
441 "local": "1000",
442 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
443 "summary": "...",
444 "message": "...",
445 "authorEmail": "foo@example.com"
446 }
447 }
448 }
449
450 Note: metadata extracted from "local:commits" will lose time zone
451 information.
452 """
453 props = diff.get(r'properties') or {}
454 meta = props.get(r'hg:meta')
455 if not meta and props.get(r'local:commits'):
456 commit = sorted(props[r'local:commits'].values())[0]
457 meta = {
458 r'date': r'%d 0' % commit[r'time'],
459 r'node': commit[r'rev'],
460 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
461 }
462 if len(commit.get(r'parents', ())) >= 1:
463 meta[r'parent'] = commit[r'parents'][0]
464 return meta or {}
465
416 466 def readpatch(repo, params, write, stack=False):
417 467 """generate plain-text patch readable by 'hg import'
418 468
419 469 write is usually ui.write. params is passed to "differential.query". If
420 470 stack is True, also write dependent patches.
421 471 """
422 472 # Differential Revisions
423 473 drevs = querydrev(repo, params, stack)
424 474
425 475 # Prefetch hg:meta property for all diffs
426 476 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
427 477 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
428 478
429 479 # Generate patch for each drev
430 480 for drev in drevs:
431 481 repo.ui.note(_('reading D%s\n') % drev[r'id'])
432 482
433 483 diffid = max(int(v) for v in drev[r'diffs'])
434 484 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
435 485 desc = getdescfromdrev(drev)
436 486 header = '# HG changeset patch\n'
437 487
438 488 # Try to preserve metadata from hg:meta property. Write hg patch
439 489 # headers that can be read by the "import" command. See patchheadermap
440 490 # and extract in mercurial/patch.py for supported headers.
441 props = diffs[str(diffid)][r'properties'] # could be empty list or dict
442 if props and r'hg:meta' in props:
443 meta = props[r'hg:meta']
491 meta = getdiffmeta(diffs[str(diffid)])
444 492 for k in _metanamemap.keys():
445 493 if k in meta:
446 494 header += '# %s %s\n' % (_metanamemap[k], meta[k])
447 495
448 496 write(('%s%s\n%s') % (header, desc, body))
449 497
450 498 @command('phabread',
451 499 [('', 'stack', False, _('read dependencies'))],
452 500 _('REVID [OPTIONS]'))
453 501 def phabread(ui, repo, revid, **opts):
454 502 """print patches from Phabricator suitable for importing
455 503
456 504 REVID could be a Differential Revision identity, like ``D123``, or just the
457 505 number ``123``, or a full URL like ``https://phab.example.com/D123``.
458 506
459 507 If --stack is given, follow dependencies information and read all patches.
460 508 """
461 509 try:
462 510 revid = int(revid.split('/')[-1].replace('D', ''))
463 511 except ValueError:
464 512 raise error.Abort(_('invalid Revision ID: %s') % revid)
465 513 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
General Comments 0
You need to be logged in to leave comments. Login now