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