##// END OF EJS Templates
phabricator: allow specifying reviewers on phabsend...
Jun Wu -
r33498:b7a75b9a default
parent child Browse files
Show More
@@ -1,549 +1,576 b''
1 # phabricator.py - simple Phabricator integration
1 # phabricator.py - simple Phabricator integration
2 #
2 #
3 # Copyright 2017 Facebook, Inc.
3 # Copyright 2017 Facebook, Inc.
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7 """simple Phabricator integration
7 """simple Phabricator integration
8
8
9 This extension provides a ``phabsend`` command which sends a stack of
9 This extension provides a ``phabsend`` command which sends a stack of
10 changesets to Phabricator without amending commit messages, and a ``phabread``
10 changesets to Phabricator without amending commit messages, and a ``phabread``
11 command which prints a stack of revisions in a format suitable
11 command which prints a stack of revisions in a format suitable
12 for :hg:`import`.
12 for :hg:`import`.
13
13
14 By default, Phabricator requires ``Test Plan`` which might prevent some
14 By default, Phabricator requires ``Test Plan`` which might prevent some
15 changeset from being sent. The requirement could be disabled by changing
15 changeset from being sent. The requirement could be disabled by changing
16 ``differential.require-test-plan-field`` config server side.
16 ``differential.require-test-plan-field`` config server side.
17
17
18 Config::
18 Config::
19
19
20 [phabricator]
20 [phabricator]
21 # Phabricator URL
21 # Phabricator URL
22 url = https://phab.example.com/
22 url = https://phab.example.com/
23
23
24 # API token. Get it from https://$HOST/conduit/login/
24 # API token. Get it from https://$HOST/conduit/login/
25 token = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
25 token = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
26
26
27 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
27 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
28 # callsign is "FOO".
28 # callsign is "FOO".
29 callsign = FOO
29 callsign = FOO
30
30
31 """
31 """
32
32
33 from __future__ import absolute_import
33 from __future__ import absolute_import
34
34
35 import json
35 import json
36 import re
36 import re
37
37
38 from mercurial.node import bin, nullid
38 from mercurial.node import bin, nullid
39 from mercurial.i18n import _
39 from mercurial.i18n import _
40 from mercurial import (
40 from mercurial import (
41 encoding,
41 encoding,
42 error,
42 error,
43 mdiff,
43 mdiff,
44 obsolete,
44 obsolete,
45 patch,
45 patch,
46 registrar,
46 registrar,
47 scmutil,
47 scmutil,
48 tags,
48 tags,
49 url as urlmod,
49 url as urlmod,
50 util,
50 util,
51 )
51 )
52
52
53 cmdtable = {}
53 cmdtable = {}
54 command = registrar.command(cmdtable)
54 command = registrar.command(cmdtable)
55
55
56 def urlencodenested(params):
56 def urlencodenested(params):
57 """like urlencode, but works with nested parameters.
57 """like urlencode, but works with nested parameters.
58
58
59 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
59 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
60 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
60 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
61 urlencode. Note: the encoding is consistent with PHP's http_build_query.
61 urlencode. Note: the encoding is consistent with PHP's http_build_query.
62 """
62 """
63 flatparams = util.sortdict()
63 flatparams = util.sortdict()
64 def process(prefix, obj):
64 def process(prefix, obj):
65 items = {list: enumerate, dict: lambda x: x.items()}.get(type(obj))
65 items = {list: enumerate, dict: lambda x: x.items()}.get(type(obj))
66 if items is None:
66 if items is None:
67 flatparams[prefix] = obj
67 flatparams[prefix] = obj
68 else:
68 else:
69 for k, v in items(obj):
69 for k, v in items(obj):
70 if prefix:
70 if prefix:
71 process('%s[%s]' % (prefix, k), v)
71 process('%s[%s]' % (prefix, k), v)
72 else:
72 else:
73 process(k, v)
73 process(k, v)
74 process('', params)
74 process('', params)
75 return util.urlreq.urlencode(flatparams)
75 return util.urlreq.urlencode(flatparams)
76
76
77 def readurltoken(repo):
77 def readurltoken(repo):
78 """return conduit url, token and make sure they exist
78 """return conduit url, token and make sure they exist
79
79
80 Currently read from [phabricator] config section. In the future, it might
80 Currently read from [phabricator] config section. In the future, it might
81 make sense to read from .arcconfig and .arcrc as well.
81 make sense to read from .arcconfig and .arcrc as well.
82 """
82 """
83 values = []
83 values = []
84 section = 'phabricator'
84 section = 'phabricator'
85 for name in ['url', 'token']:
85 for name in ['url', 'token']:
86 value = repo.ui.config(section, name)
86 value = repo.ui.config(section, name)
87 if not value:
87 if not value:
88 raise error.Abort(_('config %s.%s is required') % (section, name))
88 raise error.Abort(_('config %s.%s is required') % (section, name))
89 values.append(value)
89 values.append(value)
90 return values
90 return values
91
91
92 def callconduit(repo, name, params):
92 def callconduit(repo, name, params):
93 """call Conduit API, params is a dict. return json.loads result, or None"""
93 """call Conduit API, params is a dict. return json.loads result, or None"""
94 host, token = readurltoken(repo)
94 host, token = readurltoken(repo)
95 url, authinfo = util.url('/'.join([host, 'api', name])).authinfo()
95 url, authinfo = util.url('/'.join([host, 'api', name])).authinfo()
96 urlopener = urlmod.opener(repo.ui, authinfo)
96 urlopener = urlmod.opener(repo.ui, authinfo)
97 repo.ui.debug('Conduit Call: %s %s\n' % (url, params))
97 repo.ui.debug('Conduit Call: %s %s\n' % (url, params))
98 params = params.copy()
98 params = params.copy()
99 params['api.token'] = token
99 params['api.token'] = token
100 request = util.urlreq.request(url, data=urlencodenested(params))
100 request = util.urlreq.request(url, data=urlencodenested(params))
101 body = urlopener.open(request).read()
101 body = urlopener.open(request).read()
102 repo.ui.debug('Conduit Response: %s\n' % body)
102 repo.ui.debug('Conduit Response: %s\n' % body)
103 parsed = json.loads(body)
103 parsed = json.loads(body)
104 if parsed.get(r'error_code'):
104 if parsed.get(r'error_code'):
105 msg = (_('Conduit Error (%s): %s')
105 msg = (_('Conduit Error (%s): %s')
106 % (parsed[r'error_code'], parsed[r'error_info']))
106 % (parsed[r'error_code'], parsed[r'error_info']))
107 raise error.Abort(msg)
107 raise error.Abort(msg)
108 return parsed[r'result']
108 return parsed[r'result']
109
109
110 @command('debugcallconduit', [], _('METHOD'))
110 @command('debugcallconduit', [], _('METHOD'))
111 def debugcallconduit(ui, repo, name):
111 def debugcallconduit(ui, repo, name):
112 """call Conduit API
112 """call Conduit API
113
113
114 Call parameters are read from stdin as a JSON blob. Result will be written
114 Call parameters are read from stdin as a JSON blob. Result will be written
115 to stdout as a JSON blob.
115 to stdout as a JSON blob.
116 """
116 """
117 params = json.loads(ui.fin.read())
117 params = json.loads(ui.fin.read())
118 result = callconduit(repo, name, params)
118 result = callconduit(repo, name, params)
119 s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': '))
119 s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': '))
120 ui.write('%s\n' % s)
120 ui.write('%s\n' % s)
121
121
122 def getrepophid(repo):
122 def getrepophid(repo):
123 """given callsign, return repository PHID or None"""
123 """given callsign, return repository PHID or None"""
124 # developer config: phabricator.repophid
124 # developer config: phabricator.repophid
125 repophid = repo.ui.config('phabricator', 'repophid')
125 repophid = repo.ui.config('phabricator', 'repophid')
126 if repophid:
126 if repophid:
127 return repophid
127 return repophid
128 callsign = repo.ui.config('phabricator', 'callsign')
128 callsign = repo.ui.config('phabricator', 'callsign')
129 if not callsign:
129 if not callsign:
130 return None
130 return None
131 query = callconduit(repo, 'diffusion.repository.search',
131 query = callconduit(repo, 'diffusion.repository.search',
132 {'constraints': {'callsigns': [callsign]}})
132 {'constraints': {'callsigns': [callsign]}})
133 if len(query[r'data']) == 0:
133 if len(query[r'data']) == 0:
134 return None
134 return None
135 repophid = encoding.strtolocal(query[r'data'][0][r'phid'])
135 repophid = encoding.strtolocal(query[r'data'][0][r'phid'])
136 repo.ui.setconfig('phabricator', 'repophid', repophid)
136 repo.ui.setconfig('phabricator', 'repophid', repophid)
137 return repophid
137 return repophid
138
138
139 _differentialrevisiontagre = re.compile('\AD([1-9][0-9]*)\Z')
139 _differentialrevisiontagre = re.compile('\AD([1-9][0-9]*)\Z')
140 _differentialrevisiondescre = re.compile(
140 _differentialrevisiondescre = re.compile(
141 '^Differential Revision:.*D([1-9][0-9]*)$', re.M)
141 '^Differential Revision:.*D([1-9][0-9]*)$', re.M)
142
142
143 def getoldnodedrevmap(repo, nodelist):
143 def getoldnodedrevmap(repo, nodelist):
144 """find previous nodes that has been sent to Phabricator
144 """find previous nodes that has been sent to Phabricator
145
145
146 return {node: (oldnode or None, Differential Revision ID)}
146 return {node: (oldnode or None, Differential Revision ID)}
147 for node in nodelist with known previous sent versions, or associated
147 for node in nodelist with known previous sent versions, or associated
148 Differential Revision IDs.
148 Differential Revision IDs.
149
149
150 Examines all precursors and their tags. Tags with format like "D1234" are
150 Examines all precursors and their tags. Tags with format like "D1234" are
151 considered a match and the node with that tag, and the number after "D"
151 considered a match and the node with that tag, and the number after "D"
152 (ex. 1234) will be returned.
152 (ex. 1234) will be returned.
153
153
154 If tags are not found, examine commit message. The "Differential Revision:"
154 If tags are not found, examine commit message. The "Differential Revision:"
155 line could associate this changeset to a Differential Revision.
155 line could associate this changeset to a Differential Revision.
156 """
156 """
157 url, token = readurltoken(repo)
157 url, token = readurltoken(repo)
158 unfi = repo.unfiltered()
158 unfi = repo.unfiltered()
159 nodemap = unfi.changelog.nodemap
159 nodemap = unfi.changelog.nodemap
160
160
161 result = {} # {node: (oldnode or None, drev)}
161 result = {} # {node: (oldnode or None, drev)}
162 toconfirm = {} # {node: (oldnode, {precnode}, drev)}
162 toconfirm = {} # {node: (oldnode, {precnode}, drev)}
163 for node in nodelist:
163 for node in nodelist:
164 ctx = unfi[node]
164 ctx = unfi[node]
165 # For tags like "D123", put them into "toconfirm" to verify later
165 # For tags like "D123", put them into "toconfirm" to verify later
166 precnodes = list(obsolete.allprecursors(unfi.obsstore, [node]))
166 precnodes = list(obsolete.allprecursors(unfi.obsstore, [node]))
167 for n in precnodes:
167 for n in precnodes:
168 if n in nodemap:
168 if n in nodemap:
169 for tag in unfi.nodetags(n):
169 for tag in unfi.nodetags(n):
170 m = _differentialrevisiontagre.match(tag)
170 m = _differentialrevisiontagre.match(tag)
171 if m:
171 if m:
172 toconfirm[node] = (n, set(precnodes), int(m.group(1)))
172 toconfirm[node] = (n, set(precnodes), int(m.group(1)))
173 continue
173 continue
174
174
175 # Check commit message
175 # Check commit message
176 m = _differentialrevisiondescre.search(ctx.description())
176 m = _differentialrevisiondescre.search(ctx.description())
177 if m:
177 if m:
178 result[node] = (None, int(m.group(1)))
178 result[node] = (None, int(m.group(1)))
179
179
180 # Double check if tags are genuine by collecting all old nodes from
180 # Double check if tags are genuine by collecting all old nodes from
181 # Phabricator, and expect precursors overlap with it.
181 # Phabricator, and expect precursors overlap with it.
182 if toconfirm:
182 if toconfirm:
183 confirmed = {} # {drev: {oldnode}}
183 confirmed = {} # {drev: {oldnode}}
184 drevs = [drev for n, precs, drev in toconfirm.values()]
184 drevs = [drev for n, precs, drev in toconfirm.values()]
185 diffs = callconduit(unfi, 'differential.querydiffs',
185 diffs = callconduit(unfi, 'differential.querydiffs',
186 {'revisionIDs': drevs})
186 {'revisionIDs': drevs})
187 for diff in diffs.values():
187 for diff in diffs.values():
188 drev = int(diff[r'revisionID'])
188 drev = int(diff[r'revisionID'])
189 oldnode = bin(encoding.unitolocal(getdiffmeta(diff).get(r'node')))
189 oldnode = bin(encoding.unitolocal(getdiffmeta(diff).get(r'node')))
190 if node:
190 if node:
191 confirmed.setdefault(drev, set()).add(oldnode)
191 confirmed.setdefault(drev, set()).add(oldnode)
192 for newnode, (oldnode, precset, drev) in toconfirm.items():
192 for newnode, (oldnode, precset, drev) in toconfirm.items():
193 if bool(precset & confirmed.get(drev, set())):
193 if bool(precset & confirmed.get(drev, set())):
194 result[newnode] = (oldnode, drev)
194 result[newnode] = (oldnode, drev)
195 else:
195 else:
196 tagname = 'D%d' % drev
196 tagname = 'D%d' % drev
197 tags.tag(repo, tagname, nullid, message=None, user=None,
197 tags.tag(repo, tagname, nullid, message=None, user=None,
198 date=None, local=True)
198 date=None, local=True)
199 unfi.ui.warn(_('D%s: local tag removed - does not match '
199 unfi.ui.warn(_('D%s: local tag removed - does not match '
200 'Differential history\n') % drev)
200 'Differential history\n') % drev)
201
201
202 return result
202 return result
203
203
204 def getdiff(ctx, diffopts):
204 def getdiff(ctx, diffopts):
205 """plain-text diff without header (user, commit message, etc)"""
205 """plain-text diff without header (user, commit message, etc)"""
206 output = util.stringio()
206 output = util.stringio()
207 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
207 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
208 None, opts=diffopts):
208 None, opts=diffopts):
209 output.write(chunk)
209 output.write(chunk)
210 return output.getvalue()
210 return output.getvalue()
211
211
212 def creatediff(ctx):
212 def creatediff(ctx):
213 """create a Differential Diff"""
213 """create a Differential Diff"""
214 repo = ctx.repo()
214 repo = ctx.repo()
215 repophid = getrepophid(repo)
215 repophid = getrepophid(repo)
216 # Create a "Differential Diff" via "differential.createrawdiff" API
216 # Create a "Differential Diff" via "differential.createrawdiff" API
217 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
217 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
218 if repophid:
218 if repophid:
219 params['repositoryPHID'] = repophid
219 params['repositoryPHID'] = repophid
220 diff = callconduit(repo, 'differential.createrawdiff', params)
220 diff = callconduit(repo, 'differential.createrawdiff', params)
221 if not diff:
221 if not diff:
222 raise error.Abort(_('cannot create diff for %s') % ctx)
222 raise error.Abort(_('cannot create diff for %s') % ctx)
223 return diff
223 return diff
224
224
225 def writediffproperties(ctx, diff):
225 def writediffproperties(ctx, diff):
226 """write metadata to diff so patches could be applied losslessly"""
226 """write metadata to diff so patches could be applied losslessly"""
227 params = {
227 params = {
228 'diff_id': diff[r'id'],
228 'diff_id': diff[r'id'],
229 'name': 'hg:meta',
229 'name': 'hg:meta',
230 'data': json.dumps({
230 'data': json.dumps({
231 'user': ctx.user(),
231 'user': ctx.user(),
232 'date': '%d %d' % ctx.date(),
232 'date': '%d %d' % ctx.date(),
233 'node': ctx.hex(),
233 'node': ctx.hex(),
234 'parent': ctx.p1().hex(),
234 'parent': ctx.p1().hex(),
235 }),
235 }),
236 }
236 }
237 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
237 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
238
238
239 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None):
239 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
240 actions=None):
240 """create or update a Differential Revision
241 """create or update a Differential Revision
241
242
242 If revid is None, create a new Differential Revision, otherwise update
243 If revid is None, create a new Differential Revision, otherwise update
243 revid. If parentrevid is not None, set it as a dependency.
244 revid. If parentrevid is not None, set it as a dependency.
244
245
245 If oldnode is not None, check if the patch content (without commit message
246 If oldnode is not None, check if the patch content (without commit message
246 and metadata) has changed before creating another diff.
247 and metadata) has changed before creating another diff.
248
249 If actions is not None, they will be appended to the transaction.
247 """
250 """
248 repo = ctx.repo()
251 repo = ctx.repo()
249 if oldnode:
252 if oldnode:
250 diffopts = mdiff.diffopts(git=True, context=1)
253 diffopts = mdiff.diffopts(git=True, context=1)
251 oldctx = repo.unfiltered()[oldnode]
254 oldctx = repo.unfiltered()[oldnode]
252 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
255 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
253 else:
256 else:
254 neednewdiff = True
257 neednewdiff = True
255
258
256 transactions = []
259 transactions = []
257 if neednewdiff:
260 if neednewdiff:
258 diff = creatediff(ctx)
261 diff = creatediff(ctx)
259 writediffproperties(ctx, diff)
262 writediffproperties(ctx, diff)
260 transactions.append({'type': 'update', 'value': diff[r'phid']})
263 transactions.append({'type': 'update', 'value': diff[r'phid']})
261
264
262 # Use a temporary summary to set dependency. There might be better ways but
265 # Use a temporary summary to set dependency. There might be better ways but
263 # I cannot find them for now. But do not do that if we are updating an
266 # I cannot find them for now. But do not do that if we are updating an
264 # existing revision (revid is not None) since that introduces visible
267 # existing revision (revid is not None) since that introduces visible
265 # churns (someone edited "Summary" twice) on the web page.
268 # churns (someone edited "Summary" twice) on the web page.
266 if parentrevid and revid is None:
269 if parentrevid and revid is None:
267 summary = 'Depends on D%s' % parentrevid
270 summary = 'Depends on D%s' % parentrevid
268 transactions += [{'type': 'summary', 'value': summary},
271 transactions += [{'type': 'summary', 'value': summary},
269 {'type': 'summary', 'value': ' '}]
272 {'type': 'summary', 'value': ' '}]
270
273
274 if actions:
275 transactions += actions
276
271 # Parse commit message and update related fields.
277 # Parse commit message and update related fields.
272 desc = ctx.description()
278 desc = ctx.description()
273 info = callconduit(repo, 'differential.parsecommitmessage',
279 info = callconduit(repo, 'differential.parsecommitmessage',
274 {'corpus': desc})
280 {'corpus': desc})
275 for k, v in info[r'fields'].items():
281 for k, v in info[r'fields'].items():
276 if k in ['title', 'summary', 'testPlan']:
282 if k in ['title', 'summary', 'testPlan']:
277 transactions.append({'type': k, 'value': v})
283 transactions.append({'type': k, 'value': v})
278
284
279 params = {'transactions': transactions}
285 params = {'transactions': transactions}
280 if revid is not None:
286 if revid is not None:
281 # Update an existing Differential Revision
287 # Update an existing Differential Revision
282 params['objectIdentifier'] = revid
288 params['objectIdentifier'] = revid
283
289
284 revision = callconduit(repo, 'differential.revision.edit', params)
290 revision = callconduit(repo, 'differential.revision.edit', params)
285 if not revision:
291 if not revision:
286 raise error.Abort(_('cannot create revision for %s') % ctx)
292 raise error.Abort(_('cannot create revision for %s') % ctx)
287
293
288 return revision
294 return revision
289
295
296 def userphids(repo, names):
297 """convert user names to PHIDs"""
298 query = {'constraints': {'usernames': names}}
299 result = callconduit(repo, 'user.search', query)
300 # username not found is not an error of the API. So check if we have missed
301 # some names here.
302 data = result[r'data']
303 resolved = set(entry[r'fields'][r'username'] for entry in data)
304 unresolved = set(names) - resolved
305 if unresolved:
306 raise error.Abort(_('unknown username: %s')
307 % ' '.join(sorted(unresolved)))
308 return [entry[r'phid'] for entry in data]
309
290 @command('phabsend',
310 @command('phabsend',
291 [('r', 'rev', [], _('revisions to send'), _('REV'))],
311 [('r', 'rev', [], _('revisions to send'), _('REV')),
312 ('', 'reviewer', [], _('specify reviewers'))],
292 _('REV [OPTIONS]'))
313 _('REV [OPTIONS]'))
293 def phabsend(ui, repo, *revs, **opts):
314 def phabsend(ui, repo, *revs, **opts):
294 """upload changesets to Phabricator
315 """upload changesets to Phabricator
295
316
296 If there are multiple revisions specified, they will be send as a stack
317 If there are multiple revisions specified, they will be send as a stack
297 with a linear dependencies relationship using the order specified by the
318 with a linear dependencies relationship using the order specified by the
298 revset.
319 revset.
299
320
300 For the first time uploading changesets, local tags will be created to
321 For the first time uploading changesets, local tags will be created to
301 maintain the association. After the first time, phabsend will check
322 maintain the association. After the first time, phabsend will check
302 obsstore and tags information so it can figure out whether to update an
323 obsstore and tags information so it can figure out whether to update an
303 existing Differential Revision, or create a new one.
324 existing Differential Revision, or create a new one.
304 """
325 """
305 revs = list(revs) + opts.get('rev', [])
326 revs = list(revs) + opts.get('rev', [])
306 revs = scmutil.revrange(repo, revs)
327 revs = scmutil.revrange(repo, revs)
307
328
308 if not revs:
329 if not revs:
309 raise error.Abort(_('phabsend requires at least one changeset'))
330 raise error.Abort(_('phabsend requires at least one changeset'))
310
331
332 actions = []
333 reviewers = opts.get('reviewer', [])
334 if reviewers:
335 phids = userphids(repo, reviewers)
336 actions.append({'type': 'reviewers.add', 'value': phids})
337
311 oldnodedrev = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
338 oldnodedrev = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
312
339
313 # Send patches one by one so we know their Differential Revision IDs and
340 # Send patches one by one so we know their Differential Revision IDs and
314 # can provide dependency relationship
341 # can provide dependency relationship
315 lastrevid = None
342 lastrevid = None
316 for rev in revs:
343 for rev in revs:
317 ui.debug('sending rev %d\n' % rev)
344 ui.debug('sending rev %d\n' % rev)
318 ctx = repo[rev]
345 ctx = repo[rev]
319
346
320 # Get Differential Revision ID
347 # Get Differential Revision ID
321 oldnode, revid = oldnodedrev.get(ctx.node(), (None, None))
348 oldnode, revid = oldnodedrev.get(ctx.node(), (None, None))
322 if oldnode != ctx.node():
349 if oldnode != ctx.node():
323 # Create or update Differential Revision
350 # Create or update Differential Revision
324 revision = createdifferentialrevision(ctx, revid, lastrevid,
351 revision = createdifferentialrevision(ctx, revid, lastrevid,
325 oldnode)
352 oldnode, actions)
326 newrevid = int(revision[r'object'][r'id'])
353 newrevid = int(revision[r'object'][r'id'])
327 if revid:
354 if revid:
328 action = _('updated')
355 action = _('updated')
329 else:
356 else:
330 action = _('created')
357 action = _('created')
331
358
332 # Create a local tag to note the association
359 # Create a local tag to note the association
333 tagname = 'D%d' % newrevid
360 tagname = 'D%d' % newrevid
334 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
361 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
335 date=None, local=True)
362 date=None, local=True)
336 else:
363 else:
337 # Nothing changed. But still set "newrevid" so the next revision
364 # Nothing changed. But still set "newrevid" so the next revision
338 # could depend on this one.
365 # could depend on this one.
339 newrevid = revid
366 newrevid = revid
340 action = _('skipped')
367 action = _('skipped')
341
368
342 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
369 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
343 ctx.description().split('\n')[0]))
370 ctx.description().split('\n')[0]))
344 lastrevid = newrevid
371 lastrevid = newrevid
345
372
346 # Map from "hg:meta" keys to header understood by "hg import". The order is
373 # Map from "hg:meta" keys to header understood by "hg import". The order is
347 # consistent with "hg export" output.
374 # consistent with "hg export" output.
348 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
375 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
349 (r'node', 'Node ID'), (r'parent', 'Parent ')])
376 (r'node', 'Node ID'), (r'parent', 'Parent ')])
350
377
351 def querydrev(repo, params, stack=False):
378 def querydrev(repo, params, stack=False):
352 """return a list of "Differential Revision" dicts
379 """return a list of "Differential Revision" dicts
353
380
354 params is the input of "differential.query" API, and is expected to match
381 params is the input of "differential.query" API, and is expected to match
355 just a single Differential Revision.
382 just a single Differential Revision.
356
383
357 A "Differential Revision dict" looks like:
384 A "Differential Revision dict" looks like:
358
385
359 {
386 {
360 "id": "2",
387 "id": "2",
361 "phid": "PHID-DREV-672qvysjcczopag46qty",
388 "phid": "PHID-DREV-672qvysjcczopag46qty",
362 "title": "example",
389 "title": "example",
363 "uri": "https://phab.example.com/D2",
390 "uri": "https://phab.example.com/D2",
364 "dateCreated": "1499181406",
391 "dateCreated": "1499181406",
365 "dateModified": "1499182103",
392 "dateModified": "1499182103",
366 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
393 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
367 "status": "0",
394 "status": "0",
368 "statusName": "Needs Review",
395 "statusName": "Needs Review",
369 "properties": [],
396 "properties": [],
370 "branch": null,
397 "branch": null,
371 "summary": "",
398 "summary": "",
372 "testPlan": "",
399 "testPlan": "",
373 "lineCount": "2",
400 "lineCount": "2",
374 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
401 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
375 "diffs": [
402 "diffs": [
376 "3",
403 "3",
377 "4",
404 "4",
378 ],
405 ],
379 "commits": [],
406 "commits": [],
380 "reviewers": [],
407 "reviewers": [],
381 "ccs": [],
408 "ccs": [],
382 "hashes": [],
409 "hashes": [],
383 "auxiliary": {
410 "auxiliary": {
384 "phabricator:projects": [],
411 "phabricator:projects": [],
385 "phabricator:depends-on": [
412 "phabricator:depends-on": [
386 "PHID-DREV-gbapp366kutjebt7agcd"
413 "PHID-DREV-gbapp366kutjebt7agcd"
387 ]
414 ]
388 },
415 },
389 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
416 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
390 "sourcePath": null
417 "sourcePath": null
391 }
418 }
392
419
393 If stack is True, return a list of "Differential Revision dict"s in an
420 If stack is True, return a list of "Differential Revision dict"s in an
394 order that the latter ones depend on the former ones. Otherwise, return a
421 order that the latter ones depend on the former ones. Otherwise, return a
395 list of a unique "Differential Revision dict".
422 list of a unique "Differential Revision dict".
396 """
423 """
397 prefetched = {} # {id or phid: drev}
424 prefetched = {} # {id or phid: drev}
398 def fetch(params):
425 def fetch(params):
399 """params -> single drev or None"""
426 """params -> single drev or None"""
400 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
427 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
401 if key in prefetched:
428 if key in prefetched:
402 return prefetched[key]
429 return prefetched[key]
403 # Otherwise, send the request. If we're fetching a stack, be smarter
430 # Otherwise, send the request. If we're fetching a stack, be smarter
404 # and fetch more ids in one batch, even if it could be unnecessary.
431 # and fetch more ids in one batch, even if it could be unnecessary.
405 batchparams = params
432 batchparams = params
406 if stack and len(params.get(r'ids', [])) == 1:
433 if stack and len(params.get(r'ids', [])) == 1:
407 i = int(params[r'ids'][0])
434 i = int(params[r'ids'][0])
408 # developer config: phabricator.batchsize
435 # developer config: phabricator.batchsize
409 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
436 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
410 batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
437 batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
411 drevs = callconduit(repo, 'differential.query', batchparams)
438 drevs = callconduit(repo, 'differential.query', batchparams)
412 # Fill prefetched with the result
439 # Fill prefetched with the result
413 for drev in drevs:
440 for drev in drevs:
414 prefetched[drev[r'phid']] = drev
441 prefetched[drev[r'phid']] = drev
415 prefetched[int(drev[r'id'])] = drev
442 prefetched[int(drev[r'id'])] = drev
416 if key not in prefetched:
443 if key not in prefetched:
417 raise error.Abort(_('cannot get Differential Revision %r') % params)
444 raise error.Abort(_('cannot get Differential Revision %r') % params)
418 return prefetched[key]
445 return prefetched[key]
419
446
420 visited = set()
447 visited = set()
421 result = []
448 result = []
422 queue = [params]
449 queue = [params]
423 while queue:
450 while queue:
424 params = queue.pop()
451 params = queue.pop()
425 drev = fetch(params)
452 drev = fetch(params)
426 if drev[r'id'] in visited:
453 if drev[r'id'] in visited:
427 continue
454 continue
428 visited.add(drev[r'id'])
455 visited.add(drev[r'id'])
429 result.append(drev)
456 result.append(drev)
430 if stack:
457 if stack:
431 auxiliary = drev.get(r'auxiliary', {})
458 auxiliary = drev.get(r'auxiliary', {})
432 depends = auxiliary.get(r'phabricator:depends-on', [])
459 depends = auxiliary.get(r'phabricator:depends-on', [])
433 for phid in depends:
460 for phid in depends:
434 queue.append({'phids': [phid]})
461 queue.append({'phids': [phid]})
435 result.reverse()
462 result.reverse()
436 return result
463 return result
437
464
438 def getdescfromdrev(drev):
465 def getdescfromdrev(drev):
439 """get description (commit message) from "Differential Revision"
466 """get description (commit message) from "Differential Revision"
440
467
441 This is similar to differential.getcommitmessage API. But we only care
468 This is similar to differential.getcommitmessage API. But we only care
442 about limited fields: title, summary, test plan, and URL.
469 about limited fields: title, summary, test plan, and URL.
443 """
470 """
444 title = drev[r'title']
471 title = drev[r'title']
445 summary = drev[r'summary'].rstrip()
472 summary = drev[r'summary'].rstrip()
446 testplan = drev[r'testPlan'].rstrip()
473 testplan = drev[r'testPlan'].rstrip()
447 if testplan:
474 if testplan:
448 testplan = 'Test Plan:\n%s' % testplan
475 testplan = 'Test Plan:\n%s' % testplan
449 uri = 'Differential Revision: %s' % drev[r'uri']
476 uri = 'Differential Revision: %s' % drev[r'uri']
450 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
477 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
451
478
452 def getdiffmeta(diff):
479 def getdiffmeta(diff):
453 """get commit metadata (date, node, user, p1) from a diff object
480 """get commit metadata (date, node, user, p1) from a diff object
454
481
455 The metadata could be "hg:meta", sent by phabsend, like:
482 The metadata could be "hg:meta", sent by phabsend, like:
456
483
457 "properties": {
484 "properties": {
458 "hg:meta": {
485 "hg:meta": {
459 "date": "1499571514 25200",
486 "date": "1499571514 25200",
460 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
487 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
461 "user": "Foo Bar <foo@example.com>",
488 "user": "Foo Bar <foo@example.com>",
462 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
489 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
463 }
490 }
464 }
491 }
465
492
466 Or converted from "local:commits", sent by "arc", like:
493 Or converted from "local:commits", sent by "arc", like:
467
494
468 "properties": {
495 "properties": {
469 "local:commits": {
496 "local:commits": {
470 "98c08acae292b2faf60a279b4189beb6cff1414d": {
497 "98c08acae292b2faf60a279b4189beb6cff1414d": {
471 "author": "Foo Bar",
498 "author": "Foo Bar",
472 "time": 1499546314,
499 "time": 1499546314,
473 "branch": "default",
500 "branch": "default",
474 "tag": "",
501 "tag": "",
475 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
502 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
476 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
503 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
477 "local": "1000",
504 "local": "1000",
478 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
505 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
479 "summary": "...",
506 "summary": "...",
480 "message": "...",
507 "message": "...",
481 "authorEmail": "foo@example.com"
508 "authorEmail": "foo@example.com"
482 }
509 }
483 }
510 }
484 }
511 }
485
512
486 Note: metadata extracted from "local:commits" will lose time zone
513 Note: metadata extracted from "local:commits" will lose time zone
487 information.
514 information.
488 """
515 """
489 props = diff.get(r'properties') or {}
516 props = diff.get(r'properties') or {}
490 meta = props.get(r'hg:meta')
517 meta = props.get(r'hg:meta')
491 if not meta and props.get(r'local:commits'):
518 if not meta and props.get(r'local:commits'):
492 commit = sorted(props[r'local:commits'].values())[0]
519 commit = sorted(props[r'local:commits'].values())[0]
493 meta = {
520 meta = {
494 r'date': r'%d 0' % commit[r'time'],
521 r'date': r'%d 0' % commit[r'time'],
495 r'node': commit[r'rev'],
522 r'node': commit[r'rev'],
496 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
523 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
497 }
524 }
498 if len(commit.get(r'parents', ())) >= 1:
525 if len(commit.get(r'parents', ())) >= 1:
499 meta[r'parent'] = commit[r'parents'][0]
526 meta[r'parent'] = commit[r'parents'][0]
500 return meta or {}
527 return meta or {}
501
528
502 def readpatch(repo, params, write, stack=False):
529 def readpatch(repo, params, write, stack=False):
503 """generate plain-text patch readable by 'hg import'
530 """generate plain-text patch readable by 'hg import'
504
531
505 write is usually ui.write. params is passed to "differential.query". If
532 write is usually ui.write. params is passed to "differential.query". If
506 stack is True, also write dependent patches.
533 stack is True, also write dependent patches.
507 """
534 """
508 # Differential Revisions
535 # Differential Revisions
509 drevs = querydrev(repo, params, stack)
536 drevs = querydrev(repo, params, stack)
510
537
511 # Prefetch hg:meta property for all diffs
538 # Prefetch hg:meta property for all diffs
512 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
539 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
513 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
540 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
514
541
515 # Generate patch for each drev
542 # Generate patch for each drev
516 for drev in drevs:
543 for drev in drevs:
517 repo.ui.note(_('reading D%s\n') % drev[r'id'])
544 repo.ui.note(_('reading D%s\n') % drev[r'id'])
518
545
519 diffid = max(int(v) for v in drev[r'diffs'])
546 diffid = max(int(v) for v in drev[r'diffs'])
520 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
547 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
521 desc = getdescfromdrev(drev)
548 desc = getdescfromdrev(drev)
522 header = '# HG changeset patch\n'
549 header = '# HG changeset patch\n'
523
550
524 # Try to preserve metadata from hg:meta property. Write hg patch
551 # Try to preserve metadata from hg:meta property. Write hg patch
525 # headers that can be read by the "import" command. See patchheadermap
552 # headers that can be read by the "import" command. See patchheadermap
526 # and extract in mercurial/patch.py for supported headers.
553 # and extract in mercurial/patch.py for supported headers.
527 meta = getdiffmeta(diffs[str(diffid)])
554 meta = getdiffmeta(diffs[str(diffid)])
528 for k in _metanamemap.keys():
555 for k in _metanamemap.keys():
529 if k in meta:
556 if k in meta:
530 header += '# %s %s\n' % (_metanamemap[k], meta[k])
557 header += '# %s %s\n' % (_metanamemap[k], meta[k])
531
558
532 write(('%s%s\n%s') % (header, desc, body))
559 write(('%s%s\n%s') % (header, desc, body))
533
560
534 @command('phabread',
561 @command('phabread',
535 [('', 'stack', False, _('read dependencies'))],
562 [('', 'stack', False, _('read dependencies'))],
536 _('REVID [OPTIONS]'))
563 _('REVID [OPTIONS]'))
537 def phabread(ui, repo, revid, **opts):
564 def phabread(ui, repo, revid, **opts):
538 """print patches from Phabricator suitable for importing
565 """print patches from Phabricator suitable for importing
539
566
540 REVID could be a Differential Revision identity, like ``D123``, or just the
567 REVID could be a Differential Revision identity, like ``D123``, or just the
541 number ``123``, or a full URL like ``https://phab.example.com/D123``.
568 number ``123``, or a full URL like ``https://phab.example.com/D123``.
542
569
543 If --stack is given, follow dependencies information and read all patches.
570 If --stack is given, follow dependencies information and read all patches.
544 """
571 """
545 try:
572 try:
546 revid = int(revid.split('/')[-1].replace('D', ''))
573 revid = int(revid.split('/')[-1].replace('D', ''))
547 except ValueError:
574 except ValueError:
548 raise error.Abort(_('invalid Revision ID: %s') % revid)
575 raise error.Abort(_('invalid Revision ID: %s') % revid)
549 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
576 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
General Comments 0
You need to be logged in to leave comments. Login now