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