##// END OF EJS Templates
phabricator: abort if phabsend gets empty revs...
Jun Wu -
r33266:5b2391b4 default
parent child Browse files
Show More
@@ -1,375 +1,378
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 if not revs:
275 raise error.Abort(_('phabsend requires at least one changeset'))
276
274 277 # Send patches one by one so we know their Differential Revision IDs and
275 278 # can provide dependency relationship
276 279 lastrevid = None
277 280 for rev in revs:
278 281 ui.debug('sending rev %d\n' % rev)
279 282 ctx = repo[rev]
280 283
281 284 # Get Differential Revision ID
282 285 oldnode, revid = getmapping(ctx)
283 286 if oldnode != ctx.node():
284 287 # Create or update Differential Revision
285 288 revision = createdifferentialrevision(ctx, revid, lastrevid,
286 289 oldnode)
287 290 newrevid = int(revision[r'object'][r'id'])
288 291 if revid:
289 292 action = _('updated')
290 293 else:
291 294 action = _('created')
292 295
293 296 # Create a local tag to note the association
294 297 tagname = 'D%d' % newrevid
295 298 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
296 299 date=None, local=True)
297 300 else:
298 301 # Nothing changed. But still set "newrevid" so the next revision
299 302 # could depend on this one.
300 303 newrevid = revid
301 304 action = _('skipped')
302 305
303 306 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
304 307 ctx.description().split('\n')[0]))
305 308 lastrevid = newrevid
306 309
307 310 _summaryre = re.compile('^Summary:\s*', re.M)
308 311
309 312 # Map from "hg:meta" keys to header understood by "hg import". The order is
310 313 # consistent with "hg export" output.
311 314 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
312 315 (r'node', 'Node ID'), (r'parent', 'Parent ')])
313 316
314 317 def readpatch(repo, params, recursive=False):
315 318 """generate plain-text patch readable by 'hg import'
316 319
317 320 params is passed to "differential.query". If recursive is True, also return
318 321 dependent patches.
319 322 """
320 323 # Differential Revisions
321 324 drevs = callconduit(repo, 'differential.query', params)
322 325 if len(drevs) == 1:
323 326 drev = drevs[0]
324 327 else:
325 328 raise error.Abort(_('cannot get Differential Revision %r') % params)
326 329
327 330 repo.ui.note(_('reading D%s\n') % drev[r'id'])
328 331
329 332 diffid = max(int(v) for v in drev[r'diffs'])
330 333 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
331 334 desc = callconduit(repo, 'differential.getcommitmessage',
332 335 {'revision_id': drev[r'id']})
333 336 header = '# HG changeset patch\n'
334 337
335 338 # Remove potential empty "Summary:"
336 339 desc = _summaryre.sub('', desc)
337 340
338 341 # Try to preserve metadata from hg:meta property. Write hg patch headers
339 342 # that can be read by the "import" command. See patchheadermap and extract
340 343 # in mercurial/patch.py for supported headers.
341 344 diffs = callconduit(repo, 'differential.querydiffs', {'ids': [diffid]})
342 345 props = diffs[str(diffid)][r'properties'] # could be empty list or dict
343 346 if props and r'hg:meta' in props:
344 347 meta = props[r'hg:meta']
345 348 for k in _metanamemap.keys():
346 349 if k in meta:
347 350 header += '# %s %s\n' % (_metanamemap[k], meta[k])
348 351
349 352 patch = ('%s%s\n%s') % (header, desc, body)
350 353
351 354 # Check dependencies
352 355 if recursive:
353 356 auxiliary = drev.get(r'auxiliary', {})
354 357 depends = auxiliary.get(r'phabricator:depends-on', [])
355 358 for phid in depends:
356 359 patch = readpatch(repo, {'phids': [phid]}, recursive=True) + patch
357 360 return patch
358 361
359 362 @command('phabread',
360 363 [('', 'stack', False, _('read dependencies'))],
361 364 _('REVID [OPTIONS]'))
362 365 def phabread(ui, repo, revid, **opts):
363 366 """print patches from Phabricator suitable for importing
364 367
365 368 REVID could be a Differential Revision identity, like ``D123``, or just the
366 369 number ``123``, or a full URL like ``https://phab.example.com/D123``.
367 370
368 371 If --stack is given, follow dependencies information and read all patches.
369 372 """
370 373 try:
371 374 revid = int(revid.split('/')[-1].replace('D', ''))
372 375 except ValueError:
373 376 raise error.Abort(_('invalid Revision ID: %s') % revid)
374 377 patch = readpatch(repo, {'ids': [revid]}, recursive=opts.get('stack'))
375 378 ui.write(patch)
General Comments 0
You need to be logged in to leave comments. Login now