##// END OF EJS Templates
phabricator: auto-sanitise API tokens and HTTP cookies from VCR recordings...
Ian Moody -
r42636:d3c81439 default
parent child Browse files
Show More
@@ -1,1067 +1,1082 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 (EXPERIMENTAL)
7 """simple Phabricator integration (EXPERIMENTAL)
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 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
24 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
25 # callsign is "FOO".
25 # callsign is "FOO".
26 callsign = FOO
26 callsign = FOO
27
27
28 # curl command to use. If not set (default), use builtin HTTP library to
28 # curl command to use. If not set (default), use builtin HTTP library to
29 # communicate. If set, use the specified curl command. This could be useful
29 # communicate. If set, use the specified curl command. This could be useful
30 # if you need to specify advanced options that is not easily supported by
30 # if you need to specify advanced options that is not easily supported by
31 # the internal library.
31 # the internal library.
32 curlcmd = curl --connect-timeout 2 --retry 3 --silent
32 curlcmd = curl --connect-timeout 2 --retry 3 --silent
33
33
34 [auth]
34 [auth]
35 example.schemes = https
35 example.schemes = https
36 example.prefix = phab.example.com
36 example.prefix = phab.example.com
37
37
38 # API token. Get it from https://$HOST/conduit/login/
38 # API token. Get it from https://$HOST/conduit/login/
39 example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
39 example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
40 """
40 """
41
41
42 from __future__ import absolute_import
42 from __future__ import absolute_import
43
43
44 import contextlib
44 import contextlib
45 import itertools
45 import itertools
46 import json
46 import json
47 import operator
47 import operator
48 import re
48 import re
49
49
50 from mercurial.node import bin, nullid
50 from mercurial.node import bin, nullid
51 from mercurial.i18n import _
51 from mercurial.i18n import _
52 from mercurial import (
52 from mercurial import (
53 cmdutil,
53 cmdutil,
54 context,
54 context,
55 encoding,
55 encoding,
56 error,
56 error,
57 httpconnection as httpconnectionmod,
57 httpconnection as httpconnectionmod,
58 mdiff,
58 mdiff,
59 obsutil,
59 obsutil,
60 parser,
60 parser,
61 patch,
61 patch,
62 phases,
62 phases,
63 pycompat,
63 pycompat,
64 registrar,
64 registrar,
65 scmutil,
65 scmutil,
66 smartset,
66 smartset,
67 tags,
67 tags,
68 templatefilters,
68 templatefilters,
69 templateutil,
69 templateutil,
70 url as urlmod,
70 url as urlmod,
71 util,
71 util,
72 )
72 )
73 from mercurial.utils import (
73 from mercurial.utils import (
74 procutil,
74 procutil,
75 stringutil,
75 stringutil,
76 )
76 )
77
77
78 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
78 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
79 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
79 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
80 # be specifying the version(s) of Mercurial they are tested with, or
80 # be specifying the version(s) of Mercurial they are tested with, or
81 # leave the attribute unspecified.
81 # leave the attribute unspecified.
82 testedwith = 'ships-with-hg-core'
82 testedwith = 'ships-with-hg-core'
83
83
84 cmdtable = {}
84 cmdtable = {}
85 command = registrar.command(cmdtable)
85 command = registrar.command(cmdtable)
86
86
87 configtable = {}
87 configtable = {}
88 configitem = registrar.configitem(configtable)
88 configitem = registrar.configitem(configtable)
89
89
90 # developer config: phabricator.batchsize
90 # developer config: phabricator.batchsize
91 configitem(b'phabricator', b'batchsize',
91 configitem(b'phabricator', b'batchsize',
92 default=12,
92 default=12,
93 )
93 )
94 configitem(b'phabricator', b'callsign',
94 configitem(b'phabricator', b'callsign',
95 default=None,
95 default=None,
96 )
96 )
97 configitem(b'phabricator', b'curlcmd',
97 configitem(b'phabricator', b'curlcmd',
98 default=None,
98 default=None,
99 )
99 )
100 # developer config: phabricator.repophid
100 # developer config: phabricator.repophid
101 configitem(b'phabricator', b'repophid',
101 configitem(b'phabricator', b'repophid',
102 default=None,
102 default=None,
103 )
103 )
104 configitem(b'phabricator', b'url',
104 configitem(b'phabricator', b'url',
105 default=None,
105 default=None,
106 )
106 )
107 configitem(b'phabsend', b'confirm',
107 configitem(b'phabsend', b'confirm',
108 default=False,
108 default=False,
109 )
109 )
110
110
111 colortable = {
111 colortable = {
112 b'phabricator.action.created': b'green',
112 b'phabricator.action.created': b'green',
113 b'phabricator.action.skipped': b'magenta',
113 b'phabricator.action.skipped': b'magenta',
114 b'phabricator.action.updated': b'magenta',
114 b'phabricator.action.updated': b'magenta',
115 b'phabricator.desc': b'',
115 b'phabricator.desc': b'',
116 b'phabricator.drev': b'bold',
116 b'phabricator.drev': b'bold',
117 b'phabricator.node': b'',
117 b'phabricator.node': b'',
118 }
118 }
119
119
120 _VCR_FLAGS = [
120 _VCR_FLAGS = [
121 (b'', b'test-vcr', b'',
121 (b'', b'test-vcr', b'',
122 _(b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
122 _(b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
123 b', otherwise will mock all http requests using the specified vcr file.'
123 b', otherwise will mock all http requests using the specified vcr file.'
124 b' (ADVANCED)'
124 b' (ADVANCED)'
125 )),
125 )),
126 ]
126 ]
127
127
128 def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
128 def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
129 fullflags = flags + _VCR_FLAGS
129 fullflags = flags + _VCR_FLAGS
130 def hgmatcher(r1, r2):
130 def hgmatcher(r1, r2):
131 if r1.uri != r2.uri or r1.method != r2.method:
131 if r1.uri != r2.uri or r1.method != r2.method:
132 return False
132 return False
133 r1params = r1.body.split(b'&')
133 r1params = r1.body.split(b'&')
134 r2params = r2.body.split(b'&')
134 r2params = r2.body.split(b'&')
135 return set(r1params) == set(r2params)
135 return set(r1params) == set(r2params)
136
136
137 def sanitiserequest(request):
138 request.body = re.sub(
139 r'cli-[a-z0-9]+',
140 r'cli-hahayouwish',
141 request.body
142 )
143 return request
144
145 def sanitiseresponse(response):
146 if r'set-cookie' in response[r'headers']:
147 del response[r'headers'][r'set-cookie']
148 return response
149
137 def decorate(fn):
150 def decorate(fn):
138 def inner(*args, **kwargs):
151 def inner(*args, **kwargs):
139 cassette = pycompat.fsdecode(kwargs.pop(r'test_vcr', None))
152 cassette = pycompat.fsdecode(kwargs.pop(r'test_vcr', None))
140 if cassette:
153 if cassette:
141 import hgdemandimport
154 import hgdemandimport
142 with hgdemandimport.deactivated():
155 with hgdemandimport.deactivated():
143 import vcr as vcrmod
156 import vcr as vcrmod
144 import vcr.stubs as stubs
157 import vcr.stubs as stubs
145 vcr = vcrmod.VCR(
158 vcr = vcrmod.VCR(
146 serializer=r'json',
159 serializer=r'json',
160 before_record_request=sanitiserequest,
161 before_record_response=sanitiseresponse,
147 custom_patches=[
162 custom_patches=[
148 (urlmod, r'httpconnection',
163 (urlmod, r'httpconnection',
149 stubs.VCRHTTPConnection),
164 stubs.VCRHTTPConnection),
150 (urlmod, r'httpsconnection',
165 (urlmod, r'httpsconnection',
151 stubs.VCRHTTPSConnection),
166 stubs.VCRHTTPSConnection),
152 ])
167 ])
153 vcr.register_matcher(r'hgmatcher', hgmatcher)
168 vcr.register_matcher(r'hgmatcher', hgmatcher)
154 with vcr.use_cassette(cassette, match_on=[r'hgmatcher']):
169 with vcr.use_cassette(cassette, match_on=[r'hgmatcher']):
155 return fn(*args, **kwargs)
170 return fn(*args, **kwargs)
156 return fn(*args, **kwargs)
171 return fn(*args, **kwargs)
157 inner.__name__ = fn.__name__
172 inner.__name__ = fn.__name__
158 inner.__doc__ = fn.__doc__
173 inner.__doc__ = fn.__doc__
159 return command(name, fullflags, spec, helpcategory=helpcategory,
174 return command(name, fullflags, spec, helpcategory=helpcategory,
160 optionalrepo=optionalrepo)(inner)
175 optionalrepo=optionalrepo)(inner)
161 return decorate
176 return decorate
162
177
163 def urlencodenested(params):
178 def urlencodenested(params):
164 """like urlencode, but works with nested parameters.
179 """like urlencode, but works with nested parameters.
165
180
166 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
181 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
167 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
182 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
168 urlencode. Note: the encoding is consistent with PHP's http_build_query.
183 urlencode. Note: the encoding is consistent with PHP's http_build_query.
169 """
184 """
170 flatparams = util.sortdict()
185 flatparams = util.sortdict()
171 def process(prefix, obj):
186 def process(prefix, obj):
172 if isinstance(obj, bool):
187 if isinstance(obj, bool):
173 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
188 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
174 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
189 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
175 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
190 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
176 if items is None:
191 if items is None:
177 flatparams[prefix] = obj
192 flatparams[prefix] = obj
178 else:
193 else:
179 for k, v in items(obj):
194 for k, v in items(obj):
180 if prefix:
195 if prefix:
181 process(b'%s[%s]' % (prefix, k), v)
196 process(b'%s[%s]' % (prefix, k), v)
182 else:
197 else:
183 process(k, v)
198 process(k, v)
184 process(b'', params)
199 process(b'', params)
185 return util.urlreq.urlencode(flatparams)
200 return util.urlreq.urlencode(flatparams)
186
201
187 def readurltoken(ui):
202 def readurltoken(ui):
188 """return conduit url, token and make sure they exist
203 """return conduit url, token and make sure they exist
189
204
190 Currently read from [auth] config section. In the future, it might
205 Currently read from [auth] config section. In the future, it might
191 make sense to read from .arcconfig and .arcrc as well.
206 make sense to read from .arcconfig and .arcrc as well.
192 """
207 """
193 url = ui.config(b'phabricator', b'url')
208 url = ui.config(b'phabricator', b'url')
194 if not url:
209 if not url:
195 raise error.Abort(_(b'config %s.%s is required')
210 raise error.Abort(_(b'config %s.%s is required')
196 % (b'phabricator', b'url'))
211 % (b'phabricator', b'url'))
197
212
198 res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user)
213 res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user)
199 token = None
214 token = None
200
215
201 if res:
216 if res:
202 group, auth = res
217 group, auth = res
203
218
204 ui.debug(b"using auth.%s.* for authentication\n" % group)
219 ui.debug(b"using auth.%s.* for authentication\n" % group)
205
220
206 token = auth.get(b'phabtoken')
221 token = auth.get(b'phabtoken')
207
222
208 if not token:
223 if not token:
209 raise error.Abort(_(b'Can\'t find conduit token associated to %s')
224 raise error.Abort(_(b'Can\'t find conduit token associated to %s')
210 % (url,))
225 % (url,))
211
226
212 return url, token
227 return url, token
213
228
214 def callconduit(ui, name, params):
229 def callconduit(ui, name, params):
215 """call Conduit API, params is a dict. return json.loads result, or None"""
230 """call Conduit API, params is a dict. return json.loads result, or None"""
216 host, token = readurltoken(ui)
231 host, token = readurltoken(ui)
217 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
232 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
218 ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
233 ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
219 params = params.copy()
234 params = params.copy()
220 params[b'api.token'] = token
235 params[b'api.token'] = token
221 data = urlencodenested(params)
236 data = urlencodenested(params)
222 curlcmd = ui.config(b'phabricator', b'curlcmd')
237 curlcmd = ui.config(b'phabricator', b'curlcmd')
223 if curlcmd:
238 if curlcmd:
224 sin, sout = procutil.popen2(b'%s -d @- %s'
239 sin, sout = procutil.popen2(b'%s -d @- %s'
225 % (curlcmd, procutil.shellquote(url)))
240 % (curlcmd, procutil.shellquote(url)))
226 sin.write(data)
241 sin.write(data)
227 sin.close()
242 sin.close()
228 body = sout.read()
243 body = sout.read()
229 else:
244 else:
230 urlopener = urlmod.opener(ui, authinfo)
245 urlopener = urlmod.opener(ui, authinfo)
231 request = util.urlreq.request(pycompat.strurl(url), data=data)
246 request = util.urlreq.request(pycompat.strurl(url), data=data)
232 with contextlib.closing(urlopener.open(request)) as rsp:
247 with contextlib.closing(urlopener.open(request)) as rsp:
233 body = rsp.read()
248 body = rsp.read()
234 ui.debug(b'Conduit Response: %s\n' % body)
249 ui.debug(b'Conduit Response: %s\n' % body)
235 parsed = pycompat.rapply(
250 parsed = pycompat.rapply(
236 lambda x: encoding.unitolocal(x) if isinstance(x, pycompat.unicode)
251 lambda x: encoding.unitolocal(x) if isinstance(x, pycompat.unicode)
237 else x,
252 else x,
238 json.loads(body)
253 json.loads(body)
239 )
254 )
240 if parsed.get(b'error_code'):
255 if parsed.get(b'error_code'):
241 msg = (_(b'Conduit Error (%s): %s')
256 msg = (_(b'Conduit Error (%s): %s')
242 % (parsed[b'error_code'], parsed[b'error_info']))
257 % (parsed[b'error_code'], parsed[b'error_info']))
243 raise error.Abort(msg)
258 raise error.Abort(msg)
244 return parsed[b'result']
259 return parsed[b'result']
245
260
246 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
261 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
247 def debugcallconduit(ui, repo, name):
262 def debugcallconduit(ui, repo, name):
248 """call Conduit API
263 """call Conduit API
249
264
250 Call parameters are read from stdin as a JSON blob. Result will be written
265 Call parameters are read from stdin as a JSON blob. Result will be written
251 to stdout as a JSON blob.
266 to stdout as a JSON blob.
252 """
267 """
253 # json.loads only accepts bytes from 3.6+
268 # json.loads only accepts bytes from 3.6+
254 rawparams = encoding.unifromlocal(ui.fin.read())
269 rawparams = encoding.unifromlocal(ui.fin.read())
255 # json.loads only returns unicode strings
270 # json.loads only returns unicode strings
256 params = pycompat.rapply(lambda x:
271 params = pycompat.rapply(lambda x:
257 encoding.unitolocal(x) if isinstance(x, pycompat.unicode) else x,
272 encoding.unitolocal(x) if isinstance(x, pycompat.unicode) else x,
258 json.loads(rawparams)
273 json.loads(rawparams)
259 )
274 )
260 # json.dumps only accepts unicode strings
275 # json.dumps only accepts unicode strings
261 result = pycompat.rapply(lambda x:
276 result = pycompat.rapply(lambda x:
262 encoding.unifromlocal(x) if isinstance(x, bytes) else x,
277 encoding.unifromlocal(x) if isinstance(x, bytes) else x,
263 callconduit(ui, name, params)
278 callconduit(ui, name, params)
264 )
279 )
265 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
280 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
266 ui.write(b'%s\n' % encoding.unitolocal(s))
281 ui.write(b'%s\n' % encoding.unitolocal(s))
267
282
268 def getrepophid(repo):
283 def getrepophid(repo):
269 """given callsign, return repository PHID or None"""
284 """given callsign, return repository PHID or None"""
270 # developer config: phabricator.repophid
285 # developer config: phabricator.repophid
271 repophid = repo.ui.config(b'phabricator', b'repophid')
286 repophid = repo.ui.config(b'phabricator', b'repophid')
272 if repophid:
287 if repophid:
273 return repophid
288 return repophid
274 callsign = repo.ui.config(b'phabricator', b'callsign')
289 callsign = repo.ui.config(b'phabricator', b'callsign')
275 if not callsign:
290 if not callsign:
276 return None
291 return None
277 query = callconduit(repo.ui, b'diffusion.repository.search',
292 query = callconduit(repo.ui, b'diffusion.repository.search',
278 {b'constraints': {b'callsigns': [callsign]}})
293 {b'constraints': {b'callsigns': [callsign]}})
279 if len(query[b'data']) == 0:
294 if len(query[b'data']) == 0:
280 return None
295 return None
281 repophid = query[b'data'][0][b'phid']
296 repophid = query[b'data'][0][b'phid']
282 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
297 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
283 return repophid
298 return repophid
284
299
285 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
300 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
286 _differentialrevisiondescre = re.compile(
301 _differentialrevisiondescre = re.compile(
287 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M)
302 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M)
288
303
289 def getoldnodedrevmap(repo, nodelist):
304 def getoldnodedrevmap(repo, nodelist):
290 """find previous nodes that has been sent to Phabricator
305 """find previous nodes that has been sent to Phabricator
291
306
292 return {node: (oldnode, Differential diff, Differential Revision ID)}
307 return {node: (oldnode, Differential diff, Differential Revision ID)}
293 for node in nodelist with known previous sent versions, or associated
308 for node in nodelist with known previous sent versions, or associated
294 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
309 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
295 be ``None``.
310 be ``None``.
296
311
297 Examines commit messages like "Differential Revision:" to get the
312 Examines commit messages like "Differential Revision:" to get the
298 association information.
313 association information.
299
314
300 If such commit message line is not found, examines all precursors and their
315 If such commit message line is not found, examines all precursors and their
301 tags. Tags with format like "D1234" are considered a match and the node
316 tags. Tags with format like "D1234" are considered a match and the node
302 with that tag, and the number after "D" (ex. 1234) will be returned.
317 with that tag, and the number after "D" (ex. 1234) will be returned.
303
318
304 The ``old node``, if not None, is guaranteed to be the last diff of
319 The ``old node``, if not None, is guaranteed to be the last diff of
305 corresponding Differential Revision, and exist in the repo.
320 corresponding Differential Revision, and exist in the repo.
306 """
321 """
307 unfi = repo.unfiltered()
322 unfi = repo.unfiltered()
308 nodemap = unfi.changelog.nodemap
323 nodemap = unfi.changelog.nodemap
309
324
310 result = {} # {node: (oldnode?, lastdiff?, drev)}
325 result = {} # {node: (oldnode?, lastdiff?, drev)}
311 toconfirm = {} # {node: (force, {precnode}, drev)}
326 toconfirm = {} # {node: (force, {precnode}, drev)}
312 for node in nodelist:
327 for node in nodelist:
313 ctx = unfi[node]
328 ctx = unfi[node]
314 # For tags like "D123", put them into "toconfirm" to verify later
329 # For tags like "D123", put them into "toconfirm" to verify later
315 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
330 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
316 for n in precnodes:
331 for n in precnodes:
317 if n in nodemap:
332 if n in nodemap:
318 for tag in unfi.nodetags(n):
333 for tag in unfi.nodetags(n):
319 m = _differentialrevisiontagre.match(tag)
334 m = _differentialrevisiontagre.match(tag)
320 if m:
335 if m:
321 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
336 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
322 continue
337 continue
323
338
324 # Check commit message
339 # Check commit message
325 m = _differentialrevisiondescre.search(ctx.description())
340 m = _differentialrevisiondescre.search(ctx.description())
326 if m:
341 if m:
327 toconfirm[node] = (1, set(precnodes), int(m.group(r'id')))
342 toconfirm[node] = (1, set(precnodes), int(m.group(r'id')))
328
343
329 # Double check if tags are genuine by collecting all old nodes from
344 # Double check if tags are genuine by collecting all old nodes from
330 # Phabricator, and expect precursors overlap with it.
345 # Phabricator, and expect precursors overlap with it.
331 if toconfirm:
346 if toconfirm:
332 drevs = [drev for force, precs, drev in toconfirm.values()]
347 drevs = [drev for force, precs, drev in toconfirm.values()]
333 alldiffs = callconduit(unfi.ui, b'differential.querydiffs',
348 alldiffs = callconduit(unfi.ui, b'differential.querydiffs',
334 {b'revisionIDs': drevs})
349 {b'revisionIDs': drevs})
335 getnode = lambda d: bin(
350 getnode = lambda d: bin(
336 getdiffmeta(d).get(b'node', b'')) or None
351 getdiffmeta(d).get(b'node', b'')) or None
337 for newnode, (force, precset, drev) in toconfirm.items():
352 for newnode, (force, precset, drev) in toconfirm.items():
338 diffs = [d for d in alldiffs.values()
353 diffs = [d for d in alldiffs.values()
339 if int(d[b'revisionID']) == drev]
354 if int(d[b'revisionID']) == drev]
340
355
341 # "precursors" as known by Phabricator
356 # "precursors" as known by Phabricator
342 phprecset = set(getnode(d) for d in diffs)
357 phprecset = set(getnode(d) for d in diffs)
343
358
344 # Ignore if precursors (Phabricator and local repo) do not overlap,
359 # Ignore if precursors (Phabricator and local repo) do not overlap,
345 # and force is not set (when commit message says nothing)
360 # and force is not set (when commit message says nothing)
346 if not force and not bool(phprecset & precset):
361 if not force and not bool(phprecset & precset):
347 tagname = b'D%d' % drev
362 tagname = b'D%d' % drev
348 tags.tag(repo, tagname, nullid, message=None, user=None,
363 tags.tag(repo, tagname, nullid, message=None, user=None,
349 date=None, local=True)
364 date=None, local=True)
350 unfi.ui.warn(_(b'D%s: local tag removed - does not match '
365 unfi.ui.warn(_(b'D%s: local tag removed - does not match '
351 b'Differential history\n') % drev)
366 b'Differential history\n') % drev)
352 continue
367 continue
353
368
354 # Find the last node using Phabricator metadata, and make sure it
369 # Find the last node using Phabricator metadata, and make sure it
355 # exists in the repo
370 # exists in the repo
356 oldnode = lastdiff = None
371 oldnode = lastdiff = None
357 if diffs:
372 if diffs:
358 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
373 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
359 oldnode = getnode(lastdiff)
374 oldnode = getnode(lastdiff)
360 if oldnode and oldnode not in nodemap:
375 if oldnode and oldnode not in nodemap:
361 oldnode = None
376 oldnode = None
362
377
363 result[newnode] = (oldnode, lastdiff, drev)
378 result[newnode] = (oldnode, lastdiff, drev)
364
379
365 return result
380 return result
366
381
367 def getdiff(ctx, diffopts):
382 def getdiff(ctx, diffopts):
368 """plain-text diff without header (user, commit message, etc)"""
383 """plain-text diff without header (user, commit message, etc)"""
369 output = util.stringio()
384 output = util.stringio()
370 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
385 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
371 None, opts=diffopts):
386 None, opts=diffopts):
372 output.write(chunk)
387 output.write(chunk)
373 return output.getvalue()
388 return output.getvalue()
374
389
375 def creatediff(ctx):
390 def creatediff(ctx):
376 """create a Differential Diff"""
391 """create a Differential Diff"""
377 repo = ctx.repo()
392 repo = ctx.repo()
378 repophid = getrepophid(repo)
393 repophid = getrepophid(repo)
379 # Create a "Differential Diff" via "differential.createrawdiff" API
394 # Create a "Differential Diff" via "differential.createrawdiff" API
380 params = {b'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
395 params = {b'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
381 if repophid:
396 if repophid:
382 params[b'repositoryPHID'] = repophid
397 params[b'repositoryPHID'] = repophid
383 diff = callconduit(repo.ui, b'differential.createrawdiff', params)
398 diff = callconduit(repo.ui, b'differential.createrawdiff', params)
384 if not diff:
399 if not diff:
385 raise error.Abort(_(b'cannot create diff for %s') % ctx)
400 raise error.Abort(_(b'cannot create diff for %s') % ctx)
386 return diff
401 return diff
387
402
388 def writediffproperties(ctx, diff):
403 def writediffproperties(ctx, diff):
389 """write metadata to diff so patches could be applied losslessly"""
404 """write metadata to diff so patches could be applied losslessly"""
390 params = {
405 params = {
391 b'diff_id': diff[b'id'],
406 b'diff_id': diff[b'id'],
392 b'name': b'hg:meta',
407 b'name': b'hg:meta',
393 b'data': templatefilters.json({
408 b'data': templatefilters.json({
394 b'user': ctx.user(),
409 b'user': ctx.user(),
395 b'date': b'%d %d' % ctx.date(),
410 b'date': b'%d %d' % ctx.date(),
396 b'branch': ctx.branch(),
411 b'branch': ctx.branch(),
397 b'node': ctx.hex(),
412 b'node': ctx.hex(),
398 b'parent': ctx.p1().hex(),
413 b'parent': ctx.p1().hex(),
399 }),
414 }),
400 }
415 }
401 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
416 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
402
417
403 params = {
418 params = {
404 b'diff_id': diff[b'id'],
419 b'diff_id': diff[b'id'],
405 b'name': b'local:commits',
420 b'name': b'local:commits',
406 b'data': templatefilters.json({
421 b'data': templatefilters.json({
407 ctx.hex(): {
422 ctx.hex(): {
408 b'author': stringutil.person(ctx.user()),
423 b'author': stringutil.person(ctx.user()),
409 b'authorEmail': stringutil.email(ctx.user()),
424 b'authorEmail': stringutil.email(ctx.user()),
410 b'time': int(ctx.date()[0]),
425 b'time': int(ctx.date()[0]),
411 b'commit': ctx.hex(),
426 b'commit': ctx.hex(),
412 b'parents': [ctx.p1().hex()],
427 b'parents': [ctx.p1().hex()],
413 b'branch': ctx.branch(),
428 b'branch': ctx.branch(),
414 },
429 },
415 }),
430 }),
416 }
431 }
417 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
432 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
418
433
419 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
434 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
420 olddiff=None, actions=None, comment=None):
435 olddiff=None, actions=None, comment=None):
421 """create or update a Differential Revision
436 """create or update a Differential Revision
422
437
423 If revid is None, create a new Differential Revision, otherwise update
438 If revid is None, create a new Differential Revision, otherwise update
424 revid. If parentrevid is not None, set it as a dependency.
439 revid. If parentrevid is not None, set it as a dependency.
425
440
426 If oldnode is not None, check if the patch content (without commit message
441 If oldnode is not None, check if the patch content (without commit message
427 and metadata) has changed before creating another diff.
442 and metadata) has changed before creating another diff.
428
443
429 If actions is not None, they will be appended to the transaction.
444 If actions is not None, they will be appended to the transaction.
430 """
445 """
431 repo = ctx.repo()
446 repo = ctx.repo()
432 if oldnode:
447 if oldnode:
433 diffopts = mdiff.diffopts(git=True, context=32767)
448 diffopts = mdiff.diffopts(git=True, context=32767)
434 oldctx = repo.unfiltered()[oldnode]
449 oldctx = repo.unfiltered()[oldnode]
435 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
450 neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
436 else:
451 else:
437 neednewdiff = True
452 neednewdiff = True
438
453
439 transactions = []
454 transactions = []
440 if neednewdiff:
455 if neednewdiff:
441 diff = creatediff(ctx)
456 diff = creatediff(ctx)
442 transactions.append({b'type': b'update', b'value': diff[b'phid']})
457 transactions.append({b'type': b'update', b'value': diff[b'phid']})
443 if comment:
458 if comment:
444 transactions.append({b'type': b'comment', b'value': comment})
459 transactions.append({b'type': b'comment', b'value': comment})
445 else:
460 else:
446 # Even if we don't need to upload a new diff because the patch content
461 # Even if we don't need to upload a new diff because the patch content
447 # does not change. We might still need to update its metadata so
462 # does not change. We might still need to update its metadata so
448 # pushers could know the correct node metadata.
463 # pushers could know the correct node metadata.
449 assert olddiff
464 assert olddiff
450 diff = olddiff
465 diff = olddiff
451 writediffproperties(ctx, diff)
466 writediffproperties(ctx, diff)
452
467
453 # Use a temporary summary to set dependency. There might be better ways but
468 # Use a temporary summary to set dependency. There might be better ways but
454 # I cannot find them for now. But do not do that if we are updating an
469 # I cannot find them for now. But do not do that if we are updating an
455 # existing revision (revid is not None) since that introduces visible
470 # existing revision (revid is not None) since that introduces visible
456 # churns (someone edited "Summary" twice) on the web page.
471 # churns (someone edited "Summary" twice) on the web page.
457 if parentrevid and revid is None:
472 if parentrevid and revid is None:
458 summary = b'Depends on D%d' % parentrevid
473 summary = b'Depends on D%d' % parentrevid
459 transactions += [{b'type': b'summary', b'value': summary},
474 transactions += [{b'type': b'summary', b'value': summary},
460 {b'type': b'summary', b'value': b' '}]
475 {b'type': b'summary', b'value': b' '}]
461
476
462 if actions:
477 if actions:
463 transactions += actions
478 transactions += actions
464
479
465 # Parse commit message and update related fields.
480 # Parse commit message and update related fields.
466 desc = ctx.description()
481 desc = ctx.description()
467 info = callconduit(repo.ui, b'differential.parsecommitmessage',
482 info = callconduit(repo.ui, b'differential.parsecommitmessage',
468 {b'corpus': desc})
483 {b'corpus': desc})
469 for k, v in info[b'fields'].items():
484 for k, v in info[b'fields'].items():
470 if k in [b'title', b'summary', b'testPlan']:
485 if k in [b'title', b'summary', b'testPlan']:
471 transactions.append({b'type': k, b'value': v})
486 transactions.append({b'type': k, b'value': v})
472
487
473 params = {b'transactions': transactions}
488 params = {b'transactions': transactions}
474 if revid is not None:
489 if revid is not None:
475 # Update an existing Differential Revision
490 # Update an existing Differential Revision
476 params[b'objectIdentifier'] = revid
491 params[b'objectIdentifier'] = revid
477
492
478 revision = callconduit(repo.ui, b'differential.revision.edit', params)
493 revision = callconduit(repo.ui, b'differential.revision.edit', params)
479 if not revision:
494 if not revision:
480 raise error.Abort(_(b'cannot create revision for %s') % ctx)
495 raise error.Abort(_(b'cannot create revision for %s') % ctx)
481
496
482 return revision, diff
497 return revision, diff
483
498
484 def userphids(repo, names):
499 def userphids(repo, names):
485 """convert user names to PHIDs"""
500 """convert user names to PHIDs"""
486 names = [name.lower() for name in names]
501 names = [name.lower() for name in names]
487 query = {b'constraints': {b'usernames': names}}
502 query = {b'constraints': {b'usernames': names}}
488 result = callconduit(repo.ui, b'user.search', query)
503 result = callconduit(repo.ui, b'user.search', query)
489 # username not found is not an error of the API. So check if we have missed
504 # username not found is not an error of the API. So check if we have missed
490 # some names here.
505 # some names here.
491 data = result[b'data']
506 data = result[b'data']
492 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
507 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
493 unresolved = set(names) - resolved
508 unresolved = set(names) - resolved
494 if unresolved:
509 if unresolved:
495 raise error.Abort(_(b'unknown username: %s')
510 raise error.Abort(_(b'unknown username: %s')
496 % b' '.join(sorted(unresolved)))
511 % b' '.join(sorted(unresolved)))
497 return [entry[b'phid'] for entry in data]
512 return [entry[b'phid'] for entry in data]
498
513
499 @vcrcommand(b'phabsend',
514 @vcrcommand(b'phabsend',
500 [(b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
515 [(b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
501 (b'', b'amend', True, _(b'update commit messages')),
516 (b'', b'amend', True, _(b'update commit messages')),
502 (b'', b'reviewer', [], _(b'specify reviewers')),
517 (b'', b'reviewer', [], _(b'specify reviewers')),
503 (b'm', b'comment', b'',
518 (b'm', b'comment', b'',
504 _(b'add a comment to Revisions with new/updated Diffs')),
519 _(b'add a comment to Revisions with new/updated Diffs')),
505 (b'', b'confirm', None, _(b'ask for confirmation before sending'))],
520 (b'', b'confirm', None, _(b'ask for confirmation before sending'))],
506 _(b'REV [OPTIONS]'),
521 _(b'REV [OPTIONS]'),
507 helpcategory=command.CATEGORY_IMPORT_EXPORT)
522 helpcategory=command.CATEGORY_IMPORT_EXPORT)
508 def phabsend(ui, repo, *revs, **opts):
523 def phabsend(ui, repo, *revs, **opts):
509 """upload changesets to Phabricator
524 """upload changesets to Phabricator
510
525
511 If there are multiple revisions specified, they will be send as a stack
526 If there are multiple revisions specified, they will be send as a stack
512 with a linear dependencies relationship using the order specified by the
527 with a linear dependencies relationship using the order specified by the
513 revset.
528 revset.
514
529
515 For the first time uploading changesets, local tags will be created to
530 For the first time uploading changesets, local tags will be created to
516 maintain the association. After the first time, phabsend will check
531 maintain the association. After the first time, phabsend will check
517 obsstore and tags information so it can figure out whether to update an
532 obsstore and tags information so it can figure out whether to update an
518 existing Differential Revision, or create a new one.
533 existing Differential Revision, or create a new one.
519
534
520 If --amend is set, update commit messages so they have the
535 If --amend is set, update commit messages so they have the
521 ``Differential Revision`` URL, remove related tags. This is similar to what
536 ``Differential Revision`` URL, remove related tags. This is similar to what
522 arcanist will do, and is more desired in author-push workflows. Otherwise,
537 arcanist will do, and is more desired in author-push workflows. Otherwise,
523 use local tags to record the ``Differential Revision`` association.
538 use local tags to record the ``Differential Revision`` association.
524
539
525 The --confirm option lets you confirm changesets before sending them. You
540 The --confirm option lets you confirm changesets before sending them. You
526 can also add following to your configuration file to make it default
541 can also add following to your configuration file to make it default
527 behaviour::
542 behaviour::
528
543
529 [phabsend]
544 [phabsend]
530 confirm = true
545 confirm = true
531
546
532 phabsend will check obsstore and the above association to decide whether to
547 phabsend will check obsstore and the above association to decide whether to
533 update an existing Differential Revision, or create a new one.
548 update an existing Differential Revision, or create a new one.
534 """
549 """
535 opts = pycompat.byteskwargs(opts)
550 opts = pycompat.byteskwargs(opts)
536 revs = list(revs) + opts.get(b'rev', [])
551 revs = list(revs) + opts.get(b'rev', [])
537 revs = scmutil.revrange(repo, revs)
552 revs = scmutil.revrange(repo, revs)
538
553
539 if not revs:
554 if not revs:
540 raise error.Abort(_(b'phabsend requires at least one changeset'))
555 raise error.Abort(_(b'phabsend requires at least one changeset'))
541 if opts.get(b'amend'):
556 if opts.get(b'amend'):
542 cmdutil.checkunfinished(repo)
557 cmdutil.checkunfinished(repo)
543
558
544 # {newnode: (oldnode, olddiff, olddrev}
559 # {newnode: (oldnode, olddiff, olddrev}
545 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
560 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
546
561
547 confirm = ui.configbool(b'phabsend', b'confirm')
562 confirm = ui.configbool(b'phabsend', b'confirm')
548 confirm |= bool(opts.get(b'confirm'))
563 confirm |= bool(opts.get(b'confirm'))
549 if confirm:
564 if confirm:
550 confirmed = _confirmbeforesend(repo, revs, oldmap)
565 confirmed = _confirmbeforesend(repo, revs, oldmap)
551 if not confirmed:
566 if not confirmed:
552 raise error.Abort(_(b'phabsend cancelled'))
567 raise error.Abort(_(b'phabsend cancelled'))
553
568
554 actions = []
569 actions = []
555 reviewers = opts.get(b'reviewer', [])
570 reviewers = opts.get(b'reviewer', [])
556 if reviewers:
571 if reviewers:
557 phids = userphids(repo, reviewers)
572 phids = userphids(repo, reviewers)
558 actions.append({b'type': b'reviewers.add', b'value': phids})
573 actions.append({b'type': b'reviewers.add', b'value': phids})
559
574
560 drevids = [] # [int]
575 drevids = [] # [int]
561 diffmap = {} # {newnode: diff}
576 diffmap = {} # {newnode: diff}
562
577
563 # Send patches one by one so we know their Differential Revision IDs and
578 # Send patches one by one so we know their Differential Revision IDs and
564 # can provide dependency relationship
579 # can provide dependency relationship
565 lastrevid = None
580 lastrevid = None
566 for rev in revs:
581 for rev in revs:
567 ui.debug(b'sending rev %d\n' % rev)
582 ui.debug(b'sending rev %d\n' % rev)
568 ctx = repo[rev]
583 ctx = repo[rev]
569
584
570 # Get Differential Revision ID
585 # Get Differential Revision ID
571 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
586 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
572 if oldnode != ctx.node() or opts.get(b'amend'):
587 if oldnode != ctx.node() or opts.get(b'amend'):
573 # Create or update Differential Revision
588 # Create or update Differential Revision
574 revision, diff = createdifferentialrevision(
589 revision, diff = createdifferentialrevision(
575 ctx, revid, lastrevid, oldnode, olddiff, actions,
590 ctx, revid, lastrevid, oldnode, olddiff, actions,
576 opts.get(b'comment'))
591 opts.get(b'comment'))
577 diffmap[ctx.node()] = diff
592 diffmap[ctx.node()] = diff
578 newrevid = int(revision[b'object'][b'id'])
593 newrevid = int(revision[b'object'][b'id'])
579 if revid:
594 if revid:
580 action = b'updated'
595 action = b'updated'
581 else:
596 else:
582 action = b'created'
597 action = b'created'
583
598
584 # Create a local tag to note the association, if commit message
599 # Create a local tag to note the association, if commit message
585 # does not have it already
600 # does not have it already
586 m = _differentialrevisiondescre.search(ctx.description())
601 m = _differentialrevisiondescre.search(ctx.description())
587 if not m or int(m.group(r'id')) != newrevid:
602 if not m or int(m.group(r'id')) != newrevid:
588 tagname = b'D%d' % newrevid
603 tagname = b'D%d' % newrevid
589 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
604 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
590 date=None, local=True)
605 date=None, local=True)
591 else:
606 else:
592 # Nothing changed. But still set "newrevid" so the next revision
607 # Nothing changed. But still set "newrevid" so the next revision
593 # could depend on this one.
608 # could depend on this one.
594 newrevid = revid
609 newrevid = revid
595 action = b'skipped'
610 action = b'skipped'
596
611
597 actiondesc = ui.label(
612 actiondesc = ui.label(
598 {b'created': _(b'created'),
613 {b'created': _(b'created'),
599 b'skipped': _(b'skipped'),
614 b'skipped': _(b'skipped'),
600 b'updated': _(b'updated')}[action],
615 b'updated': _(b'updated')}[action],
601 b'phabricator.action.%s' % action)
616 b'phabricator.action.%s' % action)
602 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
617 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
603 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
618 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
604 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
619 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
605 ui.write(_(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc,
620 ui.write(_(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc,
606 desc))
621 desc))
607 drevids.append(newrevid)
622 drevids.append(newrevid)
608 lastrevid = newrevid
623 lastrevid = newrevid
609
624
610 # Update commit messages and remove tags
625 # Update commit messages and remove tags
611 if opts.get(b'amend'):
626 if opts.get(b'amend'):
612 unfi = repo.unfiltered()
627 unfi = repo.unfiltered()
613 drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
628 drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
614 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
629 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
615 wnode = unfi[b'.'].node()
630 wnode = unfi[b'.'].node()
616 mapping = {} # {oldnode: [newnode]}
631 mapping = {} # {oldnode: [newnode]}
617 for i, rev in enumerate(revs):
632 for i, rev in enumerate(revs):
618 old = unfi[rev]
633 old = unfi[rev]
619 drevid = drevids[i]
634 drevid = drevids[i]
620 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
635 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
621 newdesc = getdescfromdrev(drev)
636 newdesc = getdescfromdrev(drev)
622 # Make sure commit message contain "Differential Revision"
637 # Make sure commit message contain "Differential Revision"
623 if old.description() != newdesc:
638 if old.description() != newdesc:
624 if old.phase() == phases.public:
639 if old.phase() == phases.public:
625 ui.warn(_("warning: not updating public commit %s\n")
640 ui.warn(_("warning: not updating public commit %s\n")
626 % scmutil.formatchangeid(old))
641 % scmutil.formatchangeid(old))
627 continue
642 continue
628 parents = [
643 parents = [
629 mapping.get(old.p1().node(), (old.p1(),))[0],
644 mapping.get(old.p1().node(), (old.p1(),))[0],
630 mapping.get(old.p2().node(), (old.p2(),))[0],
645 mapping.get(old.p2().node(), (old.p2(),))[0],
631 ]
646 ]
632 new = context.metadataonlyctx(
647 new = context.metadataonlyctx(
633 repo, old, parents=parents, text=newdesc,
648 repo, old, parents=parents, text=newdesc,
634 user=old.user(), date=old.date(), extra=old.extra())
649 user=old.user(), date=old.date(), extra=old.extra())
635
650
636 newnode = new.commit()
651 newnode = new.commit()
637
652
638 mapping[old.node()] = [newnode]
653 mapping[old.node()] = [newnode]
639 # Update diff property
654 # Update diff property
640 writediffproperties(unfi[newnode], diffmap[old.node()])
655 writediffproperties(unfi[newnode], diffmap[old.node()])
641 # Remove local tags since it's no longer necessary
656 # Remove local tags since it's no longer necessary
642 tagname = b'D%d' % drevid
657 tagname = b'D%d' % drevid
643 if tagname in repo.tags():
658 if tagname in repo.tags():
644 tags.tag(repo, tagname, nullid, message=None, user=None,
659 tags.tag(repo, tagname, nullid, message=None, user=None,
645 date=None, local=True)
660 date=None, local=True)
646 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
661 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
647 if wnode in mapping:
662 if wnode in mapping:
648 unfi.setparents(mapping[wnode][0])
663 unfi.setparents(mapping[wnode][0])
649
664
650 # Map from "hg:meta" keys to header understood by "hg import". The order is
665 # Map from "hg:meta" keys to header understood by "hg import". The order is
651 # consistent with "hg export" output.
666 # consistent with "hg export" output.
652 _metanamemap = util.sortdict([(b'user', b'User'), (b'date', b'Date'),
667 _metanamemap = util.sortdict([(b'user', b'User'), (b'date', b'Date'),
653 (b'branch', b'Branch'), (b'node', b'Node ID'),
668 (b'branch', b'Branch'), (b'node', b'Node ID'),
654 (b'parent', b'Parent ')])
669 (b'parent', b'Parent ')])
655
670
656 def _confirmbeforesend(repo, revs, oldmap):
671 def _confirmbeforesend(repo, revs, oldmap):
657 url, token = readurltoken(repo.ui)
672 url, token = readurltoken(repo.ui)
658 ui = repo.ui
673 ui = repo.ui
659 for rev in revs:
674 for rev in revs:
660 ctx = repo[rev]
675 ctx = repo[rev]
661 desc = ctx.description().splitlines()[0]
676 desc = ctx.description().splitlines()[0]
662 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
677 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
663 if drevid:
678 if drevid:
664 drevdesc = ui.label(b'D%s' % drevid, b'phabricator.drev')
679 drevdesc = ui.label(b'D%s' % drevid, b'phabricator.drev')
665 else:
680 else:
666 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
681 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
667
682
668 ui.write(_(b'%s - %s: %s\n')
683 ui.write(_(b'%s - %s: %s\n')
669 % (drevdesc,
684 % (drevdesc,
670 ui.label(bytes(ctx), b'phabricator.node'),
685 ui.label(bytes(ctx), b'phabricator.node'),
671 ui.label(desc, b'phabricator.desc')))
686 ui.label(desc, b'phabricator.desc')))
672
687
673 if ui.promptchoice(_(b'Send the above changes to %s (yn)?'
688 if ui.promptchoice(_(b'Send the above changes to %s (yn)?'
674 b'$$ &Yes $$ &No') % url):
689 b'$$ &Yes $$ &No') % url):
675 return False
690 return False
676
691
677 return True
692 return True
678
693
679 _knownstatusnames = {b'accepted', b'needsreview', b'needsrevision', b'closed',
694 _knownstatusnames = {b'accepted', b'needsreview', b'needsrevision', b'closed',
680 b'abandoned'}
695 b'abandoned'}
681
696
682 def _getstatusname(drev):
697 def _getstatusname(drev):
683 """get normalized status name from a Differential Revision"""
698 """get normalized status name from a Differential Revision"""
684 return drev[b'statusName'].replace(b' ', b'').lower()
699 return drev[b'statusName'].replace(b' ', b'').lower()
685
700
686 # Small language to specify differential revisions. Support symbols: (), :X,
701 # Small language to specify differential revisions. Support symbols: (), :X,
687 # +, and -.
702 # +, and -.
688
703
689 _elements = {
704 _elements = {
690 # token-type: binding-strength, primary, prefix, infix, suffix
705 # token-type: binding-strength, primary, prefix, infix, suffix
691 b'(': (12, None, (b'group', 1, b')'), None, None),
706 b'(': (12, None, (b'group', 1, b')'), None, None),
692 b':': (8, None, (b'ancestors', 8), None, None),
707 b':': (8, None, (b'ancestors', 8), None, None),
693 b'&': (5, None, None, (b'and_', 5), None),
708 b'&': (5, None, None, (b'and_', 5), None),
694 b'+': (4, None, None, (b'add', 4), None),
709 b'+': (4, None, None, (b'add', 4), None),
695 b'-': (4, None, None, (b'sub', 4), None),
710 b'-': (4, None, None, (b'sub', 4), None),
696 b')': (0, None, None, None, None),
711 b')': (0, None, None, None, None),
697 b'symbol': (0, b'symbol', None, None, None),
712 b'symbol': (0, b'symbol', None, None, None),
698 b'end': (0, None, None, None, None),
713 b'end': (0, None, None, None, None),
699 }
714 }
700
715
701 def _tokenize(text):
716 def _tokenize(text):
702 view = memoryview(text) # zero-copy slice
717 view = memoryview(text) # zero-copy slice
703 special = b'():+-& '
718 special = b'():+-& '
704 pos = 0
719 pos = 0
705 length = len(text)
720 length = len(text)
706 while pos < length:
721 while pos < length:
707 symbol = b''.join(itertools.takewhile(lambda ch: ch not in special,
722 symbol = b''.join(itertools.takewhile(lambda ch: ch not in special,
708 pycompat.iterbytestr(view[pos:])))
723 pycompat.iterbytestr(view[pos:])))
709 if symbol:
724 if symbol:
710 yield (b'symbol', symbol, pos)
725 yield (b'symbol', symbol, pos)
711 pos += len(symbol)
726 pos += len(symbol)
712 else: # special char, ignore space
727 else: # special char, ignore space
713 if text[pos] != b' ':
728 if text[pos] != b' ':
714 yield (text[pos], None, pos)
729 yield (text[pos], None, pos)
715 pos += 1
730 pos += 1
716 yield (b'end', None, pos)
731 yield (b'end', None, pos)
717
732
718 def _parse(text):
733 def _parse(text):
719 tree, pos = parser.parser(_elements).parse(_tokenize(text))
734 tree, pos = parser.parser(_elements).parse(_tokenize(text))
720 if pos != len(text):
735 if pos != len(text):
721 raise error.ParseError(b'invalid token', pos)
736 raise error.ParseError(b'invalid token', pos)
722 return tree
737 return tree
723
738
724 def _parsedrev(symbol):
739 def _parsedrev(symbol):
725 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
740 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
726 if symbol.startswith(b'D') and symbol[1:].isdigit():
741 if symbol.startswith(b'D') and symbol[1:].isdigit():
727 return int(symbol[1:])
742 return int(symbol[1:])
728 if symbol.isdigit():
743 if symbol.isdigit():
729 return int(symbol)
744 return int(symbol)
730
745
731 def _prefetchdrevs(tree):
746 def _prefetchdrevs(tree):
732 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
747 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
733 drevs = set()
748 drevs = set()
734 ancestordrevs = set()
749 ancestordrevs = set()
735 op = tree[0]
750 op = tree[0]
736 if op == b'symbol':
751 if op == b'symbol':
737 r = _parsedrev(tree[1])
752 r = _parsedrev(tree[1])
738 if r:
753 if r:
739 drevs.add(r)
754 drevs.add(r)
740 elif op == b'ancestors':
755 elif op == b'ancestors':
741 r, a = _prefetchdrevs(tree[1])
756 r, a = _prefetchdrevs(tree[1])
742 drevs.update(r)
757 drevs.update(r)
743 ancestordrevs.update(r)
758 ancestordrevs.update(r)
744 ancestordrevs.update(a)
759 ancestordrevs.update(a)
745 else:
760 else:
746 for t in tree[1:]:
761 for t in tree[1:]:
747 r, a = _prefetchdrevs(t)
762 r, a = _prefetchdrevs(t)
748 drevs.update(r)
763 drevs.update(r)
749 ancestordrevs.update(a)
764 ancestordrevs.update(a)
750 return drevs, ancestordrevs
765 return drevs, ancestordrevs
751
766
752 def querydrev(repo, spec):
767 def querydrev(repo, spec):
753 """return a list of "Differential Revision" dicts
768 """return a list of "Differential Revision" dicts
754
769
755 spec is a string using a simple query language, see docstring in phabread
770 spec is a string using a simple query language, see docstring in phabread
756 for details.
771 for details.
757
772
758 A "Differential Revision dict" looks like:
773 A "Differential Revision dict" looks like:
759
774
760 {
775 {
761 "id": "2",
776 "id": "2",
762 "phid": "PHID-DREV-672qvysjcczopag46qty",
777 "phid": "PHID-DREV-672qvysjcczopag46qty",
763 "title": "example",
778 "title": "example",
764 "uri": "https://phab.example.com/D2",
779 "uri": "https://phab.example.com/D2",
765 "dateCreated": "1499181406",
780 "dateCreated": "1499181406",
766 "dateModified": "1499182103",
781 "dateModified": "1499182103",
767 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
782 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
768 "status": "0",
783 "status": "0",
769 "statusName": "Needs Review",
784 "statusName": "Needs Review",
770 "properties": [],
785 "properties": [],
771 "branch": null,
786 "branch": null,
772 "summary": "",
787 "summary": "",
773 "testPlan": "",
788 "testPlan": "",
774 "lineCount": "2",
789 "lineCount": "2",
775 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
790 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
776 "diffs": [
791 "diffs": [
777 "3",
792 "3",
778 "4",
793 "4",
779 ],
794 ],
780 "commits": [],
795 "commits": [],
781 "reviewers": [],
796 "reviewers": [],
782 "ccs": [],
797 "ccs": [],
783 "hashes": [],
798 "hashes": [],
784 "auxiliary": {
799 "auxiliary": {
785 "phabricator:projects": [],
800 "phabricator:projects": [],
786 "phabricator:depends-on": [
801 "phabricator:depends-on": [
787 "PHID-DREV-gbapp366kutjebt7agcd"
802 "PHID-DREV-gbapp366kutjebt7agcd"
788 ]
803 ]
789 },
804 },
790 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
805 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
791 "sourcePath": null
806 "sourcePath": null
792 }
807 }
793 """
808 """
794 def fetch(params):
809 def fetch(params):
795 """params -> single drev or None"""
810 """params -> single drev or None"""
796 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
811 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
797 if key in prefetched:
812 if key in prefetched:
798 return prefetched[key]
813 return prefetched[key]
799 drevs = callconduit(repo.ui, b'differential.query', params)
814 drevs = callconduit(repo.ui, b'differential.query', params)
800 # Fill prefetched with the result
815 # Fill prefetched with the result
801 for drev in drevs:
816 for drev in drevs:
802 prefetched[drev[b'phid']] = drev
817 prefetched[drev[b'phid']] = drev
803 prefetched[int(drev[b'id'])] = drev
818 prefetched[int(drev[b'id'])] = drev
804 if key not in prefetched:
819 if key not in prefetched:
805 raise error.Abort(_(b'cannot get Differential Revision %r')
820 raise error.Abort(_(b'cannot get Differential Revision %r')
806 % params)
821 % params)
807 return prefetched[key]
822 return prefetched[key]
808
823
809 def getstack(topdrevids):
824 def getstack(topdrevids):
810 """given a top, get a stack from the bottom, [id] -> [id]"""
825 """given a top, get a stack from the bottom, [id] -> [id]"""
811 visited = set()
826 visited = set()
812 result = []
827 result = []
813 queue = [{b'ids': [i]} for i in topdrevids]
828 queue = [{b'ids': [i]} for i in topdrevids]
814 while queue:
829 while queue:
815 params = queue.pop()
830 params = queue.pop()
816 drev = fetch(params)
831 drev = fetch(params)
817 if drev[b'id'] in visited:
832 if drev[b'id'] in visited:
818 continue
833 continue
819 visited.add(drev[b'id'])
834 visited.add(drev[b'id'])
820 result.append(int(drev[b'id']))
835 result.append(int(drev[b'id']))
821 auxiliary = drev.get(b'auxiliary', {})
836 auxiliary = drev.get(b'auxiliary', {})
822 depends = auxiliary.get(b'phabricator:depends-on', [])
837 depends = auxiliary.get(b'phabricator:depends-on', [])
823 for phid in depends:
838 for phid in depends:
824 queue.append({b'phids': [phid]})
839 queue.append({b'phids': [phid]})
825 result.reverse()
840 result.reverse()
826 return smartset.baseset(result)
841 return smartset.baseset(result)
827
842
828 # Initialize prefetch cache
843 # Initialize prefetch cache
829 prefetched = {} # {id or phid: drev}
844 prefetched = {} # {id or phid: drev}
830
845
831 tree = _parse(spec)
846 tree = _parse(spec)
832 drevs, ancestordrevs = _prefetchdrevs(tree)
847 drevs, ancestordrevs = _prefetchdrevs(tree)
833
848
834 # developer config: phabricator.batchsize
849 # developer config: phabricator.batchsize
835 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
850 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
836
851
837 # Prefetch Differential Revisions in batch
852 # Prefetch Differential Revisions in batch
838 tofetch = set(drevs)
853 tofetch = set(drevs)
839 for r in ancestordrevs:
854 for r in ancestordrevs:
840 tofetch.update(range(max(1, r - batchsize), r + 1))
855 tofetch.update(range(max(1, r - batchsize), r + 1))
841 if drevs:
856 if drevs:
842 fetch({b'ids': list(tofetch)})
857 fetch({b'ids': list(tofetch)})
843 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
858 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
844
859
845 # Walk through the tree, return smartsets
860 # Walk through the tree, return smartsets
846 def walk(tree):
861 def walk(tree):
847 op = tree[0]
862 op = tree[0]
848 if op == b'symbol':
863 if op == b'symbol':
849 drev = _parsedrev(tree[1])
864 drev = _parsedrev(tree[1])
850 if drev:
865 if drev:
851 return smartset.baseset([drev])
866 return smartset.baseset([drev])
852 elif tree[1] in _knownstatusnames:
867 elif tree[1] in _knownstatusnames:
853 drevs = [r for r in validids
868 drevs = [r for r in validids
854 if _getstatusname(prefetched[r]) == tree[1]]
869 if _getstatusname(prefetched[r]) == tree[1]]
855 return smartset.baseset(drevs)
870 return smartset.baseset(drevs)
856 else:
871 else:
857 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
872 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
858 elif op in {b'and_', b'add', b'sub'}:
873 elif op in {b'and_', b'add', b'sub'}:
859 assert len(tree) == 3
874 assert len(tree) == 3
860 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
875 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
861 elif op == b'group':
876 elif op == b'group':
862 return walk(tree[1])
877 return walk(tree[1])
863 elif op == b'ancestors':
878 elif op == b'ancestors':
864 return getstack(walk(tree[1]))
879 return getstack(walk(tree[1]))
865 else:
880 else:
866 raise error.ProgrammingError(b'illegal tree: %r' % tree)
881 raise error.ProgrammingError(b'illegal tree: %r' % tree)
867
882
868 return [prefetched[r] for r in walk(tree)]
883 return [prefetched[r] for r in walk(tree)]
869
884
870 def getdescfromdrev(drev):
885 def getdescfromdrev(drev):
871 """get description (commit message) from "Differential Revision"
886 """get description (commit message) from "Differential Revision"
872
887
873 This is similar to differential.getcommitmessage API. But we only care
888 This is similar to differential.getcommitmessage API. But we only care
874 about limited fields: title, summary, test plan, and URL.
889 about limited fields: title, summary, test plan, and URL.
875 """
890 """
876 title = drev[b'title']
891 title = drev[b'title']
877 summary = drev[b'summary'].rstrip()
892 summary = drev[b'summary'].rstrip()
878 testplan = drev[b'testPlan'].rstrip()
893 testplan = drev[b'testPlan'].rstrip()
879 if testplan:
894 if testplan:
880 testplan = b'Test Plan:\n%s' % testplan
895 testplan = b'Test Plan:\n%s' % testplan
881 uri = b'Differential Revision: %s' % drev[b'uri']
896 uri = b'Differential Revision: %s' % drev[b'uri']
882 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
897 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
883
898
884 def getdiffmeta(diff):
899 def getdiffmeta(diff):
885 """get commit metadata (date, node, user, p1) from a diff object
900 """get commit metadata (date, node, user, p1) from a diff object
886
901
887 The metadata could be "hg:meta", sent by phabsend, like:
902 The metadata could be "hg:meta", sent by phabsend, like:
888
903
889 "properties": {
904 "properties": {
890 "hg:meta": {
905 "hg:meta": {
891 "date": "1499571514 25200",
906 "date": "1499571514 25200",
892 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
907 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
893 "user": "Foo Bar <foo@example.com>",
908 "user": "Foo Bar <foo@example.com>",
894 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
909 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
895 }
910 }
896 }
911 }
897
912
898 Or converted from "local:commits", sent by "arc", like:
913 Or converted from "local:commits", sent by "arc", like:
899
914
900 "properties": {
915 "properties": {
901 "local:commits": {
916 "local:commits": {
902 "98c08acae292b2faf60a279b4189beb6cff1414d": {
917 "98c08acae292b2faf60a279b4189beb6cff1414d": {
903 "author": "Foo Bar",
918 "author": "Foo Bar",
904 "time": 1499546314,
919 "time": 1499546314,
905 "branch": "default",
920 "branch": "default",
906 "tag": "",
921 "tag": "",
907 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
922 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
908 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
923 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
909 "local": "1000",
924 "local": "1000",
910 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
925 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
911 "summary": "...",
926 "summary": "...",
912 "message": "...",
927 "message": "...",
913 "authorEmail": "foo@example.com"
928 "authorEmail": "foo@example.com"
914 }
929 }
915 }
930 }
916 }
931 }
917
932
918 Note: metadata extracted from "local:commits" will lose time zone
933 Note: metadata extracted from "local:commits" will lose time zone
919 information.
934 information.
920 """
935 """
921 props = diff.get(b'properties') or {}
936 props = diff.get(b'properties') or {}
922 meta = props.get(b'hg:meta')
937 meta = props.get(b'hg:meta')
923 if not meta:
938 if not meta:
924 if props.get(b'local:commits'):
939 if props.get(b'local:commits'):
925 commit = sorted(props[b'local:commits'].values())[0]
940 commit = sorted(props[b'local:commits'].values())[0]
926 meta = {}
941 meta = {}
927 if b'author' in commit and b'authorEmail' in commit:
942 if b'author' in commit and b'authorEmail' in commit:
928 meta[b'user'] = b'%s <%s>' % (commit[b'author'],
943 meta[b'user'] = b'%s <%s>' % (commit[b'author'],
929 commit[b'authorEmail'])
944 commit[b'authorEmail'])
930 if b'time' in commit:
945 if b'time' in commit:
931 meta[b'date'] = b'%d 0' % commit[b'time']
946 meta[b'date'] = b'%d 0' % commit[b'time']
932 if b'branch' in commit:
947 if b'branch' in commit:
933 meta[b'branch'] = commit[b'branch']
948 meta[b'branch'] = commit[b'branch']
934 node = commit.get(b'commit', commit.get(b'rev'))
949 node = commit.get(b'commit', commit.get(b'rev'))
935 if node:
950 if node:
936 meta[b'node'] = node
951 meta[b'node'] = node
937 if len(commit.get(b'parents', ())) >= 1:
952 if len(commit.get(b'parents', ())) >= 1:
938 meta[b'parent'] = commit[b'parents'][0]
953 meta[b'parent'] = commit[b'parents'][0]
939 else:
954 else:
940 meta = {}
955 meta = {}
941 if b'date' not in meta and b'dateCreated' in diff:
956 if b'date' not in meta and b'dateCreated' in diff:
942 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
957 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
943 if b'branch' not in meta and diff.get(b'branch'):
958 if b'branch' not in meta and diff.get(b'branch'):
944 meta[b'branch'] = diff[b'branch']
959 meta[b'branch'] = diff[b'branch']
945 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
960 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
946 meta[b'parent'] = diff[b'sourceControlBaseRevision']
961 meta[b'parent'] = diff[b'sourceControlBaseRevision']
947 return meta
962 return meta
948
963
949 def readpatch(repo, drevs, write):
964 def readpatch(repo, drevs, write):
950 """generate plain-text patch readable by 'hg import'
965 """generate plain-text patch readable by 'hg import'
951
966
952 write is usually ui.write. drevs is what "querydrev" returns, results of
967 write is usually ui.write. drevs is what "querydrev" returns, results of
953 "differential.query".
968 "differential.query".
954 """
969 """
955 # Prefetch hg:meta property for all diffs
970 # Prefetch hg:meta property for all diffs
956 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
971 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
957 diffs = callconduit(repo.ui, b'differential.querydiffs', {b'ids': diffids})
972 diffs = callconduit(repo.ui, b'differential.querydiffs', {b'ids': diffids})
958
973
959 # Generate patch for each drev
974 # Generate patch for each drev
960 for drev in drevs:
975 for drev in drevs:
961 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
976 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
962
977
963 diffid = max(int(v) for v in drev[b'diffs'])
978 diffid = max(int(v) for v in drev[b'diffs'])
964 body = callconduit(repo.ui, b'differential.getrawdiff',
979 body = callconduit(repo.ui, b'differential.getrawdiff',
965 {b'diffID': diffid})
980 {b'diffID': diffid})
966 desc = getdescfromdrev(drev)
981 desc = getdescfromdrev(drev)
967 header = b'# HG changeset patch\n'
982 header = b'# HG changeset patch\n'
968
983
969 # Try to preserve metadata from hg:meta property. Write hg patch
984 # Try to preserve metadata from hg:meta property. Write hg patch
970 # headers that can be read by the "import" command. See patchheadermap
985 # headers that can be read by the "import" command. See patchheadermap
971 # and extract in mercurial/patch.py for supported headers.
986 # and extract in mercurial/patch.py for supported headers.
972 meta = getdiffmeta(diffs[b'%d' % diffid])
987 meta = getdiffmeta(diffs[b'%d' % diffid])
973 for k in _metanamemap.keys():
988 for k in _metanamemap.keys():
974 if k in meta:
989 if k in meta:
975 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
990 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
976
991
977 content = b'%s%s\n%s' % (header, desc, body)
992 content = b'%s%s\n%s' % (header, desc, body)
978 write(content)
993 write(content)
979
994
980 @vcrcommand(b'phabread',
995 @vcrcommand(b'phabread',
981 [(b'', b'stack', False, _(b'read dependencies'))],
996 [(b'', b'stack', False, _(b'read dependencies'))],
982 _(b'DREVSPEC [OPTIONS]'),
997 _(b'DREVSPEC [OPTIONS]'),
983 helpcategory=command.CATEGORY_IMPORT_EXPORT)
998 helpcategory=command.CATEGORY_IMPORT_EXPORT)
984 def phabread(ui, repo, spec, **opts):
999 def phabread(ui, repo, spec, **opts):
985 """print patches from Phabricator suitable for importing
1000 """print patches from Phabricator suitable for importing
986
1001
987 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
1002 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
988 the number ``123``. It could also have common operators like ``+``, ``-``,
1003 the number ``123``. It could also have common operators like ``+``, ``-``,
989 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
1004 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
990 select a stack.
1005 select a stack.
991
1006
992 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
1007 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
993 could be used to filter patches by status. For performance reason, they
1008 could be used to filter patches by status. For performance reason, they
994 only represent a subset of non-status selections and cannot be used alone.
1009 only represent a subset of non-status selections and cannot be used alone.
995
1010
996 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
1011 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
997 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
1012 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
998 stack up to D9.
1013 stack up to D9.
999
1014
1000 If --stack is given, follow dependencies information and read all patches.
1015 If --stack is given, follow dependencies information and read all patches.
1001 It is equivalent to the ``:`` operator.
1016 It is equivalent to the ``:`` operator.
1002 """
1017 """
1003 opts = pycompat.byteskwargs(opts)
1018 opts = pycompat.byteskwargs(opts)
1004 if opts.get(b'stack'):
1019 if opts.get(b'stack'):
1005 spec = b':(%s)' % spec
1020 spec = b':(%s)' % spec
1006 drevs = querydrev(repo, spec)
1021 drevs = querydrev(repo, spec)
1007 readpatch(repo, drevs, ui.write)
1022 readpatch(repo, drevs, ui.write)
1008
1023
1009 @vcrcommand(b'phabupdate',
1024 @vcrcommand(b'phabupdate',
1010 [(b'', b'accept', False, _(b'accept revisions')),
1025 [(b'', b'accept', False, _(b'accept revisions')),
1011 (b'', b'reject', False, _(b'reject revisions')),
1026 (b'', b'reject', False, _(b'reject revisions')),
1012 (b'', b'abandon', False, _(b'abandon revisions')),
1027 (b'', b'abandon', False, _(b'abandon revisions')),
1013 (b'', b'reclaim', False, _(b'reclaim revisions')),
1028 (b'', b'reclaim', False, _(b'reclaim revisions')),
1014 (b'm', b'comment', b'', _(b'comment on the last revision')),
1029 (b'm', b'comment', b'', _(b'comment on the last revision')),
1015 ], _(b'DREVSPEC [OPTIONS]'),
1030 ], _(b'DREVSPEC [OPTIONS]'),
1016 helpcategory=command.CATEGORY_IMPORT_EXPORT)
1031 helpcategory=command.CATEGORY_IMPORT_EXPORT)
1017 def phabupdate(ui, repo, spec, **opts):
1032 def phabupdate(ui, repo, spec, **opts):
1018 """update Differential Revision in batch
1033 """update Differential Revision in batch
1019
1034
1020 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
1035 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
1021 """
1036 """
1022 opts = pycompat.byteskwargs(opts)
1037 opts = pycompat.byteskwargs(opts)
1023 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1038 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1024 if len(flags) > 1:
1039 if len(flags) > 1:
1025 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1040 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1026
1041
1027 actions = []
1042 actions = []
1028 for f in flags:
1043 for f in flags:
1029 actions.append({b'type': f, b'value': b'true'})
1044 actions.append({b'type': f, b'value': b'true'})
1030
1045
1031 drevs = querydrev(repo, spec)
1046 drevs = querydrev(repo, spec)
1032 for i, drev in enumerate(drevs):
1047 for i, drev in enumerate(drevs):
1033 if i + 1 == len(drevs) and opts.get(b'comment'):
1048 if i + 1 == len(drevs) and opts.get(b'comment'):
1034 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1049 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1035 if actions:
1050 if actions:
1036 params = {b'objectIdentifier': drev[b'phid'],
1051 params = {b'objectIdentifier': drev[b'phid'],
1037 b'transactions': actions}
1052 b'transactions': actions}
1038 callconduit(ui, b'differential.revision.edit', params)
1053 callconduit(ui, b'differential.revision.edit', params)
1039
1054
1040 templatekeyword = registrar.templatekeyword()
1055 templatekeyword = registrar.templatekeyword()
1041
1056
1042 @templatekeyword(b'phabreview', requires={b'ctx'})
1057 @templatekeyword(b'phabreview', requires={b'ctx'})
1043 def template_review(context, mapping):
1058 def template_review(context, mapping):
1044 """:phabreview: Object describing the review for this changeset.
1059 """:phabreview: Object describing the review for this changeset.
1045 Has attributes `url` and `id`.
1060 Has attributes `url` and `id`.
1046 """
1061 """
1047 ctx = context.resource(mapping, b'ctx')
1062 ctx = context.resource(mapping, b'ctx')
1048 m = _differentialrevisiondescre.search(ctx.description())
1063 m = _differentialrevisiondescre.search(ctx.description())
1049 if m:
1064 if m:
1050 return templateutil.hybriddict({
1065 return templateutil.hybriddict({
1051 b'url': m.group(r'url'),
1066 b'url': m.group(r'url'),
1052 b'id': b"D%s" % m.group(r'id'),
1067 b'id': b"D%s" % m.group(r'id'),
1053 })
1068 })
1054 else:
1069 else:
1055 tags = ctx.repo().nodetags(ctx.node())
1070 tags = ctx.repo().nodetags(ctx.node())
1056 for t in tags:
1071 for t in tags:
1057 if _differentialrevisiontagre.match(t):
1072 if _differentialrevisiontagre.match(t):
1058 url = ctx.repo().ui.config(b'phabricator', b'url')
1073 url = ctx.repo().ui.config(b'phabricator', b'url')
1059 if not url.endswith(b'/'):
1074 if not url.endswith(b'/'):
1060 url += b'/'
1075 url += b'/'
1061 url += t
1076 url += t
1062
1077
1063 return templateutil.hybriddict({
1078 return templateutil.hybriddict({
1064 b'url': url,
1079 b'url': url,
1065 b'id': t,
1080 b'id': t,
1066 })
1081 })
1067 return None
1082 return None
@@ -1,134 +1,134 b''
1 #require vcr
1 #require vcr
2 $ cat >> $HGRCPATH <<EOF
2 $ cat >> $HGRCPATH <<EOF
3 > [extensions]
3 > [extensions]
4 > phabricator =
4 > phabricator =
5 > EOF
5 > EOF
6 $ hg init repo
6 $ hg init repo
7 $ cd repo
7 $ cd repo
8 $ cat >> .hg/hgrc <<EOF
8 $ cat >> .hg/hgrc <<EOF
9 > [phabricator]
9 > [phabricator]
10 > url = https://phab.mercurial-scm.org/
10 > url = https://phab.mercurial-scm.org/
11 > callsign = HG
11 > callsign = HG
12 >
12 >
13 > [auth]
13 > [auth]
14 > hgphab.schemes = https
14 > hgphab.schemes = https
15 > hgphab.prefix = phab.mercurial-scm.org
15 > hgphab.prefix = phab.mercurial-scm.org
16 > # When working on the extension and making phabricator interaction
16 > # When working on the extension and making phabricator interaction
17 > # changes, edit this to be a real phabricator token. When done, edit
17 > # changes, edit this to be a real phabricator token. When done, edit
18 > # it back, and make sure to also edit your VCR transcripts to match
18 > # it back. The VCR transcripts will be auto-sanitised to replace your real
19 > # whatever value you put here.
19 > # token with this value.
20 > hgphab.phabtoken = cli-hahayouwish
20 > hgphab.phabtoken = cli-hahayouwish
21 > EOF
21 > EOF
22 $ VCR="$TESTDIR/phabricator"
22 $ VCR="$TESTDIR/phabricator"
23
23
24 Error is handled reasonably. We override the phabtoken here so that
24 Error is handled reasonably. We override the phabtoken here so that
25 when you're developing changes to phabricator.py you can edit the
25 when you're developing changes to phabricator.py you can edit the
26 above config and have a real token in the test but not have to edit
26 above config and have a real token in the test but not have to edit
27 this test.
27 this test.
28 $ hg phabread --config auth.hgphab.phabtoken=cli-notavalidtoken \
28 $ hg phabread --config auth.hgphab.phabtoken=cli-notavalidtoken \
29 > --test-vcr "$VCR/phabread-conduit-error.json" D4480 | head
29 > --test-vcr "$VCR/phabread-conduit-error.json" D4480 | head
30 abort: Conduit Error (ERR-INVALID-AUTH): API token "cli-notavalidtoken" has the wrong length. API tokens should be 32 characters long.
30 abort: Conduit Error (ERR-INVALID-AUTH): API token "cli-notavalidtoken" has the wrong length. API tokens should be 32 characters long.
31
31
32 Basic phabread:
32 Basic phabread:
33 $ hg phabread --test-vcr "$VCR/phabread-4480.json" D4480 | head
33 $ hg phabread --test-vcr "$VCR/phabread-4480.json" D4480 | head
34 # HG changeset patch
34 # HG changeset patch
35 # Date 1536771503 0
35 # Date 1536771503 0
36 # Parent a5de21c9e3703f8e8eb064bd7d893ff2f703c66a
36 # Parent a5de21c9e3703f8e8eb064bd7d893ff2f703c66a
37 exchangev2: start to implement pull with wire protocol v2
37 exchangev2: start to implement pull with wire protocol v2
38
38
39 Wire protocol version 2 will take a substantially different
39 Wire protocol version 2 will take a substantially different
40 approach to exchange than version 1 (at least as far as pulling
40 approach to exchange than version 1 (at least as far as pulling
41 is concerned).
41 is concerned).
42
42
43 This commit establishes a new exchangev2 module for holding
43 This commit establishes a new exchangev2 module for holding
44
44
45 phabupdate with an accept:
45 phabupdate with an accept:
46 $ hg phabupdate --accept D4564 \
46 $ hg phabupdate --accept D4564 \
47 > -m 'I think I like where this is headed. Will read rest of series later.'\
47 > -m 'I think I like where this is headed. Will read rest of series later.'\
48 > --test-vcr "$VCR/accept-4564.json"
48 > --test-vcr "$VCR/accept-4564.json"
49
49
50 Create a differential diff:
50 Create a differential diff:
51 $ HGENCODING=utf-8; export HGENCODING
51 $ HGENCODING=utf-8; export HGENCODING
52 $ echo alpha > alpha
52 $ echo alpha > alpha
53 $ hg ci --addremove -m 'create alpha for phabricator test €'
53 $ hg ci --addremove -m 'create alpha for phabricator test €'
54 adding alpha
54 adding alpha
55 $ hg phabsend -r . --test-vcr "$VCR/phabsend-create-alpha.json"
55 $ hg phabsend -r . --test-vcr "$VCR/phabsend-create-alpha.json"
56 D1190 - created - d386117f30e6: create alpha for phabricator test \xe2\x82\xac (esc)
56 D1190 - created - d386117f30e6: create alpha for phabricator test \xe2\x82\xac (esc)
57 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/d386117f30e6-24ffe649-phabsend.hg
57 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/d386117f30e6-24ffe649-phabsend.hg
58 $ echo more >> alpha
58 $ echo more >> alpha
59 $ HGEDITOR=true hg ci --amend
59 $ HGEDITOR=true hg ci --amend
60 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/a86ed7d85e86-b7a54f3b-amend.hg
60 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/a86ed7d85e86-b7a54f3b-amend.hg
61 $ echo beta > beta
61 $ echo beta > beta
62 $ hg ci --addremove -m 'create beta for phabricator test'
62 $ hg ci --addremove -m 'create beta for phabricator test'
63 adding beta
63 adding beta
64 $ hg phabsend -r ".^::" --test-vcr "$VCR/phabsend-update-alpha-create-beta.json"
64 $ hg phabsend -r ".^::" --test-vcr "$VCR/phabsend-update-alpha-create-beta.json"
65 D1190 - updated - d940d39fb603: create alpha for phabricator test \xe2\x82\xac (esc)
65 D1190 - updated - d940d39fb603: create alpha for phabricator test \xe2\x82\xac (esc)
66 D1191 - created - 4b2486dfc8c7: create beta for phabricator test
66 D1191 - created - 4b2486dfc8c7: create beta for phabricator test
67 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/4b2486dfc8c7-d90584fa-phabsend.hg
67 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/4b2486dfc8c7-d90584fa-phabsend.hg
68 $ unset HGENCODING
68 $ unset HGENCODING
69
69
70 The amend won't explode after posting a public commit. The local tag is left
70 The amend won't explode after posting a public commit. The local tag is left
71 behind to identify it.
71 behind to identify it.
72
72
73 $ echo 'public change' > beta
73 $ echo 'public change' > beta
74 $ hg ci -m 'create public change for phabricator testing'
74 $ hg ci -m 'create public change for phabricator testing'
75 $ hg phase --public .
75 $ hg phase --public .
76 $ echo 'draft change' > alpha
76 $ echo 'draft change' > alpha
77 $ hg ci -m 'create draft change for phabricator testing'
77 $ hg ci -m 'create draft change for phabricator testing'
78 $ hg phabsend --amend -r '.^::' --test-vcr "$VCR/phabsend-create-public.json"
78 $ hg phabsend --amend -r '.^::' --test-vcr "$VCR/phabsend-create-public.json"
79 D1192 - created - 24ffd6bca53a: create public change for phabricator testing
79 D1192 - created - 24ffd6bca53a: create public change for phabricator testing
80 D1193 - created - ac331633be79: create draft change for phabricator testing
80 D1193 - created - ac331633be79: create draft change for phabricator testing
81 warning: not updating public commit 2:24ffd6bca53a
81 warning: not updating public commit 2:24ffd6bca53a
82 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/ac331633be79-719b961c-phabsend.hg
82 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/ac331633be79-719b961c-phabsend.hg
83 $ hg tags -v
83 $ hg tags -v
84 tip 3:a19f1434f9a5
84 tip 3:a19f1434f9a5
85 D1192 2:24ffd6bca53a local
85 D1192 2:24ffd6bca53a local
86
86
87 $ hg debugcallconduit user.search --test-vcr "$VCR/phab-conduit.json" <<EOF
87 $ hg debugcallconduit user.search --test-vcr "$VCR/phab-conduit.json" <<EOF
88 > {
88 > {
89 > "constraints": {
89 > "constraints": {
90 > "isBot": true
90 > "isBot": true
91 > }
91 > }
92 > }
92 > }
93 > EOF
93 > EOF
94 {
94 {
95 "cursor": {
95 "cursor": {
96 "after": null,
96 "after": null,
97 "before": null,
97 "before": null,
98 "limit": 100,
98 "limit": 100,
99 "order": null
99 "order": null
100 },
100 },
101 "data": [],
101 "data": [],
102 "maps": {},
102 "maps": {},
103 "query": {
103 "query": {
104 "queryKey": null
104 "queryKey": null
105 }
105 }
106 }
106 }
107
107
108 Template keywords
108 Template keywords
109 $ hg log -T'{rev} {phabreview|json}\n'
109 $ hg log -T'{rev} {phabreview|json}\n'
110 3 {"id": "D1193", "url": "https://phab.mercurial-scm.org/D1193"}
110 3 {"id": "D1193", "url": "https://phab.mercurial-scm.org/D1193"}
111 2 {"id": "D1192", "url": "https://phab.mercurial-scm.org/D1192"}
111 2 {"id": "D1192", "url": "https://phab.mercurial-scm.org/D1192"}
112 1 {"id": "D1191", "url": "https://phab.mercurial-scm.org/D1191"}
112 1 {"id": "D1191", "url": "https://phab.mercurial-scm.org/D1191"}
113 0 {"id": "D1190", "url": "https://phab.mercurial-scm.org/D1190"}
113 0 {"id": "D1190", "url": "https://phab.mercurial-scm.org/D1190"}
114
114
115 $ hg log -T'{rev} {if(phabreview, "{phabreview.url} {phabreview.id}")}\n'
115 $ hg log -T'{rev} {if(phabreview, "{phabreview.url} {phabreview.id}")}\n'
116 3 https://phab.mercurial-scm.org/D1193 D1193
116 3 https://phab.mercurial-scm.org/D1193 D1193
117 2 https://phab.mercurial-scm.org/D1192 D1192
117 2 https://phab.mercurial-scm.org/D1192 D1192
118 1 https://phab.mercurial-scm.org/D1191 D1191
118 1 https://phab.mercurial-scm.org/D1191 D1191
119 0 https://phab.mercurial-scm.org/D1190 D1190
119 0 https://phab.mercurial-scm.org/D1190 D1190
120
120
121 Commenting when phabsending:
121 Commenting when phabsending:
122 $ echo comment > comment
122 $ echo comment > comment
123 $ hg ci --addremove -m "create comment for phabricator test"
123 $ hg ci --addremove -m "create comment for phabricator test"
124 adding comment
124 adding comment
125 $ hg phabsend -r . -m "For default branch" --test-vcr "$VCR/phabsend-comment-created.json"
125 $ hg phabsend -r . -m "For default branch" --test-vcr "$VCR/phabsend-comment-created.json"
126 D1253 - created - a7ee4bac036a: create comment for phabricator test
126 D1253 - created - a7ee4bac036a: create comment for phabricator test
127 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/a7ee4bac036a-8009b5a0-phabsend.hg
127 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/a7ee4bac036a-8009b5a0-phabsend.hg
128 $ echo comment2 >> comment
128 $ echo comment2 >> comment
129 $ hg ci --amend
129 $ hg ci --amend
130 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/81fce7de1b7d-05339e5b-amend.hg
130 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/81fce7de1b7d-05339e5b-amend.hg
131 $ hg phabsend -r . -m "Address review comments" --test-vcr "$VCR/phabsend-comment-updated.json"
131 $ hg phabsend -r . -m "Address review comments" --test-vcr "$VCR/phabsend-comment-updated.json"
132 D1253 - updated - 1acd4b60af38: create comment for phabricator test
132 D1253 - updated - 1acd4b60af38: create comment for phabricator test
133
133
134 $ cd ..
134 $ cd ..
General Comments 0
You need to be logged in to leave comments. Login now