##// END OF EJS Templates
phabricator: do not upload new diff if nothing changes...
Jun Wu -
r33265:95f658b5 default
parent child Browse files
Show More
@@ -1,363 +1,375 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 def createdifferentialrevision(ctx, revid=None, parentrevid=None):
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
211 If oldnode is not None, check if the patch content (without commit message
212 and metadata) has changed before creating another diff.
210 213 """
211 214 repo = ctx.repo()
212 diff = creatediff(ctx)
213 writediffproperties(ctx, diff)
215 if oldnode:
216 diffopts = mdiff.diffopts(git=True, context=1)
217 oldctx = repo.unfiltered()[oldnode]
218 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
219 else:
220 neednewdiff = True
214 221
215 transactions = [{'type': 'update', 'value': diff[r'phid']}]
222 transactions = []
223 if neednewdiff:
224 diff = creatediff(ctx)
225 writediffproperties(ctx, diff)
226 transactions.append({'type': 'update', 'value': diff[r'phid']})
216 227
217 228 # Use a temporary summary to set dependency. There might be better ways but
218 229 # I cannot find them for now. But do not do that if we are updating an
219 230 # existing revision (revid is not None) since that introduces visible
220 231 # churns (someone edited "Summary" twice) on the web page.
221 232 if parentrevid and revid is None:
222 233 summary = 'Depends on D%s' % parentrevid
223 234 transactions += [{'type': 'summary', 'value': summary},
224 235 {'type': 'summary', 'value': ' '}]
225 236
226 237 # Parse commit message and update related fields.
227 238 desc = ctx.description()
228 239 info = callconduit(repo, 'differential.parsecommitmessage',
229 240 {'corpus': desc})
230 241 for k, v in info[r'fields'].items():
231 242 if k in ['title', 'summary', 'testPlan']:
232 243 transactions.append({'type': k, 'value': v})
233 244
234 245 params = {'transactions': transactions}
235 246 if revid is not None:
236 247 # Update an existing Differential Revision
237 248 params['objectIdentifier'] = revid
238 249
239 250 revision = callconduit(repo, 'differential.revision.edit', params)
240 251 if not revision:
241 252 raise error.Abort(_('cannot create revision for %s') % ctx)
242 253
243 254 return revision
244 255
245 256 @command('phabsend',
246 257 [('r', 'rev', [], _('revisions to send'), _('REV'))],
247 258 _('REV [OPTIONS]'))
248 259 def phabsend(ui, repo, *revs, **opts):
249 260 """upload changesets to Phabricator
250 261
251 262 If there are multiple revisions specified, they will be send as a stack
252 263 with a linear dependencies relationship using the order specified by the
253 264 revset.
254 265
255 266 For the first time uploading changesets, local tags will be created to
256 267 maintain the association. After the first time, phabsend will check
257 268 obsstore and tags information so it can figure out whether to update an
258 269 existing Differential Revision, or create a new one.
259 270 """
260 271 revs = list(revs) + opts.get('rev', [])
261 272 revs = scmutil.revrange(repo, revs)
262 273
263 274 # Send patches one by one so we know their Differential Revision IDs and
264 275 # can provide dependency relationship
265 276 lastrevid = None
266 277 for rev in revs:
267 278 ui.debug('sending rev %d\n' % rev)
268 279 ctx = repo[rev]
269 280
270 281 # Get Differential Revision ID
271 282 oldnode, revid = getmapping(ctx)
272 283 if oldnode != ctx.node():
273 284 # Create or update Differential Revision
274 revision = createdifferentialrevision(ctx, revid, lastrevid)
285 revision = createdifferentialrevision(ctx, revid, lastrevid,
286 oldnode)
275 287 newrevid = int(revision[r'object'][r'id'])
276 288 if revid:
277 289 action = _('updated')
278 290 else:
279 291 action = _('created')
280 292
281 293 # Create a local tag to note the association
282 294 tagname = 'D%d' % newrevid
283 295 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
284 296 date=None, local=True)
285 297 else:
286 298 # Nothing changed. But still set "newrevid" so the next revision
287 299 # could depend on this one.
288 300 newrevid = revid
289 301 action = _('skipped')
290 302
291 303 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
292 304 ctx.description().split('\n')[0]))
293 305 lastrevid = newrevid
294 306
295 307 _summaryre = re.compile('^Summary:\s*', re.M)
296 308
297 309 # Map from "hg:meta" keys to header understood by "hg import". The order is
298 310 # consistent with "hg export" output.
299 311 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
300 312 (r'node', 'Node ID'), (r'parent', 'Parent ')])
301 313
302 314 def readpatch(repo, params, recursive=False):
303 315 """generate plain-text patch readable by 'hg import'
304 316
305 317 params is passed to "differential.query". If recursive is True, also return
306 318 dependent patches.
307 319 """
308 320 # Differential Revisions
309 321 drevs = callconduit(repo, 'differential.query', params)
310 322 if len(drevs) == 1:
311 323 drev = drevs[0]
312 324 else:
313 325 raise error.Abort(_('cannot get Differential Revision %r') % params)
314 326
315 327 repo.ui.note(_('reading D%s\n') % drev[r'id'])
316 328
317 329 diffid = max(int(v) for v in drev[r'diffs'])
318 330 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
319 331 desc = callconduit(repo, 'differential.getcommitmessage',
320 332 {'revision_id': drev[r'id']})
321 333 header = '# HG changeset patch\n'
322 334
323 335 # Remove potential empty "Summary:"
324 336 desc = _summaryre.sub('', desc)
325 337
326 338 # Try to preserve metadata from hg:meta property. Write hg patch headers
327 339 # that can be read by the "import" command. See patchheadermap and extract
328 340 # in mercurial/patch.py for supported headers.
329 341 diffs = callconduit(repo, 'differential.querydiffs', {'ids': [diffid]})
330 342 props = diffs[str(diffid)][r'properties'] # could be empty list or dict
331 343 if props and r'hg:meta' in props:
332 344 meta = props[r'hg:meta']
333 345 for k in _metanamemap.keys():
334 346 if k in meta:
335 347 header += '# %s %s\n' % (_metanamemap[k], meta[k])
336 348
337 349 patch = ('%s%s\n%s') % (header, desc, body)
338 350
339 351 # Check dependencies
340 352 if recursive:
341 353 auxiliary = drev.get(r'auxiliary', {})
342 354 depends = auxiliary.get(r'phabricator:depends-on', [])
343 355 for phid in depends:
344 356 patch = readpatch(repo, {'phids': [phid]}, recursive=True) + patch
345 357 return patch
346 358
347 359 @command('phabread',
348 360 [('', 'stack', False, _('read dependencies'))],
349 361 _('REVID [OPTIONS]'))
350 362 def phabread(ui, repo, revid, **opts):
351 363 """print patches from Phabricator suitable for importing
352 364
353 365 REVID could be a Differential Revision identity, like ``D123``, or just the
354 366 number ``123``, or a full URL like ``https://phab.example.com/D123``.
355 367
356 368 If --stack is given, follow dependencies information and read all patches.
357 369 """
358 370 try:
359 371 revid = int(revid.split('/')[-1].replace('D', ''))
360 372 except ValueError:
361 373 raise error.Abort(_('invalid Revision ID: %s') % revid)
362 374 patch = readpatch(repo, {'ids': [revid]}, recursive=opts.get('stack'))
363 375 ui.write(patch)
General Comments 0
You need to be logged in to leave comments. Login now