##// END OF EJS Templates
phabricator: add a template item for linking to a differential review...
Tom Prince -
r35740:f18ba40d default
parent child Browse files
Show More
@@ -1,867 +1,881 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, and a ``phabread`` command which prints a stack of
10 changesets to Phabricator, and a ``phabread`` command which prints a stack of
11 revisions in a format suitable for :hg:`import`, and a ``phabupdate`` command
11 revisions in a format suitable for :hg:`import`, and a ``phabupdate`` command
12 to update statuses in batch.
12 to update statuses in batch.
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 # curl command to use. If not set (default), use builtin HTTP library to
31 # curl command to use. If not set (default), use builtin HTTP library to
32 # communicate. If set, use the specified curl command. This could be useful
32 # communicate. If set, use the specified curl command. This could be useful
33 # if you need to specify advanced options that is not easily supported by
33 # if you need to specify advanced options that is not easily supported by
34 # the internal library.
34 # the internal library.
35 curlcmd = curl --connect-timeout 2 --retry 3 --silent
35 curlcmd = curl --connect-timeout 2 --retry 3 --silent
36 """
36 """
37
37
38 from __future__ import absolute_import
38 from __future__ import absolute_import
39
39
40 import itertools
40 import itertools
41 import json
41 import json
42 import operator
42 import operator
43 import re
43 import re
44
44
45 from mercurial.node import bin, nullid
45 from mercurial.node import bin, nullid
46 from mercurial.i18n import _
46 from mercurial.i18n import _
47 from mercurial import (
47 from mercurial import (
48 cmdutil,
48 cmdutil,
49 context,
49 context,
50 encoding,
50 encoding,
51 error,
51 error,
52 mdiff,
52 mdiff,
53 obsutil,
53 obsutil,
54 parser,
54 parser,
55 patch,
55 patch,
56 registrar,
56 registrar,
57 scmutil,
57 scmutil,
58 smartset,
58 smartset,
59 tags,
59 tags,
60 url as urlmod,
60 url as urlmod,
61 util,
61 util,
62 )
62 )
63
63
64 cmdtable = {}
64 cmdtable = {}
65 command = registrar.command(cmdtable)
65 command = registrar.command(cmdtable)
66
66
67 colortable = {
67 colortable = {
68 'phabricator.action.created': 'green',
68 'phabricator.action.created': 'green',
69 'phabricator.action.skipped': 'magenta',
69 'phabricator.action.skipped': 'magenta',
70 'phabricator.action.updated': 'magenta',
70 'phabricator.action.updated': 'magenta',
71 'phabricator.desc': '',
71 'phabricator.desc': '',
72 'phabricator.drev': 'bold',
72 'phabricator.drev': 'bold',
73 'phabricator.node': '',
73 'phabricator.node': '',
74 }
74 }
75
75
76 def urlencodenested(params):
76 def urlencodenested(params):
77 """like urlencode, but works with nested parameters.
77 """like urlencode, but works with nested parameters.
78
78
79 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
79 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
80 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
80 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
81 urlencode. Note: the encoding is consistent with PHP's http_build_query.
81 urlencode. Note: the encoding is consistent with PHP's http_build_query.
82 """
82 """
83 flatparams = util.sortdict()
83 flatparams = util.sortdict()
84 def process(prefix, obj):
84 def process(prefix, obj):
85 items = {list: enumerate, dict: lambda x: x.items()}.get(type(obj))
85 items = {list: enumerate, dict: lambda x: x.items()}.get(type(obj))
86 if items is None:
86 if items is None:
87 flatparams[prefix] = obj
87 flatparams[prefix] = obj
88 else:
88 else:
89 for k, v in items(obj):
89 for k, v in items(obj):
90 if prefix:
90 if prefix:
91 process('%s[%s]' % (prefix, k), v)
91 process('%s[%s]' % (prefix, k), v)
92 else:
92 else:
93 process(k, v)
93 process(k, v)
94 process('', params)
94 process('', params)
95 return util.urlreq.urlencode(flatparams)
95 return util.urlreq.urlencode(flatparams)
96
96
97 def readurltoken(repo):
97 def readurltoken(repo):
98 """return conduit url, token and make sure they exist
98 """return conduit url, token and make sure they exist
99
99
100 Currently read from [phabricator] config section. In the future, it might
100 Currently read from [phabricator] config section. In the future, it might
101 make sense to read from .arcconfig and .arcrc as well.
101 make sense to read from .arcconfig and .arcrc as well.
102 """
102 """
103 values = []
103 values = []
104 section = 'phabricator'
104 section = 'phabricator'
105 for name in ['url', 'token']:
105 for name in ['url', 'token']:
106 value = repo.ui.config(section, name)
106 value = repo.ui.config(section, name)
107 if not value:
107 if not value:
108 raise error.Abort(_('config %s.%s is required') % (section, name))
108 raise error.Abort(_('config %s.%s is required') % (section, name))
109 values.append(value)
109 values.append(value)
110 return values
110 return values
111
111
112 def callconduit(repo, name, params):
112 def callconduit(repo, name, params):
113 """call Conduit API, params is a dict. return json.loads result, or None"""
113 """call Conduit API, params is a dict. return json.loads result, or None"""
114 host, token = readurltoken(repo)
114 host, token = readurltoken(repo)
115 url, authinfo = util.url('/'.join([host, 'api', name])).authinfo()
115 url, authinfo = util.url('/'.join([host, 'api', name])).authinfo()
116 repo.ui.debug('Conduit Call: %s %s\n' % (url, params))
116 repo.ui.debug('Conduit Call: %s %s\n' % (url, params))
117 params = params.copy()
117 params = params.copy()
118 params['api.token'] = token
118 params['api.token'] = token
119 data = urlencodenested(params)
119 data = urlencodenested(params)
120 curlcmd = repo.ui.config('phabricator', 'curlcmd')
120 curlcmd = repo.ui.config('phabricator', 'curlcmd')
121 if curlcmd:
121 if curlcmd:
122 sin, sout = util.popen2('%s -d @- %s' % (curlcmd, util.shellquote(url)))
122 sin, sout = util.popen2('%s -d @- %s' % (curlcmd, util.shellquote(url)))
123 sin.write(data)
123 sin.write(data)
124 sin.close()
124 sin.close()
125 body = sout.read()
125 body = sout.read()
126 else:
126 else:
127 urlopener = urlmod.opener(repo.ui, authinfo)
127 urlopener = urlmod.opener(repo.ui, authinfo)
128 request = util.urlreq.request(url, data=data)
128 request = util.urlreq.request(url, data=data)
129 body = urlopener.open(request).read()
129 body = urlopener.open(request).read()
130 repo.ui.debug('Conduit Response: %s\n' % body)
130 repo.ui.debug('Conduit Response: %s\n' % body)
131 parsed = json.loads(body)
131 parsed = json.loads(body)
132 if parsed.get(r'error_code'):
132 if parsed.get(r'error_code'):
133 msg = (_('Conduit Error (%s): %s')
133 msg = (_('Conduit Error (%s): %s')
134 % (parsed[r'error_code'], parsed[r'error_info']))
134 % (parsed[r'error_code'], parsed[r'error_info']))
135 raise error.Abort(msg)
135 raise error.Abort(msg)
136 return parsed[r'result']
136 return parsed[r'result']
137
137
138 @command('debugcallconduit', [], _('METHOD'))
138 @command('debugcallconduit', [], _('METHOD'))
139 def debugcallconduit(ui, repo, name):
139 def debugcallconduit(ui, repo, name):
140 """call Conduit API
140 """call Conduit API
141
141
142 Call parameters are read from stdin as a JSON blob. Result will be written
142 Call parameters are read from stdin as a JSON blob. Result will be written
143 to stdout as a JSON blob.
143 to stdout as a JSON blob.
144 """
144 """
145 params = json.loads(ui.fin.read())
145 params = json.loads(ui.fin.read())
146 result = callconduit(repo, name, params)
146 result = callconduit(repo, name, params)
147 s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': '))
147 s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': '))
148 ui.write('%s\n' % s)
148 ui.write('%s\n' % s)
149
149
150 def getrepophid(repo):
150 def getrepophid(repo):
151 """given callsign, return repository PHID or None"""
151 """given callsign, return repository PHID or None"""
152 # developer config: phabricator.repophid
152 # developer config: phabricator.repophid
153 repophid = repo.ui.config('phabricator', 'repophid')
153 repophid = repo.ui.config('phabricator', 'repophid')
154 if repophid:
154 if repophid:
155 return repophid
155 return repophid
156 callsign = repo.ui.config('phabricator', 'callsign')
156 callsign = repo.ui.config('phabricator', 'callsign')
157 if not callsign:
157 if not callsign:
158 return None
158 return None
159 query = callconduit(repo, 'diffusion.repository.search',
159 query = callconduit(repo, 'diffusion.repository.search',
160 {'constraints': {'callsigns': [callsign]}})
160 {'constraints': {'callsigns': [callsign]}})
161 if len(query[r'data']) == 0:
161 if len(query[r'data']) == 0:
162 return None
162 return None
163 repophid = encoding.strtolocal(query[r'data'][0][r'phid'])
163 repophid = encoding.strtolocal(query[r'data'][0][r'phid'])
164 repo.ui.setconfig('phabricator', 'repophid', repophid)
164 repo.ui.setconfig('phabricator', 'repophid', repophid)
165 return repophid
165 return repophid
166
166
167 _differentialrevisiontagre = re.compile('\AD([1-9][0-9]*)\Z')
167 _differentialrevisiontagre = re.compile('\AD([1-9][0-9]*)\Z')
168 _differentialrevisiondescre = re.compile(
168 _differentialrevisiondescre = re.compile(
169 '^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M)
169 '^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M)
170
170
171 def getoldnodedrevmap(repo, nodelist):
171 def getoldnodedrevmap(repo, nodelist):
172 """find previous nodes that has been sent to Phabricator
172 """find previous nodes that has been sent to Phabricator
173
173
174 return {node: (oldnode, Differential diff, Differential Revision ID)}
174 return {node: (oldnode, Differential diff, Differential Revision ID)}
175 for node in nodelist with known previous sent versions, or associated
175 for node in nodelist with known previous sent versions, or associated
176 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
176 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
177 be ``None``.
177 be ``None``.
178
178
179 Examines commit messages like "Differential Revision:" to get the
179 Examines commit messages like "Differential Revision:" to get the
180 association information.
180 association information.
181
181
182 If such commit message line is not found, examines all precursors and their
182 If such commit message line is not found, examines all precursors and their
183 tags. Tags with format like "D1234" are considered a match and the node
183 tags. Tags with format like "D1234" are considered a match and the node
184 with that tag, and the number after "D" (ex. 1234) will be returned.
184 with that tag, and the number after "D" (ex. 1234) will be returned.
185
185
186 The ``old node``, if not None, is guaranteed to be the last diff of
186 The ``old node``, if not None, is guaranteed to be the last diff of
187 corresponding Differential Revision, and exist in the repo.
187 corresponding Differential Revision, and exist in the repo.
188 """
188 """
189 url, token = readurltoken(repo)
189 url, token = readurltoken(repo)
190 unfi = repo.unfiltered()
190 unfi = repo.unfiltered()
191 nodemap = unfi.changelog.nodemap
191 nodemap = unfi.changelog.nodemap
192
192
193 result = {} # {node: (oldnode?, lastdiff?, drev)}
193 result = {} # {node: (oldnode?, lastdiff?, drev)}
194 toconfirm = {} # {node: (force, {precnode}, drev)}
194 toconfirm = {} # {node: (force, {precnode}, drev)}
195 for node in nodelist:
195 for node in nodelist:
196 ctx = unfi[node]
196 ctx = unfi[node]
197 # For tags like "D123", put them into "toconfirm" to verify later
197 # For tags like "D123", put them into "toconfirm" to verify later
198 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
198 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
199 for n in precnodes:
199 for n in precnodes:
200 if n in nodemap:
200 if n in nodemap:
201 for tag in unfi.nodetags(n):
201 for tag in unfi.nodetags(n):
202 m = _differentialrevisiontagre.match(tag)
202 m = _differentialrevisiontagre.match(tag)
203 if m:
203 if m:
204 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
204 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
205 continue
205 continue
206
206
207 # Check commit message
207 # Check commit message
208 m = _differentialrevisiondescre.search(ctx.description())
208 m = _differentialrevisiondescre.search(ctx.description())
209 if m:
209 if m:
210 toconfirm[node] = (1, set(precnodes), int(m.group('id')))
210 toconfirm[node] = (1, set(precnodes), int(m.group('id')))
211
211
212 # Double check if tags are genuine by collecting all old nodes from
212 # Double check if tags are genuine by collecting all old nodes from
213 # Phabricator, and expect precursors overlap with it.
213 # Phabricator, and expect precursors overlap with it.
214 if toconfirm:
214 if toconfirm:
215 drevs = [drev for force, precs, drev in toconfirm.values()]
215 drevs = [drev for force, precs, drev in toconfirm.values()]
216 alldiffs = callconduit(unfi, 'differential.querydiffs',
216 alldiffs = callconduit(unfi, 'differential.querydiffs',
217 {'revisionIDs': drevs})
217 {'revisionIDs': drevs})
218 getnode = lambda d: bin(encoding.unitolocal(
218 getnode = lambda d: bin(encoding.unitolocal(
219 getdiffmeta(d).get(r'node', ''))) or None
219 getdiffmeta(d).get(r'node', ''))) or None
220 for newnode, (force, precset, drev) in toconfirm.items():
220 for newnode, (force, precset, drev) in toconfirm.items():
221 diffs = [d for d in alldiffs.values()
221 diffs = [d for d in alldiffs.values()
222 if int(d[r'revisionID']) == drev]
222 if int(d[r'revisionID']) == drev]
223
223
224 # "precursors" as known by Phabricator
224 # "precursors" as known by Phabricator
225 phprecset = set(getnode(d) for d in diffs)
225 phprecset = set(getnode(d) for d in diffs)
226
226
227 # Ignore if precursors (Phabricator and local repo) do not overlap,
227 # Ignore if precursors (Phabricator and local repo) do not overlap,
228 # and force is not set (when commit message says nothing)
228 # and force is not set (when commit message says nothing)
229 if not force and not bool(phprecset & precset):
229 if not force and not bool(phprecset & precset):
230 tagname = 'D%d' % drev
230 tagname = 'D%d' % drev
231 tags.tag(repo, tagname, nullid, message=None, user=None,
231 tags.tag(repo, tagname, nullid, message=None, user=None,
232 date=None, local=True)
232 date=None, local=True)
233 unfi.ui.warn(_('D%s: local tag removed - does not match '
233 unfi.ui.warn(_('D%s: local tag removed - does not match '
234 'Differential history\n') % drev)
234 'Differential history\n') % drev)
235 continue
235 continue
236
236
237 # Find the last node using Phabricator metadata, and make sure it
237 # Find the last node using Phabricator metadata, and make sure it
238 # exists in the repo
238 # exists in the repo
239 oldnode = lastdiff = None
239 oldnode = lastdiff = None
240 if diffs:
240 if diffs:
241 lastdiff = max(diffs, key=lambda d: int(d[r'id']))
241 lastdiff = max(diffs, key=lambda d: int(d[r'id']))
242 oldnode = getnode(lastdiff)
242 oldnode = getnode(lastdiff)
243 if oldnode and oldnode not in nodemap:
243 if oldnode and oldnode not in nodemap:
244 oldnode = None
244 oldnode = None
245
245
246 result[newnode] = (oldnode, lastdiff, drev)
246 result[newnode] = (oldnode, lastdiff, drev)
247
247
248 return result
248 return result
249
249
250 def getdiff(ctx, diffopts):
250 def getdiff(ctx, diffopts):
251 """plain-text diff without header (user, commit message, etc)"""
251 """plain-text diff without header (user, commit message, etc)"""
252 output = util.stringio()
252 output = util.stringio()
253 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
253 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
254 None, opts=diffopts):
254 None, opts=diffopts):
255 output.write(chunk)
255 output.write(chunk)
256 return output.getvalue()
256 return output.getvalue()
257
257
258 def creatediff(ctx):
258 def creatediff(ctx):
259 """create a Differential Diff"""
259 """create a Differential Diff"""
260 repo = ctx.repo()
260 repo = ctx.repo()
261 repophid = getrepophid(repo)
261 repophid = getrepophid(repo)
262 # Create a "Differential Diff" via "differential.createrawdiff" API
262 # Create a "Differential Diff" via "differential.createrawdiff" API
263 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
263 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
264 if repophid:
264 if repophid:
265 params['repositoryPHID'] = repophid
265 params['repositoryPHID'] = repophid
266 diff = callconduit(repo, 'differential.createrawdiff', params)
266 diff = callconduit(repo, 'differential.createrawdiff', params)
267 if not diff:
267 if not diff:
268 raise error.Abort(_('cannot create diff for %s') % ctx)
268 raise error.Abort(_('cannot create diff for %s') % ctx)
269 return diff
269 return diff
270
270
271 def writediffproperties(ctx, diff):
271 def writediffproperties(ctx, diff):
272 """write metadata to diff so patches could be applied losslessly"""
272 """write metadata to diff so patches could be applied losslessly"""
273 params = {
273 params = {
274 'diff_id': diff[r'id'],
274 'diff_id': diff[r'id'],
275 'name': 'hg:meta',
275 'name': 'hg:meta',
276 'data': json.dumps({
276 'data': json.dumps({
277 'user': ctx.user(),
277 'user': ctx.user(),
278 'date': '%d %d' % ctx.date(),
278 'date': '%d %d' % ctx.date(),
279 'node': ctx.hex(),
279 'node': ctx.hex(),
280 'parent': ctx.p1().hex(),
280 'parent': ctx.p1().hex(),
281 }),
281 }),
282 }
282 }
283 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
283 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
284
284
285 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
285 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
286 olddiff=None, actions=None):
286 olddiff=None, actions=None):
287 """create or update a Differential Revision
287 """create or update a Differential Revision
288
288
289 If revid is None, create a new Differential Revision, otherwise update
289 If revid is None, create a new Differential Revision, otherwise update
290 revid. If parentrevid is not None, set it as a dependency.
290 revid. If parentrevid is not None, set it as a dependency.
291
291
292 If oldnode is not None, check if the patch content (without commit message
292 If oldnode is not None, check if the patch content (without commit message
293 and metadata) has changed before creating another diff.
293 and metadata) has changed before creating another diff.
294
294
295 If actions is not None, they will be appended to the transaction.
295 If actions is not None, they will be appended to the transaction.
296 """
296 """
297 repo = ctx.repo()
297 repo = ctx.repo()
298 if oldnode:
298 if oldnode:
299 diffopts = mdiff.diffopts(git=True, context=32767)
299 diffopts = mdiff.diffopts(git=True, context=32767)
300 oldctx = repo.unfiltered()[oldnode]
300 oldctx = repo.unfiltered()[oldnode]
301 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
301 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
302 else:
302 else:
303 neednewdiff = True
303 neednewdiff = True
304
304
305 transactions = []
305 transactions = []
306 if neednewdiff:
306 if neednewdiff:
307 diff = creatediff(ctx)
307 diff = creatediff(ctx)
308 transactions.append({'type': 'update', 'value': diff[r'phid']})
308 transactions.append({'type': 'update', 'value': diff[r'phid']})
309 else:
309 else:
310 # Even if we don't need to upload a new diff because the patch content
310 # Even if we don't need to upload a new diff because the patch content
311 # does not change. We might still need to update its metadata so
311 # does not change. We might still need to update its metadata so
312 # pushers could know the correct node metadata.
312 # pushers could know the correct node metadata.
313 assert olddiff
313 assert olddiff
314 diff = olddiff
314 diff = olddiff
315 writediffproperties(ctx, diff)
315 writediffproperties(ctx, diff)
316
316
317 # Use a temporary summary to set dependency. There might be better ways but
317 # Use a temporary summary to set dependency. There might be better ways but
318 # I cannot find them for now. But do not do that if we are updating an
318 # I cannot find them for now. But do not do that if we are updating an
319 # existing revision (revid is not None) since that introduces visible
319 # existing revision (revid is not None) since that introduces visible
320 # churns (someone edited "Summary" twice) on the web page.
320 # churns (someone edited "Summary" twice) on the web page.
321 if parentrevid and revid is None:
321 if parentrevid and revid is None:
322 summary = 'Depends on D%s' % parentrevid
322 summary = 'Depends on D%s' % parentrevid
323 transactions += [{'type': 'summary', 'value': summary},
323 transactions += [{'type': 'summary', 'value': summary},
324 {'type': 'summary', 'value': ' '}]
324 {'type': 'summary', 'value': ' '}]
325
325
326 if actions:
326 if actions:
327 transactions += actions
327 transactions += actions
328
328
329 # Parse commit message and update related fields.
329 # Parse commit message and update related fields.
330 desc = ctx.description()
330 desc = ctx.description()
331 info = callconduit(repo, 'differential.parsecommitmessage',
331 info = callconduit(repo, 'differential.parsecommitmessage',
332 {'corpus': desc})
332 {'corpus': desc})
333 for k, v in info[r'fields'].items():
333 for k, v in info[r'fields'].items():
334 if k in ['title', 'summary', 'testPlan']:
334 if k in ['title', 'summary', 'testPlan']:
335 transactions.append({'type': k, 'value': v})
335 transactions.append({'type': k, 'value': v})
336
336
337 params = {'transactions': transactions}
337 params = {'transactions': transactions}
338 if revid is not None:
338 if revid is not None:
339 # Update an existing Differential Revision
339 # Update an existing Differential Revision
340 params['objectIdentifier'] = revid
340 params['objectIdentifier'] = revid
341
341
342 revision = callconduit(repo, 'differential.revision.edit', params)
342 revision = callconduit(repo, 'differential.revision.edit', params)
343 if not revision:
343 if not revision:
344 raise error.Abort(_('cannot create revision for %s') % ctx)
344 raise error.Abort(_('cannot create revision for %s') % ctx)
345
345
346 return revision, diff
346 return revision, diff
347
347
348 def userphids(repo, names):
348 def userphids(repo, names):
349 """convert user names to PHIDs"""
349 """convert user names to PHIDs"""
350 query = {'constraints': {'usernames': names}}
350 query = {'constraints': {'usernames': names}}
351 result = callconduit(repo, 'user.search', query)
351 result = callconduit(repo, 'user.search', query)
352 # username not found is not an error of the API. So check if we have missed
352 # username not found is not an error of the API. So check if we have missed
353 # some names here.
353 # some names here.
354 data = result[r'data']
354 data = result[r'data']
355 resolved = set(entry[r'fields'][r'username'] for entry in data)
355 resolved = set(entry[r'fields'][r'username'] for entry in data)
356 unresolved = set(names) - resolved
356 unresolved = set(names) - resolved
357 if unresolved:
357 if unresolved:
358 raise error.Abort(_('unknown username: %s')
358 raise error.Abort(_('unknown username: %s')
359 % ' '.join(sorted(unresolved)))
359 % ' '.join(sorted(unresolved)))
360 return [entry[r'phid'] for entry in data]
360 return [entry[r'phid'] for entry in data]
361
361
362 @command('phabsend',
362 @command('phabsend',
363 [('r', 'rev', [], _('revisions to send'), _('REV')),
363 [('r', 'rev', [], _('revisions to send'), _('REV')),
364 ('', 'amend', True, _('update commit messages')),
364 ('', 'amend', True, _('update commit messages')),
365 ('', 'reviewer', [], _('specify reviewers')),
365 ('', 'reviewer', [], _('specify reviewers')),
366 ('', 'confirm', None, _('ask for confirmation before sending'))],
366 ('', 'confirm', None, _('ask for confirmation before sending'))],
367 _('REV [OPTIONS]'))
367 _('REV [OPTIONS]'))
368 def phabsend(ui, repo, *revs, **opts):
368 def phabsend(ui, repo, *revs, **opts):
369 """upload changesets to Phabricator
369 """upload changesets to Phabricator
370
370
371 If there are multiple revisions specified, they will be send as a stack
371 If there are multiple revisions specified, they will be send as a stack
372 with a linear dependencies relationship using the order specified by the
372 with a linear dependencies relationship using the order specified by the
373 revset.
373 revset.
374
374
375 For the first time uploading changesets, local tags will be created to
375 For the first time uploading changesets, local tags will be created to
376 maintain the association. After the first time, phabsend will check
376 maintain the association. After the first time, phabsend will check
377 obsstore and tags information so it can figure out whether to update an
377 obsstore and tags information so it can figure out whether to update an
378 existing Differential Revision, or create a new one.
378 existing Differential Revision, or create a new one.
379
379
380 If --amend is set, update commit messages so they have the
380 If --amend is set, update commit messages so they have the
381 ``Differential Revision`` URL, remove related tags. This is similar to what
381 ``Differential Revision`` URL, remove related tags. This is similar to what
382 arcanist will do, and is more desired in author-push workflows. Otherwise,
382 arcanist will do, and is more desired in author-push workflows. Otherwise,
383 use local tags to record the ``Differential Revision`` association.
383 use local tags to record the ``Differential Revision`` association.
384
384
385 The --confirm option lets you confirm changesets before sending them. You
385 The --confirm option lets you confirm changesets before sending them. You
386 can also add following to your configuration file to make it default
386 can also add following to your configuration file to make it default
387 behaviour::
387 behaviour::
388
388
389 [phabsend]
389 [phabsend]
390 confirm = true
390 confirm = true
391
391
392 phabsend will check obsstore and the above association to decide whether to
392 phabsend will check obsstore and the above association to decide whether to
393 update an existing Differential Revision, or create a new one.
393 update an existing Differential Revision, or create a new one.
394 """
394 """
395 revs = list(revs) + opts.get('rev', [])
395 revs = list(revs) + opts.get('rev', [])
396 revs = scmutil.revrange(repo, revs)
396 revs = scmutil.revrange(repo, revs)
397
397
398 if not revs:
398 if not revs:
399 raise error.Abort(_('phabsend requires at least one changeset'))
399 raise error.Abort(_('phabsend requires at least one changeset'))
400 if opts.get('amend'):
400 if opts.get('amend'):
401 cmdutil.checkunfinished(repo)
401 cmdutil.checkunfinished(repo)
402
402
403 # {newnode: (oldnode, olddiff, olddrev}
403 # {newnode: (oldnode, olddiff, olddrev}
404 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
404 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
405
405
406 confirm = ui.configbool('phabsend', 'confirm')
406 confirm = ui.configbool('phabsend', 'confirm')
407 confirm |= bool(opts.get('confirm'))
407 confirm |= bool(opts.get('confirm'))
408 if confirm:
408 if confirm:
409 confirmed = _confirmbeforesend(repo, revs, oldmap)
409 confirmed = _confirmbeforesend(repo, revs, oldmap)
410 if not confirmed:
410 if not confirmed:
411 raise error.Abort(_('phabsend cancelled'))
411 raise error.Abort(_('phabsend cancelled'))
412
412
413 actions = []
413 actions = []
414 reviewers = opts.get('reviewer', [])
414 reviewers = opts.get('reviewer', [])
415 if reviewers:
415 if reviewers:
416 phids = userphids(repo, reviewers)
416 phids = userphids(repo, reviewers)
417 actions.append({'type': 'reviewers.add', 'value': phids})
417 actions.append({'type': 'reviewers.add', 'value': phids})
418
418
419 drevids = [] # [int]
419 drevids = [] # [int]
420 diffmap = {} # {newnode: diff}
420 diffmap = {} # {newnode: diff}
421
421
422 # Send patches one by one so we know their Differential Revision IDs and
422 # Send patches one by one so we know their Differential Revision IDs and
423 # can provide dependency relationship
423 # can provide dependency relationship
424 lastrevid = None
424 lastrevid = None
425 for rev in revs:
425 for rev in revs:
426 ui.debug('sending rev %d\n' % rev)
426 ui.debug('sending rev %d\n' % rev)
427 ctx = repo[rev]
427 ctx = repo[rev]
428
428
429 # Get Differential Revision ID
429 # Get Differential Revision ID
430 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
430 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
431 if oldnode != ctx.node() or opts.get('amend'):
431 if oldnode != ctx.node() or opts.get('amend'):
432 # Create or update Differential Revision
432 # Create or update Differential Revision
433 revision, diff = createdifferentialrevision(
433 revision, diff = createdifferentialrevision(
434 ctx, revid, lastrevid, oldnode, olddiff, actions)
434 ctx, revid, lastrevid, oldnode, olddiff, actions)
435 diffmap[ctx.node()] = diff
435 diffmap[ctx.node()] = diff
436 newrevid = int(revision[r'object'][r'id'])
436 newrevid = int(revision[r'object'][r'id'])
437 if revid:
437 if revid:
438 action = 'updated'
438 action = 'updated'
439 else:
439 else:
440 action = 'created'
440 action = 'created'
441
441
442 # Create a local tag to note the association, if commit message
442 # Create a local tag to note the association, if commit message
443 # does not have it already
443 # does not have it already
444 m = _differentialrevisiondescre.search(ctx.description())
444 m = _differentialrevisiondescre.search(ctx.description())
445 if not m or int(m.group('id')) != newrevid:
445 if not m or int(m.group('id')) != newrevid:
446 tagname = 'D%d' % newrevid
446 tagname = 'D%d' % newrevid
447 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
447 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
448 date=None, local=True)
448 date=None, local=True)
449 else:
449 else:
450 # Nothing changed. But still set "newrevid" so the next revision
450 # Nothing changed. But still set "newrevid" so the next revision
451 # could depend on this one.
451 # could depend on this one.
452 newrevid = revid
452 newrevid = revid
453 action = 'skipped'
453 action = 'skipped'
454
454
455 actiondesc = ui.label(
455 actiondesc = ui.label(
456 {'created': _('created'),
456 {'created': _('created'),
457 'skipped': _('skipped'),
457 'skipped': _('skipped'),
458 'updated': _('updated')}[action],
458 'updated': _('updated')}[action],
459 'phabricator.action.%s' % action)
459 'phabricator.action.%s' % action)
460 drevdesc = ui.label('D%s' % newrevid, 'phabricator.drev')
460 drevdesc = ui.label('D%s' % newrevid, 'phabricator.drev')
461 nodedesc = ui.label(bytes(ctx), 'phabricator.node')
461 nodedesc = ui.label(bytes(ctx), 'phabricator.node')
462 desc = ui.label(ctx.description().split('\n')[0], 'phabricator.desc')
462 desc = ui.label(ctx.description().split('\n')[0], 'phabricator.desc')
463 ui.write(_('%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc,
463 ui.write(_('%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc,
464 desc))
464 desc))
465 drevids.append(newrevid)
465 drevids.append(newrevid)
466 lastrevid = newrevid
466 lastrevid = newrevid
467
467
468 # Update commit messages and remove tags
468 # Update commit messages and remove tags
469 if opts.get('amend'):
469 if opts.get('amend'):
470 unfi = repo.unfiltered()
470 unfi = repo.unfiltered()
471 drevs = callconduit(repo, 'differential.query', {'ids': drevids})
471 drevs = callconduit(repo, 'differential.query', {'ids': drevids})
472 with repo.wlock(), repo.lock(), repo.transaction('phabsend'):
472 with repo.wlock(), repo.lock(), repo.transaction('phabsend'):
473 wnode = unfi['.'].node()
473 wnode = unfi['.'].node()
474 mapping = {} # {oldnode: [newnode]}
474 mapping = {} # {oldnode: [newnode]}
475 for i, rev in enumerate(revs):
475 for i, rev in enumerate(revs):
476 old = unfi[rev]
476 old = unfi[rev]
477 drevid = drevids[i]
477 drevid = drevids[i]
478 drev = [d for d in drevs if int(d[r'id']) == drevid][0]
478 drev = [d for d in drevs if int(d[r'id']) == drevid][0]
479 newdesc = getdescfromdrev(drev)
479 newdesc = getdescfromdrev(drev)
480 # Make sure commit message contain "Differential Revision"
480 # Make sure commit message contain "Differential Revision"
481 if old.description() != newdesc:
481 if old.description() != newdesc:
482 parents = [
482 parents = [
483 mapping.get(old.p1().node(), (old.p1(),))[0],
483 mapping.get(old.p1().node(), (old.p1(),))[0],
484 mapping.get(old.p2().node(), (old.p2(),))[0],
484 mapping.get(old.p2().node(), (old.p2(),))[0],
485 ]
485 ]
486 new = context.metadataonlyctx(
486 new = context.metadataonlyctx(
487 repo, old, parents=parents, text=newdesc,
487 repo, old, parents=parents, text=newdesc,
488 user=old.user(), date=old.date(), extra=old.extra())
488 user=old.user(), date=old.date(), extra=old.extra())
489 newnode = new.commit()
489 newnode = new.commit()
490 mapping[old.node()] = [newnode]
490 mapping[old.node()] = [newnode]
491 # Update diff property
491 # Update diff property
492 writediffproperties(unfi[newnode], diffmap[old.node()])
492 writediffproperties(unfi[newnode], diffmap[old.node()])
493 # Remove local tags since it's no longer necessary
493 # Remove local tags since it's no longer necessary
494 tagname = 'D%d' % drevid
494 tagname = 'D%d' % drevid
495 if tagname in repo.tags():
495 if tagname in repo.tags():
496 tags.tag(repo, tagname, nullid, message=None, user=None,
496 tags.tag(repo, tagname, nullid, message=None, user=None,
497 date=None, local=True)
497 date=None, local=True)
498 scmutil.cleanupnodes(repo, mapping, 'phabsend')
498 scmutil.cleanupnodes(repo, mapping, 'phabsend')
499 if wnode in mapping:
499 if wnode in mapping:
500 unfi.setparents(mapping[wnode][0])
500 unfi.setparents(mapping[wnode][0])
501
501
502 # Map from "hg:meta" keys to header understood by "hg import". The order is
502 # Map from "hg:meta" keys to header understood by "hg import". The order is
503 # consistent with "hg export" output.
503 # consistent with "hg export" output.
504 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
504 _metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
505 (r'node', 'Node ID'), (r'parent', 'Parent ')])
505 (r'node', 'Node ID'), (r'parent', 'Parent ')])
506
506
507 def _confirmbeforesend(repo, revs, oldmap):
507 def _confirmbeforesend(repo, revs, oldmap):
508 url, token = readurltoken(repo)
508 url, token = readurltoken(repo)
509 ui = repo.ui
509 ui = repo.ui
510 for rev in revs:
510 for rev in revs:
511 ctx = repo[rev]
511 ctx = repo[rev]
512 desc = ctx.description().splitlines()[0]
512 desc = ctx.description().splitlines()[0]
513 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
513 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
514 if drevid:
514 if drevid:
515 drevdesc = ui.label('D%s' % drevid, 'phabricator.drev')
515 drevdesc = ui.label('D%s' % drevid, 'phabricator.drev')
516 else:
516 else:
517 drevdesc = ui.label(_('NEW'), 'phabricator.drev')
517 drevdesc = ui.label(_('NEW'), 'phabricator.drev')
518
518
519 ui.write(_('%s - %s: %s\n') % (drevdesc,
519 ui.write(_('%s - %s: %s\n') % (drevdesc,
520 ui.label(bytes(ctx), 'phabricator.node'),
520 ui.label(bytes(ctx), 'phabricator.node'),
521 ui.label(desc, 'phabricator.desc')))
521 ui.label(desc, 'phabricator.desc')))
522
522
523 if ui.promptchoice(_('Send the above changes to %s (yn)?'
523 if ui.promptchoice(_('Send the above changes to %s (yn)?'
524 '$$ &Yes $$ &No') % url):
524 '$$ &Yes $$ &No') % url):
525 return False
525 return False
526
526
527 return True
527 return True
528
528
529 _knownstatusnames = {'accepted', 'needsreview', 'needsrevision', 'closed',
529 _knownstatusnames = {'accepted', 'needsreview', 'needsrevision', 'closed',
530 'abandoned'}
530 'abandoned'}
531
531
532 def _getstatusname(drev):
532 def _getstatusname(drev):
533 """get normalized status name from a Differential Revision"""
533 """get normalized status name from a Differential Revision"""
534 return drev[r'statusName'].replace(' ', '').lower()
534 return drev[r'statusName'].replace(' ', '').lower()
535
535
536 # Small language to specify differential revisions. Support symbols: (), :X,
536 # Small language to specify differential revisions. Support symbols: (), :X,
537 # +, and -.
537 # +, and -.
538
538
539 _elements = {
539 _elements = {
540 # token-type: binding-strength, primary, prefix, infix, suffix
540 # token-type: binding-strength, primary, prefix, infix, suffix
541 '(': (12, None, ('group', 1, ')'), None, None),
541 '(': (12, None, ('group', 1, ')'), None, None),
542 ':': (8, None, ('ancestors', 8), None, None),
542 ':': (8, None, ('ancestors', 8), None, None),
543 '&': (5, None, None, ('and_', 5), None),
543 '&': (5, None, None, ('and_', 5), None),
544 '+': (4, None, None, ('add', 4), None),
544 '+': (4, None, None, ('add', 4), None),
545 '-': (4, None, None, ('sub', 4), None),
545 '-': (4, None, None, ('sub', 4), None),
546 ')': (0, None, None, None, None),
546 ')': (0, None, None, None, None),
547 'symbol': (0, 'symbol', None, None, None),
547 'symbol': (0, 'symbol', None, None, None),
548 'end': (0, None, None, None, None),
548 'end': (0, None, None, None, None),
549 }
549 }
550
550
551 def _tokenize(text):
551 def _tokenize(text):
552 view = memoryview(text) # zero-copy slice
552 view = memoryview(text) # zero-copy slice
553 special = '():+-& '
553 special = '():+-& '
554 pos = 0
554 pos = 0
555 length = len(text)
555 length = len(text)
556 while pos < length:
556 while pos < length:
557 symbol = ''.join(itertools.takewhile(lambda ch: ch not in special,
557 symbol = ''.join(itertools.takewhile(lambda ch: ch not in special,
558 view[pos:]))
558 view[pos:]))
559 if symbol:
559 if symbol:
560 yield ('symbol', symbol, pos)
560 yield ('symbol', symbol, pos)
561 pos += len(symbol)
561 pos += len(symbol)
562 else: # special char, ignore space
562 else: # special char, ignore space
563 if text[pos] != ' ':
563 if text[pos] != ' ':
564 yield (text[pos], None, pos)
564 yield (text[pos], None, pos)
565 pos += 1
565 pos += 1
566 yield ('end', None, pos)
566 yield ('end', None, pos)
567
567
568 def _parse(text):
568 def _parse(text):
569 tree, pos = parser.parser(_elements).parse(_tokenize(text))
569 tree, pos = parser.parser(_elements).parse(_tokenize(text))
570 if pos != len(text):
570 if pos != len(text):
571 raise error.ParseError('invalid token', pos)
571 raise error.ParseError('invalid token', pos)
572 return tree
572 return tree
573
573
574 def _parsedrev(symbol):
574 def _parsedrev(symbol):
575 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
575 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
576 if symbol.startswith('D') and symbol[1:].isdigit():
576 if symbol.startswith('D') and symbol[1:].isdigit():
577 return int(symbol[1:])
577 return int(symbol[1:])
578 if symbol.isdigit():
578 if symbol.isdigit():
579 return int(symbol)
579 return int(symbol)
580
580
581 def _prefetchdrevs(tree):
581 def _prefetchdrevs(tree):
582 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
582 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
583 drevs = set()
583 drevs = set()
584 ancestordrevs = set()
584 ancestordrevs = set()
585 op = tree[0]
585 op = tree[0]
586 if op == 'symbol':
586 if op == 'symbol':
587 r = _parsedrev(tree[1])
587 r = _parsedrev(tree[1])
588 if r:
588 if r:
589 drevs.add(r)
589 drevs.add(r)
590 elif op == 'ancestors':
590 elif op == 'ancestors':
591 r, a = _prefetchdrevs(tree[1])
591 r, a = _prefetchdrevs(tree[1])
592 drevs.update(r)
592 drevs.update(r)
593 ancestordrevs.update(r)
593 ancestordrevs.update(r)
594 ancestordrevs.update(a)
594 ancestordrevs.update(a)
595 else:
595 else:
596 for t in tree[1:]:
596 for t in tree[1:]:
597 r, a = _prefetchdrevs(t)
597 r, a = _prefetchdrevs(t)
598 drevs.update(r)
598 drevs.update(r)
599 ancestordrevs.update(a)
599 ancestordrevs.update(a)
600 return drevs, ancestordrevs
600 return drevs, ancestordrevs
601
601
602 def querydrev(repo, spec):
602 def querydrev(repo, spec):
603 """return a list of "Differential Revision" dicts
603 """return a list of "Differential Revision" dicts
604
604
605 spec is a string using a simple query language, see docstring in phabread
605 spec is a string using a simple query language, see docstring in phabread
606 for details.
606 for details.
607
607
608 A "Differential Revision dict" looks like:
608 A "Differential Revision dict" looks like:
609
609
610 {
610 {
611 "id": "2",
611 "id": "2",
612 "phid": "PHID-DREV-672qvysjcczopag46qty",
612 "phid": "PHID-DREV-672qvysjcczopag46qty",
613 "title": "example",
613 "title": "example",
614 "uri": "https://phab.example.com/D2",
614 "uri": "https://phab.example.com/D2",
615 "dateCreated": "1499181406",
615 "dateCreated": "1499181406",
616 "dateModified": "1499182103",
616 "dateModified": "1499182103",
617 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
617 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
618 "status": "0",
618 "status": "0",
619 "statusName": "Needs Review",
619 "statusName": "Needs Review",
620 "properties": [],
620 "properties": [],
621 "branch": null,
621 "branch": null,
622 "summary": "",
622 "summary": "",
623 "testPlan": "",
623 "testPlan": "",
624 "lineCount": "2",
624 "lineCount": "2",
625 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
625 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
626 "diffs": [
626 "diffs": [
627 "3",
627 "3",
628 "4",
628 "4",
629 ],
629 ],
630 "commits": [],
630 "commits": [],
631 "reviewers": [],
631 "reviewers": [],
632 "ccs": [],
632 "ccs": [],
633 "hashes": [],
633 "hashes": [],
634 "auxiliary": {
634 "auxiliary": {
635 "phabricator:projects": [],
635 "phabricator:projects": [],
636 "phabricator:depends-on": [
636 "phabricator:depends-on": [
637 "PHID-DREV-gbapp366kutjebt7agcd"
637 "PHID-DREV-gbapp366kutjebt7agcd"
638 ]
638 ]
639 },
639 },
640 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
640 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
641 "sourcePath": null
641 "sourcePath": null
642 }
642 }
643 """
643 """
644 def fetch(params):
644 def fetch(params):
645 """params -> single drev or None"""
645 """params -> single drev or None"""
646 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
646 key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
647 if key in prefetched:
647 if key in prefetched:
648 return prefetched[key]
648 return prefetched[key]
649 drevs = callconduit(repo, 'differential.query', params)
649 drevs = callconduit(repo, 'differential.query', params)
650 # Fill prefetched with the result
650 # Fill prefetched with the result
651 for drev in drevs:
651 for drev in drevs:
652 prefetched[drev[r'phid']] = drev
652 prefetched[drev[r'phid']] = drev
653 prefetched[int(drev[r'id'])] = drev
653 prefetched[int(drev[r'id'])] = drev
654 if key not in prefetched:
654 if key not in prefetched:
655 raise error.Abort(_('cannot get Differential Revision %r') % params)
655 raise error.Abort(_('cannot get Differential Revision %r') % params)
656 return prefetched[key]
656 return prefetched[key]
657
657
658 def getstack(topdrevids):
658 def getstack(topdrevids):
659 """given a top, get a stack from the bottom, [id] -> [id]"""
659 """given a top, get a stack from the bottom, [id] -> [id]"""
660 visited = set()
660 visited = set()
661 result = []
661 result = []
662 queue = [{r'ids': [i]} for i in topdrevids]
662 queue = [{r'ids': [i]} for i in topdrevids]
663 while queue:
663 while queue:
664 params = queue.pop()
664 params = queue.pop()
665 drev = fetch(params)
665 drev = fetch(params)
666 if drev[r'id'] in visited:
666 if drev[r'id'] in visited:
667 continue
667 continue
668 visited.add(drev[r'id'])
668 visited.add(drev[r'id'])
669 result.append(int(drev[r'id']))
669 result.append(int(drev[r'id']))
670 auxiliary = drev.get(r'auxiliary', {})
670 auxiliary = drev.get(r'auxiliary', {})
671 depends = auxiliary.get(r'phabricator:depends-on', [])
671 depends = auxiliary.get(r'phabricator:depends-on', [])
672 for phid in depends:
672 for phid in depends:
673 queue.append({'phids': [phid]})
673 queue.append({'phids': [phid]})
674 result.reverse()
674 result.reverse()
675 return smartset.baseset(result)
675 return smartset.baseset(result)
676
676
677 # Initialize prefetch cache
677 # Initialize prefetch cache
678 prefetched = {} # {id or phid: drev}
678 prefetched = {} # {id or phid: drev}
679
679
680 tree = _parse(spec)
680 tree = _parse(spec)
681 drevs, ancestordrevs = _prefetchdrevs(tree)
681 drevs, ancestordrevs = _prefetchdrevs(tree)
682
682
683 # developer config: phabricator.batchsize
683 # developer config: phabricator.batchsize
684 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
684 batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
685
685
686 # Prefetch Differential Revisions in batch
686 # Prefetch Differential Revisions in batch
687 tofetch = set(drevs)
687 tofetch = set(drevs)
688 for r in ancestordrevs:
688 for r in ancestordrevs:
689 tofetch.update(range(max(1, r - batchsize), r + 1))
689 tofetch.update(range(max(1, r - batchsize), r + 1))
690 if drevs:
690 if drevs:
691 fetch({r'ids': list(tofetch)})
691 fetch({r'ids': list(tofetch)})
692 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
692 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
693
693
694 # Walk through the tree, return smartsets
694 # Walk through the tree, return smartsets
695 def walk(tree):
695 def walk(tree):
696 op = tree[0]
696 op = tree[0]
697 if op == 'symbol':
697 if op == 'symbol':
698 drev = _parsedrev(tree[1])
698 drev = _parsedrev(tree[1])
699 if drev:
699 if drev:
700 return smartset.baseset([drev])
700 return smartset.baseset([drev])
701 elif tree[1] in _knownstatusnames:
701 elif tree[1] in _knownstatusnames:
702 drevs = [r for r in validids
702 drevs = [r for r in validids
703 if _getstatusname(prefetched[r]) == tree[1]]
703 if _getstatusname(prefetched[r]) == tree[1]]
704 return smartset.baseset(drevs)
704 return smartset.baseset(drevs)
705 else:
705 else:
706 raise error.Abort(_('unknown symbol: %s') % tree[1])
706 raise error.Abort(_('unknown symbol: %s') % tree[1])
707 elif op in {'and_', 'add', 'sub'}:
707 elif op in {'and_', 'add', 'sub'}:
708 assert len(tree) == 3
708 assert len(tree) == 3
709 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
709 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
710 elif op == 'group':
710 elif op == 'group':
711 return walk(tree[1])
711 return walk(tree[1])
712 elif op == 'ancestors':
712 elif op == 'ancestors':
713 return getstack(walk(tree[1]))
713 return getstack(walk(tree[1]))
714 else:
714 else:
715 raise error.ProgrammingError('illegal tree: %r' % tree)
715 raise error.ProgrammingError('illegal tree: %r' % tree)
716
716
717 return [prefetched[r] for r in walk(tree)]
717 return [prefetched[r] for r in walk(tree)]
718
718
719 def getdescfromdrev(drev):
719 def getdescfromdrev(drev):
720 """get description (commit message) from "Differential Revision"
720 """get description (commit message) from "Differential Revision"
721
721
722 This is similar to differential.getcommitmessage API. But we only care
722 This is similar to differential.getcommitmessage API. But we only care
723 about limited fields: title, summary, test plan, and URL.
723 about limited fields: title, summary, test plan, and URL.
724 """
724 """
725 title = drev[r'title']
725 title = drev[r'title']
726 summary = drev[r'summary'].rstrip()
726 summary = drev[r'summary'].rstrip()
727 testplan = drev[r'testPlan'].rstrip()
727 testplan = drev[r'testPlan'].rstrip()
728 if testplan:
728 if testplan:
729 testplan = 'Test Plan:\n%s' % testplan
729 testplan = 'Test Plan:\n%s' % testplan
730 uri = 'Differential Revision: %s' % drev[r'uri']
730 uri = 'Differential Revision: %s' % drev[r'uri']
731 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
731 return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
732
732
733 def getdiffmeta(diff):
733 def getdiffmeta(diff):
734 """get commit metadata (date, node, user, p1) from a diff object
734 """get commit metadata (date, node, user, p1) from a diff object
735
735
736 The metadata could be "hg:meta", sent by phabsend, like:
736 The metadata could be "hg:meta", sent by phabsend, like:
737
737
738 "properties": {
738 "properties": {
739 "hg:meta": {
739 "hg:meta": {
740 "date": "1499571514 25200",
740 "date": "1499571514 25200",
741 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
741 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
742 "user": "Foo Bar <foo@example.com>",
742 "user": "Foo Bar <foo@example.com>",
743 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
743 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
744 }
744 }
745 }
745 }
746
746
747 Or converted from "local:commits", sent by "arc", like:
747 Or converted from "local:commits", sent by "arc", like:
748
748
749 "properties": {
749 "properties": {
750 "local:commits": {
750 "local:commits": {
751 "98c08acae292b2faf60a279b4189beb6cff1414d": {
751 "98c08acae292b2faf60a279b4189beb6cff1414d": {
752 "author": "Foo Bar",
752 "author": "Foo Bar",
753 "time": 1499546314,
753 "time": 1499546314,
754 "branch": "default",
754 "branch": "default",
755 "tag": "",
755 "tag": "",
756 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
756 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
757 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
757 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
758 "local": "1000",
758 "local": "1000",
759 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
759 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
760 "summary": "...",
760 "summary": "...",
761 "message": "...",
761 "message": "...",
762 "authorEmail": "foo@example.com"
762 "authorEmail": "foo@example.com"
763 }
763 }
764 }
764 }
765 }
765 }
766
766
767 Note: metadata extracted from "local:commits" will lose time zone
767 Note: metadata extracted from "local:commits" will lose time zone
768 information.
768 information.
769 """
769 """
770 props = diff.get(r'properties') or {}
770 props = diff.get(r'properties') or {}
771 meta = props.get(r'hg:meta')
771 meta = props.get(r'hg:meta')
772 if not meta and props.get(r'local:commits'):
772 if not meta and props.get(r'local:commits'):
773 commit = sorted(props[r'local:commits'].values())[0]
773 commit = sorted(props[r'local:commits'].values())[0]
774 meta = {
774 meta = {
775 r'date': r'%d 0' % commit[r'time'],
775 r'date': r'%d 0' % commit[r'time'],
776 r'node': commit[r'rev'],
776 r'node': commit[r'rev'],
777 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
777 r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
778 }
778 }
779 if len(commit.get(r'parents', ())) >= 1:
779 if len(commit.get(r'parents', ())) >= 1:
780 meta[r'parent'] = commit[r'parents'][0]
780 meta[r'parent'] = commit[r'parents'][0]
781 return meta or {}
781 return meta or {}
782
782
783 def readpatch(repo, drevs, write):
783 def readpatch(repo, drevs, write):
784 """generate plain-text patch readable by 'hg import'
784 """generate plain-text patch readable by 'hg import'
785
785
786 write is usually ui.write. drevs is what "querydrev" returns, results of
786 write is usually ui.write. drevs is what "querydrev" returns, results of
787 "differential.query".
787 "differential.query".
788 """
788 """
789 # Prefetch hg:meta property for all diffs
789 # Prefetch hg:meta property for all diffs
790 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
790 diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
791 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
791 diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
792
792
793 # Generate patch for each drev
793 # Generate patch for each drev
794 for drev in drevs:
794 for drev in drevs:
795 repo.ui.note(_('reading D%s\n') % drev[r'id'])
795 repo.ui.note(_('reading D%s\n') % drev[r'id'])
796
796
797 diffid = max(int(v) for v in drev[r'diffs'])
797 diffid = max(int(v) for v in drev[r'diffs'])
798 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
798 body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
799 desc = getdescfromdrev(drev)
799 desc = getdescfromdrev(drev)
800 header = '# HG changeset patch\n'
800 header = '# HG changeset patch\n'
801
801
802 # Try to preserve metadata from hg:meta property. Write hg patch
802 # Try to preserve metadata from hg:meta property. Write hg patch
803 # headers that can be read by the "import" command. See patchheadermap
803 # headers that can be read by the "import" command. See patchheadermap
804 # and extract in mercurial/patch.py for supported headers.
804 # and extract in mercurial/patch.py for supported headers.
805 meta = getdiffmeta(diffs[str(diffid)])
805 meta = getdiffmeta(diffs[str(diffid)])
806 for k in _metanamemap.keys():
806 for k in _metanamemap.keys():
807 if k in meta:
807 if k in meta:
808 header += '# %s %s\n' % (_metanamemap[k], meta[k])
808 header += '# %s %s\n' % (_metanamemap[k], meta[k])
809
809
810 content = '%s%s\n%s' % (header, desc, body)
810 content = '%s%s\n%s' % (header, desc, body)
811 write(encoding.unitolocal(content))
811 write(encoding.unitolocal(content))
812
812
813 @command('phabread',
813 @command('phabread',
814 [('', 'stack', False, _('read dependencies'))],
814 [('', 'stack', False, _('read dependencies'))],
815 _('DREVSPEC [OPTIONS]'))
815 _('DREVSPEC [OPTIONS]'))
816 def phabread(ui, repo, spec, **opts):
816 def phabread(ui, repo, spec, **opts):
817 """print patches from Phabricator suitable for importing
817 """print patches from Phabricator suitable for importing
818
818
819 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
819 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
820 the number ``123``. It could also have common operators like ``+``, ``-``,
820 the number ``123``. It could also have common operators like ``+``, ``-``,
821 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
821 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
822 select a stack.
822 select a stack.
823
823
824 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
824 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
825 could be used to filter patches by status. For performance reason, they
825 could be used to filter patches by status. For performance reason, they
826 only represent a subset of non-status selections and cannot be used alone.
826 only represent a subset of non-status selections and cannot be used alone.
827
827
828 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
828 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
829 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
829 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
830 stack up to D9.
830 stack up to D9.
831
831
832 If --stack is given, follow dependencies information and read all patches.
832 If --stack is given, follow dependencies information and read all patches.
833 It is equivalent to the ``:`` operator.
833 It is equivalent to the ``:`` operator.
834 """
834 """
835 if opts.get('stack'):
835 if opts.get('stack'):
836 spec = ':(%s)' % spec
836 spec = ':(%s)' % spec
837 drevs = querydrev(repo, spec)
837 drevs = querydrev(repo, spec)
838 readpatch(repo, drevs, ui.write)
838 readpatch(repo, drevs, ui.write)
839
839
840 @command('phabupdate',
840 @command('phabupdate',
841 [('', 'accept', False, _('accept revisions')),
841 [('', 'accept', False, _('accept revisions')),
842 ('', 'reject', False, _('reject revisions')),
842 ('', 'reject', False, _('reject revisions')),
843 ('', 'abandon', False, _('abandon revisions')),
843 ('', 'abandon', False, _('abandon revisions')),
844 ('', 'reclaim', False, _('reclaim revisions')),
844 ('', 'reclaim', False, _('reclaim revisions')),
845 ('m', 'comment', '', _('comment on the last revision')),
845 ('m', 'comment', '', _('comment on the last revision')),
846 ], _('DREVSPEC [OPTIONS]'))
846 ], _('DREVSPEC [OPTIONS]'))
847 def phabupdate(ui, repo, spec, **opts):
847 def phabupdate(ui, repo, spec, **opts):
848 """update Differential Revision in batch
848 """update Differential Revision in batch
849
849
850 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
850 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
851 """
851 """
852 flags = [n for n in 'accept reject abandon reclaim'.split() if opts.get(n)]
852 flags = [n for n in 'accept reject abandon reclaim'.split() if opts.get(n)]
853 if len(flags) > 1:
853 if len(flags) > 1:
854 raise error.Abort(_('%s cannot be used together') % ', '.join(flags))
854 raise error.Abort(_('%s cannot be used together') % ', '.join(flags))
855
855
856 actions = []
856 actions = []
857 for f in flags:
857 for f in flags:
858 actions.append({'type': f, 'value': 'true'})
858 actions.append({'type': f, 'value': 'true'})
859
859
860 drevs = querydrev(repo, spec)
860 drevs = querydrev(repo, spec)
861 for i, drev in enumerate(drevs):
861 for i, drev in enumerate(drevs):
862 if i + 1 == len(drevs) and opts.get('comment'):
862 if i + 1 == len(drevs) and opts.get('comment'):
863 actions.append({'type': 'comment', 'value': opts['comment']})
863 actions.append({'type': 'comment', 'value': opts['comment']})
864 if actions:
864 if actions:
865 params = {'objectIdentifier': drev[r'phid'],
865 params = {'objectIdentifier': drev[r'phid'],
866 'transactions': actions}
866 'transactions': actions}
867 callconduit(repo, 'differential.revision.edit', params)
867 callconduit(repo, 'differential.revision.edit', params)
868
869 templatekeyword = registrar.templatekeyword()
870
871 @templatekeyword('phabreview')
872 def template_review(repo, ctx, revcache, **args):
873 """:phabreview: Object describing the review for this changeset.
874 Has attributes `url` and `id`.
875 """
876 m = _differentialrevisiondescre.search(ctx.description())
877 if m:
878 return {
879 'url': m.group('url'),
880 'id': "D{}".format(m.group('id')),
881 }
General Comments 0
You need to be logged in to leave comments. Login now