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