##// END OF EJS Templates
phabricator: convert unicode to binary when writing patches...
Jun Wu -
r33602:850d2ec2 stable
parent child Browse files
Show More
@@ -1,580 +1,581 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:\s*(.*)D([1-9][0-9]*)$', re.M)
141 '^Differential Revision:\s*(.*)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 (make sure URL matches)
175 # Check commit message (make sure URL matches)
176 m = _differentialrevisiondescre.search(ctx.description())
176 m = _differentialrevisiondescre.search(ctx.description())
177 if m:
177 if m:
178 if m.group(1).rstrip('/') == url.rstrip('/'):
178 if m.group(1).rstrip('/') == url.rstrip('/'):
179 result[node] = (None, int(m.group(2)))
179 result[node] = (None, int(m.group(2)))
180 else:
180 else:
181 unfi.ui.warn(_('%s: Differential Revision URL ignored - host '
181 unfi.ui.warn(_('%s: Differential Revision URL ignored - host '
182 'does not match config\n') % ctx)
182 'does not match config\n') % ctx)
183
183
184 # Double check if tags are genuine by collecting all old nodes from
184 # Double check if tags are genuine by collecting all old nodes from
185 # Phabricator, and expect precursors overlap with it.
185 # Phabricator, and expect precursors overlap with it.
186 if toconfirm:
186 if toconfirm:
187 confirmed = {} # {drev: {oldnode}}
187 confirmed = {} # {drev: {oldnode}}
188 drevs = [drev for n, precs, drev in toconfirm.values()]
188 drevs = [drev for n, precs, drev in toconfirm.values()]
189 diffs = callconduit(unfi, 'differential.querydiffs',
189 diffs = callconduit(unfi, 'differential.querydiffs',
190 {'revisionIDs': drevs})
190 {'revisionIDs': drevs})
191 for diff in diffs.values():
191 for diff in diffs.values():
192 drev = int(diff[r'revisionID'])
192 drev = int(diff[r'revisionID'])
193 oldnode = bin(encoding.unitolocal(getdiffmeta(diff).get(r'node')))
193 oldnode = bin(encoding.unitolocal(getdiffmeta(diff).get(r'node')))
194 if node:
194 if node:
195 confirmed.setdefault(drev, set()).add(oldnode)
195 confirmed.setdefault(drev, set()).add(oldnode)
196 for newnode, (oldnode, precset, drev) in toconfirm.items():
196 for newnode, (oldnode, precset, drev) in toconfirm.items():
197 if bool(precset & confirmed.get(drev, set())):
197 if bool(precset & confirmed.get(drev, set())):
198 result[newnode] = (oldnode, drev)
198 result[newnode] = (oldnode, drev)
199 else:
199 else:
200 tagname = 'D%d' % drev
200 tagname = 'D%d' % drev
201 tags.tag(repo, tagname, nullid, message=None, user=None,
201 tags.tag(repo, tagname, nullid, message=None, user=None,
202 date=None, local=True)
202 date=None, local=True)
203 unfi.ui.warn(_('D%s: local tag removed - does not match '
203 unfi.ui.warn(_('D%s: local tag removed - does not match '
204 'Differential history\n') % drev)
204 'Differential history\n') % drev)
205
205
206 return result
206 return result
207
207
208 def getdiff(ctx, diffopts):
208 def getdiff(ctx, diffopts):
209 """plain-text diff without header (user, commit message, etc)"""
209 """plain-text diff without header (user, commit message, etc)"""
210 output = util.stringio()
210 output = util.stringio()
211 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
211 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
212 None, opts=diffopts):
212 None, opts=diffopts):
213 output.write(chunk)
213 output.write(chunk)
214 return output.getvalue()
214 return output.getvalue()
215
215
216 def creatediff(ctx):
216 def creatediff(ctx):
217 """create a Differential Diff"""
217 """create a Differential Diff"""
218 repo = ctx.repo()
218 repo = ctx.repo()
219 repophid = getrepophid(repo)
219 repophid = getrepophid(repo)
220 # Create a "Differential Diff" via "differential.createrawdiff" API
220 # Create a "Differential Diff" via "differential.createrawdiff" API
221 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
221 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
222 if repophid:
222 if repophid:
223 params['repositoryPHID'] = repophid
223 params['repositoryPHID'] = repophid
224 diff = callconduit(repo, 'differential.createrawdiff', params)
224 diff = callconduit(repo, 'differential.createrawdiff', params)
225 if not diff:
225 if not diff:
226 raise error.Abort(_('cannot create diff for %s') % ctx)
226 raise error.Abort(_('cannot create diff for %s') % ctx)
227 return diff
227 return diff
228
228
229 def writediffproperties(ctx, diff):
229 def writediffproperties(ctx, diff):
230 """write metadata to diff so patches could be applied losslessly"""
230 """write metadata to diff so patches could be applied losslessly"""
231 params = {
231 params = {
232 'diff_id': diff[r'id'],
232 'diff_id': diff[r'id'],
233 'name': 'hg:meta',
233 'name': 'hg:meta',
234 'data': json.dumps({
234 'data': json.dumps({
235 'user': ctx.user(),
235 'user': ctx.user(),
236 'date': '%d %d' % ctx.date(),
236 'date': '%d %d' % ctx.date(),
237 'node': ctx.hex(),
237 'node': ctx.hex(),
238 'parent': ctx.p1().hex(),
238 'parent': ctx.p1().hex(),
239 }),
239 }),
240 }
240 }
241 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
241 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
242
242
243 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
243 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
244 actions=None):
244 actions=None):
245 """create or update a Differential Revision
245 """create or update a Differential Revision
246
246
247 If revid is None, create a new Differential Revision, otherwise update
247 If revid is None, create a new Differential Revision, otherwise update
248 revid. If parentrevid is not None, set it as a dependency.
248 revid. If parentrevid is not None, set it as a dependency.
249
249
250 If oldnode is not None, check if the patch content (without commit message
250 If oldnode is not None, check if the patch content (without commit message
251 and metadata) has changed before creating another diff.
251 and metadata) has changed before creating another diff.
252
252
253 If actions is not None, they will be appended to the transaction.
253 If actions is not None, they will be appended to the transaction.
254 """
254 """
255 repo = ctx.repo()
255 repo = ctx.repo()
256 if oldnode:
256 if oldnode:
257 diffopts = mdiff.diffopts(git=True, context=1)
257 diffopts = mdiff.diffopts(git=True, context=1)
258 oldctx = repo.unfiltered()[oldnode]
258 oldctx = repo.unfiltered()[oldnode]
259 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
259 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
260 else:
260 else:
261 neednewdiff = True
261 neednewdiff = True
262
262
263 transactions = []
263 transactions = []
264 if neednewdiff:
264 if neednewdiff:
265 diff = creatediff(ctx)
265 diff = creatediff(ctx)
266 writediffproperties(ctx, diff)
266 writediffproperties(ctx, diff)
267 transactions.append({'type': 'update', 'value': diff[r'phid']})
267 transactions.append({'type': 'update', 'value': diff[r'phid']})
268
268
269 # Use a temporary summary to set dependency. There might be better ways but
269 # Use a temporary summary to set dependency. There might be better ways but
270 # I cannot find them for now. But do not do that if we are updating an
270 # I cannot find them for now. But do not do that if we are updating an
271 # existing revision (revid is not None) since that introduces visible
271 # existing revision (revid is not None) since that introduces visible
272 # churns (someone edited "Summary" twice) on the web page.
272 # churns (someone edited "Summary" twice) on the web page.
273 if parentrevid and revid is None:
273 if parentrevid and revid is None:
274 summary = 'Depends on D%s' % parentrevid
274 summary = 'Depends on D%s' % parentrevid
275 transactions += [{'type': 'summary', 'value': summary},
275 transactions += [{'type': 'summary', 'value': summary},
276 {'type': 'summary', 'value': ' '}]
276 {'type': 'summary', 'value': ' '}]
277
277
278 if actions:
278 if actions:
279 transactions += actions
279 transactions += actions
280
280
281 # Parse commit message and update related fields.
281 # Parse commit message and update related fields.
282 desc = ctx.description()
282 desc = ctx.description()
283 info = callconduit(repo, 'differential.parsecommitmessage',
283 info = callconduit(repo, 'differential.parsecommitmessage',
284 {'corpus': desc})
284 {'corpus': desc})
285 for k, v in info[r'fields'].items():
285 for k, v in info[r'fields'].items():
286 if k in ['title', 'summary', 'testPlan']:
286 if k in ['title', 'summary', 'testPlan']:
287 transactions.append({'type': k, 'value': v})
287 transactions.append({'type': k, 'value': v})
288
288
289 params = {'transactions': transactions}
289 params = {'transactions': transactions}
290 if revid is not None:
290 if revid is not None:
291 # Update an existing Differential Revision
291 # Update an existing Differential Revision
292 params['objectIdentifier'] = revid
292 params['objectIdentifier'] = revid
293
293
294 revision = callconduit(repo, 'differential.revision.edit', params)
294 revision = callconduit(repo, 'differential.revision.edit', params)
295 if not revision:
295 if not revision:
296 raise error.Abort(_('cannot create revision for %s') % ctx)
296 raise error.Abort(_('cannot create revision for %s') % ctx)
297
297
298 return revision
298 return revision
299
299
300 def userphids(repo, names):
300 def userphids(repo, names):
301 """convert user names to PHIDs"""
301 """convert user names to PHIDs"""
302 query = {'constraints': {'usernames': names}}
302 query = {'constraints': {'usernames': names}}
303 result = callconduit(repo, 'user.search', query)
303 result = callconduit(repo, 'user.search', query)
304 # username not found is not an error of the API. So check if we have missed
304 # username not found is not an error of the API. So check if we have missed
305 # some names here.
305 # some names here.
306 data = result[r'data']
306 data = result[r'data']
307 resolved = set(entry[r'fields'][r'username'] for entry in data)
307 resolved = set(entry[r'fields'][r'username'] for entry in data)
308 unresolved = set(names) - resolved
308 unresolved = set(names) - resolved
309 if unresolved:
309 if unresolved:
310 raise error.Abort(_('unknown username: %s')
310 raise error.Abort(_('unknown username: %s')
311 % ' '.join(sorted(unresolved)))
311 % ' '.join(sorted(unresolved)))
312 return [entry[r'phid'] for entry in data]
312 return [entry[r'phid'] for entry in data]
313
313
314 @command('phabsend',
314 @command('phabsend',
315 [('r', 'rev', [], _('revisions to send'), _('REV')),
315 [('r', 'rev', [], _('revisions to send'), _('REV')),
316 ('', 'reviewer', [], _('specify reviewers'))],
316 ('', 'reviewer', [], _('specify reviewers'))],
317 _('REV [OPTIONS]'))
317 _('REV [OPTIONS]'))
318 def phabsend(ui, repo, *revs, **opts):
318 def phabsend(ui, repo, *revs, **opts):
319 """upload changesets to Phabricator
319 """upload changesets to Phabricator
320
320
321 If there are multiple revisions specified, they will be send as a stack
321 If there are multiple revisions specified, they will be send as a stack
322 with a linear dependencies relationship using the order specified by the
322 with a linear dependencies relationship using the order specified by the
323 revset.
323 revset.
324
324
325 For the first time uploading changesets, local tags will be created to
325 For the first time uploading changesets, local tags will be created to
326 maintain the association. After the first time, phabsend will check
326 maintain the association. After the first time, phabsend will check
327 obsstore and tags information so it can figure out whether to update an
327 obsstore and tags information so it can figure out whether to update an
328 existing Differential Revision, or create a new one.
328 existing Differential Revision, or create a new one.
329 """
329 """
330 revs = list(revs) + opts.get('rev', [])
330 revs = list(revs) + opts.get('rev', [])
331 revs = scmutil.revrange(repo, revs)
331 revs = scmutil.revrange(repo, revs)
332
332
333 if not revs:
333 if not revs:
334 raise error.Abort(_('phabsend requires at least one changeset'))
334 raise error.Abort(_('phabsend requires at least one changeset'))
335
335
336 actions = []
336 actions = []
337 reviewers = opts.get('reviewer', [])
337 reviewers = opts.get('reviewer', [])
338 if reviewers:
338 if reviewers:
339 phids = userphids(repo, reviewers)
339 phids = userphids(repo, reviewers)
340 actions.append({'type': 'reviewers.add', 'value': phids})
340 actions.append({'type': 'reviewers.add', 'value': phids})
341
341
342 oldnodedrev = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
342 oldnodedrev = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
343
343
344 # Send patches one by one so we know their Differential Revision IDs and
344 # Send patches one by one so we know their Differential Revision IDs and
345 # can provide dependency relationship
345 # can provide dependency relationship
346 lastrevid = None
346 lastrevid = None
347 for rev in revs:
347 for rev in revs:
348 ui.debug('sending rev %d\n' % rev)
348 ui.debug('sending rev %d\n' % rev)
349 ctx = repo[rev]
349 ctx = repo[rev]
350
350
351 # Get Differential Revision ID
351 # Get Differential Revision ID
352 oldnode, revid = oldnodedrev.get(ctx.node(), (None, None))
352 oldnode, revid = oldnodedrev.get(ctx.node(), (None, None))
353 if oldnode != ctx.node():
353 if oldnode != ctx.node():
354 # Create or update Differential Revision
354 # Create or update Differential Revision
355 revision = createdifferentialrevision(ctx, revid, lastrevid,
355 revision = createdifferentialrevision(ctx, revid, lastrevid,
356 oldnode, actions)
356 oldnode, actions)
357 newrevid = int(revision[r'object'][r'id'])
357 newrevid = int(revision[r'object'][r'id'])
358 if revid:
358 if revid:
359 action = _('updated')
359 action = _('updated')
360 else:
360 else:
361 action = _('created')
361 action = _('created')
362
362
363 # Create a local tag to note the association
363 # Create a local tag to note the association
364 tagname = 'D%d' % newrevid
364 tagname = 'D%d' % newrevid
365 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
365 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
366 date=None, local=True)
366 date=None, local=True)
367 else:
367 else:
368 # Nothing changed. But still set "newrevid" so the next revision
368 # Nothing changed. But still set "newrevid" so the next revision
369 # could depend on this one.
369 # could depend on this one.
370 newrevid = revid
370 newrevid = revid
371 action = _('skipped')
371 action = _('skipped')
372
372
373 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
373 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
374 ctx.description().split('\n')[0]))
374 ctx.description().split('\n')[0]))
375 lastrevid = newrevid
375 lastrevid = newrevid
376
376
377 # Map from "hg:meta" keys to header understood by "hg import". The order is
377 # Map from "hg:meta" keys to header understood by "hg import". The order is
378 # consistent with "hg export" output.
378 # consistent with "hg export" output.
379 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
379 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
380 (r'node', 'Node ID'), (r'parent', 'Parent ')])
380 (r'node', 'Node ID'), (r'parent', 'Parent ')])
381
381
382 def querydrev(repo, params, stack=False):
382 def querydrev(repo, params, stack=False):
383 """return a list of "Differential Revision" dicts
383 """return a list of "Differential Revision" dicts
384
384
385 params is the input of "differential.query" API, and is expected to match
385 params is the input of "differential.query" API, and is expected to match
386 just a single Differential Revision.
386 just a single Differential Revision.
387
387
388 A "Differential Revision dict" looks like:
388 A "Differential Revision dict" looks like:
389
389
390 {
390 {
391 "id": "2",
391 "id": "2",
392 "phid": "PHID-DREV-672qvysjcczopag46qty",
392 "phid": "PHID-DREV-672qvysjcczopag46qty",
393 "title": "example",
393 "title": "example",
394 "uri": "https://phab.example.com/D2",
394 "uri": "https://phab.example.com/D2",
395 "dateCreated": "1499181406",
395 "dateCreated": "1499181406",
396 "dateModified": "1499182103",
396 "dateModified": "1499182103",
397 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
397 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
398 "status": "0",
398 "status": "0",
399 "statusName": "Needs Review",
399 "statusName": "Needs Review",
400 "properties": [],
400 "properties": [],
401 "branch": null,
401 "branch": null,
402 "summary": "",
402 "summary": "",
403 "testPlan": "",
403 "testPlan": "",
404 "lineCount": "2",
404 "lineCount": "2",
405 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
405 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
406 "diffs": [
406 "diffs": [
407 "3",
407 "3",
408 "4",
408 "4",
409 ],
409 ],
410 "commits": [],
410 "commits": [],
411 "reviewers": [],
411 "reviewers": [],
412 "ccs": [],
412 "ccs": [],
413 "hashes": [],
413 "hashes": [],
414 "auxiliary": {
414 "auxiliary": {
415 "phabricator:projects": [],
415 "phabricator:projects": [],
416 "phabricator:depends-on": [
416 "phabricator:depends-on": [
417 "PHID-DREV-gbapp366kutjebt7agcd"
417 "PHID-DREV-gbapp366kutjebt7agcd"
418 ]
418 ]
419 },
419 },
420 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
420 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
421 "sourcePath": null
421 "sourcePath": null
422 }
422 }
423
423
424 If stack is True, return a list of "Differential Revision dict"s in an
424 If stack is True, return a list of "Differential Revision dict"s in an
425 order that the latter ones depend on the former ones. Otherwise, return a
425 order that the latter ones depend on the former ones. Otherwise, return a
426 list of a unique "Differential Revision dict".
426 list of a unique "Differential Revision dict".
427 """
427 """
428 prefetched = {} # {id or phid: drev}
428 prefetched = {} # {id or phid: drev}
429 def fetch(params):
429 def fetch(params):
430 """params -> single drev or None"""
430 """params -> single drev or None"""
431 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
431 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
432 if key in prefetched:
432 if key in prefetched:
433 return prefetched[key]
433 return prefetched[key]
434 # Otherwise, send the request. If we're fetching a stack, be smarter
434 # Otherwise, send the request. If we're fetching a stack, be smarter
435 # and fetch more ids in one batch, even if it could be unnecessary.
435 # and fetch more ids in one batch, even if it could be unnecessary.
436 batchparams = params
436 batchparams = params
437 if stack and len(params.get(r'ids', [])) == 1:
437 if stack and len(params.get(r'ids', [])) == 1:
438 i = int(params[r'ids'][0])
438 i = int(params[r'ids'][0])
439 # developer config: phabricator.batchsize
439 # developer config: phabricator.batchsize
440 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
440 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
441 batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
441 batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
442 drevs = callconduit(repo, 'differential.query', batchparams)
442 drevs = callconduit(repo, 'differential.query', batchparams)
443 # Fill prefetched with the result
443 # Fill prefetched with the result
444 for drev in drevs:
444 for drev in drevs:
445 prefetched[drev[r'phid']] = drev
445 prefetched[drev[r'phid']] = drev
446 prefetched[int(drev[r'id'])] = drev
446 prefetched[int(drev[r'id'])] = drev
447 if key not in prefetched:
447 if key not in prefetched:
448 raise error.Abort(_('cannot get Differential Revision %r') % params)
448 raise error.Abort(_('cannot get Differential Revision %r') % params)
449 return prefetched[key]
449 return prefetched[key]
450
450
451 visited = set()
451 visited = set()
452 result = []
452 result = []
453 queue = [params]
453 queue = [params]
454 while queue:
454 while queue:
455 params = queue.pop()
455 params = queue.pop()
456 drev = fetch(params)
456 drev = fetch(params)
457 if drev[r'id'] in visited:
457 if drev[r'id'] in visited:
458 continue
458 continue
459 visited.add(drev[r'id'])
459 visited.add(drev[r'id'])
460 result.append(drev)
460 result.append(drev)
461 if stack:
461 if stack:
462 auxiliary = drev.get(r'auxiliary', {})
462 auxiliary = drev.get(r'auxiliary', {})
463 depends = auxiliary.get(r'phabricator:depends-on', [])
463 depends = auxiliary.get(r'phabricator:depends-on', [])
464 for phid in depends:
464 for phid in depends:
465 queue.append({'phids': [phid]})
465 queue.append({'phids': [phid]})
466 result.reverse()
466 result.reverse()
467 return result
467 return result
468
468
469 def getdescfromdrev(drev):
469 def getdescfromdrev(drev):
470 """get description (commit message) from "Differential Revision"
470 """get description (commit message) from "Differential Revision"
471
471
472 This is similar to differential.getcommitmessage API. But we only care
472 This is similar to differential.getcommitmessage API. But we only care
473 about limited fields: title, summary, test plan, and URL.
473 about limited fields: title, summary, test plan, and URL.
474 """
474 """
475 title = drev[r'title']
475 title = drev[r'title']
476 summary = drev[r'summary'].rstrip()
476 summary = drev[r'summary'].rstrip()
477 testplan = drev[r'testPlan'].rstrip()
477 testplan = drev[r'testPlan'].rstrip()
478 if testplan:
478 if testplan:
479 testplan = 'Test Plan:\n%s' % testplan
479 testplan = 'Test Plan:\n%s' % testplan
480 uri = 'Differential Revision: %s' % drev[r'uri']
480 uri = 'Differential Revision: %s' % drev[r'uri']
481 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
481 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
482
482
483 def getdiffmeta(diff):
483 def getdiffmeta(diff):
484 """get commit metadata (date, node, user, p1) from a diff object
484 """get commit metadata (date, node, user, p1) from a diff object
485
485
486 The metadata could be "hg:meta", sent by phabsend, like:
486 The metadata could be "hg:meta", sent by phabsend, like:
487
487
488 "properties": {
488 "properties": {
489 "hg:meta": {
489 "hg:meta": {
490 "date": "1499571514 25200",
490 "date": "1499571514 25200",
491 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
491 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
492 "user": "Foo Bar <foo@example.com>",
492 "user": "Foo Bar <foo@example.com>",
493 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
493 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
494 }
494 }
495 }
495 }
496
496
497 Or converted from "local:commits", sent by "arc", like:
497 Or converted from "local:commits", sent by "arc", like:
498
498
499 "properties": {
499 "properties": {
500 "local:commits": {
500 "local:commits": {
501 "98c08acae292b2faf60a279b4189beb6cff1414d": {
501 "98c08acae292b2faf60a279b4189beb6cff1414d": {
502 "author": "Foo Bar",
502 "author": "Foo Bar",
503 "time": 1499546314,
503 "time": 1499546314,
504 "branch": "default",
504 "branch": "default",
505 "tag": "",
505 "tag": "",
506 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
506 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
507 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
507 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
508 "local": "1000",
508 "local": "1000",
509 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
509 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
510 "summary": "...",
510 "summary": "...",
511 "message": "...",
511 "message": "...",
512 "authorEmail": "foo@example.com"
512 "authorEmail": "foo@example.com"
513 }
513 }
514 }
514 }
515 }
515 }
516
516
517 Note: metadata extracted from "local:commits" will lose time zone
517 Note: metadata extracted from "local:commits" will lose time zone
518 information.
518 information.
519 """
519 """
520 props = diff.get(r'properties') or {}
520 props = diff.get(r'properties') or {}
521 meta = props.get(r'hg:meta')
521 meta = props.get(r'hg:meta')
522 if not meta and props.get(r'local:commits'):
522 if not meta and props.get(r'local:commits'):
523 commit = sorted(props[r'local:commits'].values())[0]
523 commit = sorted(props[r'local:commits'].values())[0]
524 meta = {
524 meta = {
525 r'date': r'%d 0' % commit[r'time'],
525 r'date': r'%d 0' % commit[r'time'],
526 r'node': commit[r'rev'],
526 r'node': commit[r'rev'],
527 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
527 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
528 }
528 }
529 if len(commit.get(r'parents', ())) >= 1:
529 if len(commit.get(r'parents', ())) >= 1:
530 meta[r'parent'] = commit[r'parents'][0]
530 meta[r'parent'] = commit[r'parents'][0]
531 return meta or {}
531 return meta or {}
532
532
533 def readpatch(repo, params, write, stack=False):
533 def readpatch(repo, params, write, stack=False):
534 """generate plain-text patch readable by 'hg import'
534 """generate plain-text patch readable by 'hg import'
535
535
536 write is usually ui.write. params is passed to "differential.query". If
536 write is usually ui.write. params is passed to "differential.query". If
537 stack is True, also write dependent patches.
537 stack is True, also write dependent patches.
538 """
538 """
539 # Differential Revisions
539 # Differential Revisions
540 drevs = querydrev(repo, params, stack)
540 drevs = querydrev(repo, params, stack)
541
541
542 # Prefetch hg:meta property for all diffs
542 # Prefetch hg:meta property for all diffs
543 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
543 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
544 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
544 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
545
545
546 # Generate patch for each drev
546 # Generate patch for each drev
547 for drev in drevs:
547 for drev in drevs:
548 repo.ui.note(_('reading D%s\n') % drev[r'id'])
548 repo.ui.note(_('reading D%s\n') % drev[r'id'])
549
549
550 diffid = max(int(v) for v in drev[r'diffs'])
550 diffid = max(int(v) for v in drev[r'diffs'])
551 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
551 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
552 desc = getdescfromdrev(drev)
552 desc = getdescfromdrev(drev)
553 header = '# HG changeset patch\n'
553 header = '# HG changeset patch\n'
554
554
555 # Try to preserve metadata from hg:meta property. Write hg patch
555 # Try to preserve metadata from hg:meta property. Write hg patch
556 # headers that can be read by the "import" command. See patchheadermap
556 # headers that can be read by the "import" command. See patchheadermap
557 # and extract in mercurial/patch.py for supported headers.
557 # and extract in mercurial/patch.py for supported headers.
558 meta = getdiffmeta(diffs[str(diffid)])
558 meta = getdiffmeta(diffs[str(diffid)])
559 for k in _metanamemap.keys():
559 for k in _metanamemap.keys():
560 if k in meta:
560 if k in meta:
561 header += '# %s %s\n' % (_metanamemap[k], meta[k])
561 header += '# %s %s\n' % (_metanamemap[k], meta[k])
562
562
563 write(('%s%s\n%s') % (header, desc, body))
563 content = '%s%s\n%s' % (header, desc, body)
564 write(encoding.unitolocal(content))
564
565
565 @command('phabread',
566 @command('phabread',
566 [('', 'stack', False, _('read dependencies'))],
567 [('', 'stack', False, _('read dependencies'))],
567 _('REVID [OPTIONS]'))
568 _('REVID [OPTIONS]'))
568 def phabread(ui, repo, revid, **opts):
569 def phabread(ui, repo, revid, **opts):
569 """print patches from Phabricator suitable for importing
570 """print patches from Phabricator suitable for importing
570
571
571 REVID could be a Differential Revision identity, like ``D123``, or just the
572 REVID could be a Differential Revision identity, like ``D123``, or just the
572 number ``123``, or a full URL like ``https://phab.example.com/D123``.
573 number ``123``, or a full URL like ``https://phab.example.com/D123``.
573
574
574 If --stack is given, follow dependencies information and read all patches.
575 If --stack is given, follow dependencies information and read all patches.
575 """
576 """
576 try:
577 try:
577 revid = int(revid.split('/')[-1].replace('D', ''))
578 revid = int(revid.split('/')[-1].replace('D', ''))
578 except ValueError:
579 except ValueError:
579 raise error.Abort(_('invalid Revision ID: %s') % revid)
580 raise error.Abort(_('invalid Revision ID: %s') % revid)
580 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
581 readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
General Comments 0
You need to be logged in to leave comments. Login now