##// END OF EJS Templates
phabricator: check associated Differential Revision from commit message...
Jun Wu -
r33263:ed611897 default
parent child Browse files
Show More
@@ -1,340 +1,353 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 _differentialrevisionre = re.compile('\AD([1-9][0-9]*)\Z')
138 _differentialrevisiontagre = re.compile('\AD([1-9][0-9]*)\Z')
139 _differentialrevisiondescre = re.compile(
140 '^Differential Revision:.*D([1-9][0-9]*)$', re.M)
139 141
140 142 def getmapping(ctx):
141 143 """return (node, associated Differential Revision ID) or (None, None)
142 144
143 145 Examines all precursors and their tags. Tags with format like "D1234" are
144 146 considered a match and the node with that tag, and the number after "D"
145 147 (ex. 1234) will be returned.
148
149 If tags are not found, examine commit message. The "Differential Revision:"
150 line could associate this changeset to a Differential Revision.
146 151 """
147 152 unfi = ctx.repo().unfiltered()
148 153 nodemap = unfi.changelog.nodemap
154
155 # Check tags like "D123"
149 156 for n in obsolete.allprecursors(unfi.obsstore, [ctx.node()]):
150 157 if n in nodemap:
151 158 for tag in unfi.nodetags(n):
152 m = _differentialrevisionre.match(tag)
159 m = _differentialrevisiontagre.match(tag)
153 160 if m:
154 161 return n, int(m.group(1))
162
163 # Check commit message
164 m = _differentialrevisiondescre.search(ctx.description())
165 if m:
166 return None, int(m.group(1))
167
155 168 return None, None
156 169
157 170 def getdiff(ctx, diffopts):
158 171 """plain-text diff without header (user, commit message, etc)"""
159 172 output = util.stringio()
160 173 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
161 174 None, opts=diffopts):
162 175 output.write(chunk)
163 176 return output.getvalue()
164 177
165 178 def creatediff(ctx):
166 179 """create a Differential Diff"""
167 180 repo = ctx.repo()
168 181 repophid = getrepophid(repo)
169 182 # Create a "Differential Diff" via "differential.createrawdiff" API
170 183 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
171 184 if repophid:
172 185 params['repositoryPHID'] = repophid
173 186 diff = callconduit(repo, 'differential.createrawdiff', params)
174 187 if not diff:
175 188 raise error.Abort(_('cannot create diff for %s') % ctx)
176 189 return diff
177 190
178 191 def writediffproperties(ctx, diff):
179 192 """write metadata to diff so patches could be applied losslessly"""
180 193 params = {
181 194 'diff_id': diff[r'id'],
182 195 'name': 'hg:meta',
183 196 'data': json.dumps({
184 197 'user': ctx.user(),
185 198 'date': '%d %d' % ctx.date(),
186 199 }),
187 200 }
188 201 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
189 202
190 203 def createdifferentialrevision(ctx, revid=None, parentrevid=None):
191 204 """create or update a Differential Revision
192 205
193 206 If revid is None, create a new Differential Revision, otherwise update
194 207 revid. If parentrevid is not None, set it as a dependency.
195 208 """
196 209 repo = ctx.repo()
197 210 diff = creatediff(ctx)
198 211 writediffproperties(ctx, diff)
199 212
200 213 transactions = [{'type': 'update', 'value': diff[r'phid']}]
201 214
202 215 # Use a temporary summary to set dependency. There might be better ways but
203 216 # I cannot find them for now. But do not do that if we are updating an
204 217 # existing revision (revid is not None) since that introduces visible
205 218 # churns (someone edited "Summary" twice) on the web page.
206 219 if parentrevid and revid is None:
207 220 summary = 'Depends on D%s' % parentrevid
208 221 transactions += [{'type': 'summary', 'value': summary},
209 222 {'type': 'summary', 'value': ' '}]
210 223
211 224 # Parse commit message and update related fields.
212 225 desc = ctx.description()
213 226 info = callconduit(repo, 'differential.parsecommitmessage',
214 227 {'corpus': desc})
215 228 for k, v in info[r'fields'].items():
216 229 if k in ['title', 'summary', 'testPlan']:
217 230 transactions.append({'type': k, 'value': v})
218 231
219 232 params = {'transactions': transactions}
220 233 if revid is not None:
221 234 # Update an existing Differential Revision
222 235 params['objectIdentifier'] = revid
223 236
224 237 revision = callconduit(repo, 'differential.revision.edit', params)
225 238 if not revision:
226 239 raise error.Abort(_('cannot create revision for %s') % ctx)
227 240
228 241 return revision
229 242
230 243 @command('phabsend',
231 244 [('r', 'rev', [], _('revisions to send'), _('REV'))],
232 245 _('REV [OPTIONS]'))
233 246 def phabsend(ui, repo, *revs, **opts):
234 247 """upload changesets to Phabricator
235 248
236 249 If there are multiple revisions specified, they will be send as a stack
237 250 with a linear dependencies relationship using the order specified by the
238 251 revset.
239 252
240 253 For the first time uploading changesets, local tags will be created to
241 254 maintain the association. After the first time, phabsend will check
242 255 obsstore and tags information so it can figure out whether to update an
243 256 existing Differential Revision, or create a new one.
244 257 """
245 258 revs = list(revs) + opts.get('rev', [])
246 259 revs = scmutil.revrange(repo, revs)
247 260
248 261 # Send patches one by one so we know their Differential Revision IDs and
249 262 # can provide dependency relationship
250 263 lastrevid = None
251 264 for rev in revs:
252 265 ui.debug('sending rev %d\n' % rev)
253 266 ctx = repo[rev]
254 267
255 268 # Get Differential Revision ID
256 269 oldnode, revid = getmapping(ctx)
257 270 if oldnode != ctx.node():
258 271 # Create or update Differential Revision
259 272 revision = createdifferentialrevision(ctx, revid, lastrevid)
260 273 newrevid = int(revision[r'object'][r'id'])
261 274 if revid:
262 275 action = _('updated')
263 276 else:
264 277 action = _('created')
265 278
266 279 # Create a local tag to note the association
267 280 tagname = 'D%d' % newrevid
268 281 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
269 282 date=None, local=True)
270 283 else:
271 284 # Nothing changed. But still set "newrevid" so the next revision
272 285 # could depend on this one.
273 286 newrevid = revid
274 287 action = _('skipped')
275 288
276 289 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
277 290 ctx.description().split('\n')[0]))
278 291 lastrevid = newrevid
279 292
280 293 _summaryre = re.compile('^Summary:\s*', re.M)
281 294
282 295 def readpatch(repo, params, recursive=False):
283 296 """generate plain-text patch readable by 'hg import'
284 297
285 298 params is passed to "differential.query". If recursive is True, also return
286 299 dependent patches.
287 300 """
288 301 # Differential Revisions
289 302 drevs = callconduit(repo, 'differential.query', params)
290 303 if len(drevs) == 1:
291 304 drev = drevs[0]
292 305 else:
293 306 raise error.Abort(_('cannot get Differential Revision %r') % params)
294 307
295 308 repo.ui.note(_('reading D%s\n') % drev[r'id'])
296 309
297 310 diffid = max(int(v) for v in drev[r'diffs'])
298 311 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
299 312 desc = callconduit(repo, 'differential.getcommitmessage',
300 313 {'revision_id': drev[r'id']})
301 314 header = '# HG changeset patch\n'
302 315
303 316 # Remove potential empty "Summary:"
304 317 desc = _summaryre.sub('', desc)
305 318
306 319 # Try to preserve metadata (user, date) from hg:meta property
307 320 diffs = callconduit(repo, 'differential.querydiffs', {'ids': [diffid]})
308 321 props = diffs[str(diffid)][r'properties'] # could be empty list or dict
309 322 if props and r'hg:meta' in props:
310 323 meta = props[r'hg:meta']
311 324 for k, v in meta.items():
312 325 header += '# %s %s\n' % (k.capitalize(), v)
313 326
314 327 patch = ('%s%s\n%s') % (header, desc, body)
315 328
316 329 # Check dependencies
317 330 if recursive:
318 331 auxiliary = drev.get(r'auxiliary', {})
319 332 depends = auxiliary.get(r'phabricator:depends-on', [])
320 333 for phid in depends:
321 334 patch = readpatch(repo, {'phids': [phid]}, recursive=True) + patch
322 335 return patch
323 336
324 337 @command('phabread',
325 338 [('', 'stack', False, _('read dependencies'))],
326 339 _('REVID [OPTIONS]'))
327 340 def phabread(ui, repo, revid, **opts):
328 341 """print patches from Phabricator suitable for importing
329 342
330 343 REVID could be a Differential Revision identity, like ``D123``, or just the
331 344 number ``123``, or a full URL like ``https://phab.example.com/D123``.
332 345
333 346 If --stack is given, follow dependencies information and read all patches.
334 347 """
335 348 try:
336 349 revid = int(revid.split('/')[-1].replace('D', ''))
337 350 except ValueError:
338 351 raise error.Abort(_('invalid Revision ID: %s') % revid)
339 352 patch = readpatch(repo, {'ids': [revid]}, recursive=opts.get('stack'))
340 353 ui.write(patch)
General Comments 0
You need to be logged in to leave comments. Login now