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