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