##// END OF EJS Templates
phabricator: add the phabchange data structure...
Ian Moody -
r43454:99ee4afd default
parent child Browse files
Show More
@@ -1,1292 +1,1333 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.pycompat import getattr
52 from mercurial.pycompat import getattr
53 from mercurial.thirdparty import attr
53 from mercurial.thirdparty import attr
54 from mercurial import (
54 from mercurial import (
55 cmdutil,
55 cmdutil,
56 context,
56 context,
57 encoding,
57 encoding,
58 error,
58 error,
59 exthelper,
59 exthelper,
60 httpconnection as httpconnectionmod,
60 httpconnection as httpconnectionmod,
61 mdiff,
61 mdiff,
62 obsutil,
62 obsutil,
63 parser,
63 parser,
64 patch,
64 patch,
65 phases,
65 phases,
66 pycompat,
66 pycompat,
67 scmutil,
67 scmutil,
68 smartset,
68 smartset,
69 tags,
69 tags,
70 templatefilters,
70 templatefilters,
71 templateutil,
71 templateutil,
72 url as urlmod,
72 url as urlmod,
73 util,
73 util,
74 )
74 )
75 from mercurial.utils import (
75 from mercurial.utils import (
76 procutil,
76 procutil,
77 stringutil,
77 stringutil,
78 )
78 )
79
79
80 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
80 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
81 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
81 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
82 # be specifying the version(s) of Mercurial they are tested with, or
82 # be specifying the version(s) of Mercurial they are tested with, or
83 # leave the attribute unspecified.
83 # leave the attribute unspecified.
84 testedwith = b'ships-with-hg-core'
84 testedwith = b'ships-with-hg-core'
85
85
86 eh = exthelper.exthelper()
86 eh = exthelper.exthelper()
87
87
88 cmdtable = eh.cmdtable
88 cmdtable = eh.cmdtable
89 command = eh.command
89 command = eh.command
90 configtable = eh.configtable
90 configtable = eh.configtable
91 templatekeyword = eh.templatekeyword
91 templatekeyword = eh.templatekeyword
92
92
93 # developer config: phabricator.batchsize
93 # developer config: phabricator.batchsize
94 eh.configitem(
94 eh.configitem(
95 b'phabricator', b'batchsize', default=12,
95 b'phabricator', b'batchsize', default=12,
96 )
96 )
97 eh.configitem(
97 eh.configitem(
98 b'phabricator', b'callsign', default=None,
98 b'phabricator', b'callsign', default=None,
99 )
99 )
100 eh.configitem(
100 eh.configitem(
101 b'phabricator', b'curlcmd', default=None,
101 b'phabricator', b'curlcmd', default=None,
102 )
102 )
103 # developer config: phabricator.repophid
103 # developer config: phabricator.repophid
104 eh.configitem(
104 eh.configitem(
105 b'phabricator', b'repophid', default=None,
105 b'phabricator', b'repophid', default=None,
106 )
106 )
107 eh.configitem(
107 eh.configitem(
108 b'phabricator', b'url', default=None,
108 b'phabricator', b'url', default=None,
109 )
109 )
110 eh.configitem(
110 eh.configitem(
111 b'phabsend', b'confirm', default=False,
111 b'phabsend', b'confirm', default=False,
112 )
112 )
113
113
114 colortable = {
114 colortable = {
115 b'phabricator.action.created': b'green',
115 b'phabricator.action.created': b'green',
116 b'phabricator.action.skipped': b'magenta',
116 b'phabricator.action.skipped': b'magenta',
117 b'phabricator.action.updated': b'magenta',
117 b'phabricator.action.updated': b'magenta',
118 b'phabricator.desc': b'',
118 b'phabricator.desc': b'',
119 b'phabricator.drev': b'bold',
119 b'phabricator.drev': b'bold',
120 b'phabricator.node': b'',
120 b'phabricator.node': b'',
121 }
121 }
122
122
123 _VCR_FLAGS = [
123 _VCR_FLAGS = [
124 (
124 (
125 b'',
125 b'',
126 b'test-vcr',
126 b'test-vcr',
127 b'',
127 b'',
128 _(
128 _(
129 b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
129 b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
130 b', otherwise will mock all http requests using the specified vcr file.'
130 b', otherwise will mock all http requests using the specified vcr file.'
131 b' (ADVANCED)'
131 b' (ADVANCED)'
132 ),
132 ),
133 ),
133 ),
134 ]
134 ]
135
135
136
136
137 def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
137 def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
138 fullflags = flags + _VCR_FLAGS
138 fullflags = flags + _VCR_FLAGS
139
139
140 def hgmatcher(r1, r2):
140 def hgmatcher(r1, r2):
141 if r1.uri != r2.uri or r1.method != r2.method:
141 if r1.uri != r2.uri or r1.method != r2.method:
142 return False
142 return False
143 r1params = r1.body.split(b'&')
143 r1params = r1.body.split(b'&')
144 r2params = r2.body.split(b'&')
144 r2params = r2.body.split(b'&')
145 return set(r1params) == set(r2params)
145 return set(r1params) == set(r2params)
146
146
147 def sanitiserequest(request):
147 def sanitiserequest(request):
148 request.body = re.sub(
148 request.body = re.sub(
149 br'cli-[a-z0-9]+', br'cli-hahayouwish', request.body
149 br'cli-[a-z0-9]+', br'cli-hahayouwish', request.body
150 )
150 )
151 return request
151 return request
152
152
153 def sanitiseresponse(response):
153 def sanitiseresponse(response):
154 if r'set-cookie' in response[r'headers']:
154 if r'set-cookie' in response[r'headers']:
155 del response[r'headers'][r'set-cookie']
155 del response[r'headers'][r'set-cookie']
156 return response
156 return response
157
157
158 def decorate(fn):
158 def decorate(fn):
159 def inner(*args, **kwargs):
159 def inner(*args, **kwargs):
160 cassette = pycompat.fsdecode(kwargs.pop(r'test_vcr', None))
160 cassette = pycompat.fsdecode(kwargs.pop(r'test_vcr', None))
161 if cassette:
161 if cassette:
162 import hgdemandimport
162 import hgdemandimport
163
163
164 with hgdemandimport.deactivated():
164 with hgdemandimport.deactivated():
165 import vcr as vcrmod
165 import vcr as vcrmod
166 import vcr.stubs as stubs
166 import vcr.stubs as stubs
167
167
168 vcr = vcrmod.VCR(
168 vcr = vcrmod.VCR(
169 serializer=r'json',
169 serializer=r'json',
170 before_record_request=sanitiserequest,
170 before_record_request=sanitiserequest,
171 before_record_response=sanitiseresponse,
171 before_record_response=sanitiseresponse,
172 custom_patches=[
172 custom_patches=[
173 (
173 (
174 urlmod,
174 urlmod,
175 r'httpconnection',
175 r'httpconnection',
176 stubs.VCRHTTPConnection,
176 stubs.VCRHTTPConnection,
177 ),
177 ),
178 (
178 (
179 urlmod,
179 urlmod,
180 r'httpsconnection',
180 r'httpsconnection',
181 stubs.VCRHTTPSConnection,
181 stubs.VCRHTTPSConnection,
182 ),
182 ),
183 ],
183 ],
184 )
184 )
185 vcr.register_matcher(r'hgmatcher', hgmatcher)
185 vcr.register_matcher(r'hgmatcher', hgmatcher)
186 with vcr.use_cassette(cassette, match_on=[r'hgmatcher']):
186 with vcr.use_cassette(cassette, match_on=[r'hgmatcher']):
187 return fn(*args, **kwargs)
187 return fn(*args, **kwargs)
188 return fn(*args, **kwargs)
188 return fn(*args, **kwargs)
189
189
190 inner.__name__ = fn.__name__
190 inner.__name__ = fn.__name__
191 inner.__doc__ = fn.__doc__
191 inner.__doc__ = fn.__doc__
192 return command(
192 return command(
193 name,
193 name,
194 fullflags,
194 fullflags,
195 spec,
195 spec,
196 helpcategory=helpcategory,
196 helpcategory=helpcategory,
197 optionalrepo=optionalrepo,
197 optionalrepo=optionalrepo,
198 )(inner)
198 )(inner)
199
199
200 return decorate
200 return decorate
201
201
202
202
203 def urlencodenested(params):
203 def urlencodenested(params):
204 """like urlencode, but works with nested parameters.
204 """like urlencode, but works with nested parameters.
205
205
206 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
206 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
207 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
207 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
208 urlencode. Note: the encoding is consistent with PHP's http_build_query.
208 urlencode. Note: the encoding is consistent with PHP's http_build_query.
209 """
209 """
210 flatparams = util.sortdict()
210 flatparams = util.sortdict()
211
211
212 def process(prefix, obj):
212 def process(prefix, obj):
213 if isinstance(obj, bool):
213 if isinstance(obj, bool):
214 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
214 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
215 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
215 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
216 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
216 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
217 if items is None:
217 if items is None:
218 flatparams[prefix] = obj
218 flatparams[prefix] = obj
219 else:
219 else:
220 for k, v in items(obj):
220 for k, v in items(obj):
221 if prefix:
221 if prefix:
222 process(b'%s[%s]' % (prefix, k), v)
222 process(b'%s[%s]' % (prefix, k), v)
223 else:
223 else:
224 process(k, v)
224 process(k, v)
225
225
226 process(b'', params)
226 process(b'', params)
227 return util.urlreq.urlencode(flatparams)
227 return util.urlreq.urlencode(flatparams)
228
228
229
229
230 def readurltoken(ui):
230 def readurltoken(ui):
231 """return conduit url, token and make sure they exist
231 """return conduit url, token and make sure they exist
232
232
233 Currently read from [auth] config section. In the future, it might
233 Currently read from [auth] config section. In the future, it might
234 make sense to read from .arcconfig and .arcrc as well.
234 make sense to read from .arcconfig and .arcrc as well.
235 """
235 """
236 url = ui.config(b'phabricator', b'url')
236 url = ui.config(b'phabricator', b'url')
237 if not url:
237 if not url:
238 raise error.Abort(
238 raise error.Abort(
239 _(b'config %s.%s is required') % (b'phabricator', b'url')
239 _(b'config %s.%s is required') % (b'phabricator', b'url')
240 )
240 )
241
241
242 res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user)
242 res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user)
243 token = None
243 token = None
244
244
245 if res:
245 if res:
246 group, auth = res
246 group, auth = res
247
247
248 ui.debug(b"using auth.%s.* for authentication\n" % group)
248 ui.debug(b"using auth.%s.* for authentication\n" % group)
249
249
250 token = auth.get(b'phabtoken')
250 token = auth.get(b'phabtoken')
251
251
252 if not token:
252 if not token:
253 raise error.Abort(
253 raise error.Abort(
254 _(b'Can\'t find conduit token associated to %s') % (url,)
254 _(b'Can\'t find conduit token associated to %s') % (url,)
255 )
255 )
256
256
257 return url, token
257 return url, token
258
258
259
259
260 def callconduit(ui, name, params):
260 def callconduit(ui, name, params):
261 """call Conduit API, params is a dict. return json.loads result, or None"""
261 """call Conduit API, params is a dict. return json.loads result, or None"""
262 host, token = readurltoken(ui)
262 host, token = readurltoken(ui)
263 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
263 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
264 ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
264 ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
265 params = params.copy()
265 params = params.copy()
266 params[b'api.token'] = token
266 params[b'api.token'] = token
267 data = urlencodenested(params)
267 data = urlencodenested(params)
268 curlcmd = ui.config(b'phabricator', b'curlcmd')
268 curlcmd = ui.config(b'phabricator', b'curlcmd')
269 if curlcmd:
269 if curlcmd:
270 sin, sout = procutil.popen2(
270 sin, sout = procutil.popen2(
271 b'%s -d @- %s' % (curlcmd, procutil.shellquote(url))
271 b'%s -d @- %s' % (curlcmd, procutil.shellquote(url))
272 )
272 )
273 sin.write(data)
273 sin.write(data)
274 sin.close()
274 sin.close()
275 body = sout.read()
275 body = sout.read()
276 else:
276 else:
277 urlopener = urlmod.opener(ui, authinfo)
277 urlopener = urlmod.opener(ui, authinfo)
278 request = util.urlreq.request(pycompat.strurl(url), data=data)
278 request = util.urlreq.request(pycompat.strurl(url), data=data)
279 with contextlib.closing(urlopener.open(request)) as rsp:
279 with contextlib.closing(urlopener.open(request)) as rsp:
280 body = rsp.read()
280 body = rsp.read()
281 ui.debug(b'Conduit Response: %s\n' % body)
281 ui.debug(b'Conduit Response: %s\n' % body)
282 parsed = pycompat.rapply(
282 parsed = pycompat.rapply(
283 lambda x: encoding.unitolocal(x)
283 lambda x: encoding.unitolocal(x)
284 if isinstance(x, pycompat.unicode)
284 if isinstance(x, pycompat.unicode)
285 else x,
285 else x,
286 # json.loads only accepts bytes from py3.6+
286 # json.loads only accepts bytes from py3.6+
287 json.loads(encoding.unifromlocal(body)),
287 json.loads(encoding.unifromlocal(body)),
288 )
288 )
289 if parsed.get(b'error_code'):
289 if parsed.get(b'error_code'):
290 msg = _(b'Conduit Error (%s): %s') % (
290 msg = _(b'Conduit Error (%s): %s') % (
291 parsed[b'error_code'],
291 parsed[b'error_code'],
292 parsed[b'error_info'],
292 parsed[b'error_info'],
293 )
293 )
294 raise error.Abort(msg)
294 raise error.Abort(msg)
295 return parsed[b'result']
295 return parsed[b'result']
296
296
297
297
298 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
298 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
299 def debugcallconduit(ui, repo, name):
299 def debugcallconduit(ui, repo, name):
300 """call Conduit API
300 """call Conduit API
301
301
302 Call parameters are read from stdin as a JSON blob. Result will be written
302 Call parameters are read from stdin as a JSON blob. Result will be written
303 to stdout as a JSON blob.
303 to stdout as a JSON blob.
304 """
304 """
305 # json.loads only accepts bytes from 3.6+
305 # json.loads only accepts bytes from 3.6+
306 rawparams = encoding.unifromlocal(ui.fin.read())
306 rawparams = encoding.unifromlocal(ui.fin.read())
307 # json.loads only returns unicode strings
307 # json.loads only returns unicode strings
308 params = pycompat.rapply(
308 params = pycompat.rapply(
309 lambda x: encoding.unitolocal(x)
309 lambda x: encoding.unitolocal(x)
310 if isinstance(x, pycompat.unicode)
310 if isinstance(x, pycompat.unicode)
311 else x,
311 else x,
312 json.loads(rawparams),
312 json.loads(rawparams),
313 )
313 )
314 # json.dumps only accepts unicode strings
314 # json.dumps only accepts unicode strings
315 result = pycompat.rapply(
315 result = pycompat.rapply(
316 lambda x: encoding.unifromlocal(x) if isinstance(x, bytes) else x,
316 lambda x: encoding.unifromlocal(x) if isinstance(x, bytes) else x,
317 callconduit(ui, name, params),
317 callconduit(ui, name, params),
318 )
318 )
319 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
319 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
320 ui.write(b'%s\n' % encoding.unitolocal(s))
320 ui.write(b'%s\n' % encoding.unitolocal(s))
321
321
322
322
323 def getrepophid(repo):
323 def getrepophid(repo):
324 """given callsign, return repository PHID or None"""
324 """given callsign, return repository PHID or None"""
325 # developer config: phabricator.repophid
325 # developer config: phabricator.repophid
326 repophid = repo.ui.config(b'phabricator', b'repophid')
326 repophid = repo.ui.config(b'phabricator', b'repophid')
327 if repophid:
327 if repophid:
328 return repophid
328 return repophid
329 callsign = repo.ui.config(b'phabricator', b'callsign')
329 callsign = repo.ui.config(b'phabricator', b'callsign')
330 if not callsign:
330 if not callsign:
331 return None
331 return None
332 query = callconduit(
332 query = callconduit(
333 repo.ui,
333 repo.ui,
334 b'diffusion.repository.search',
334 b'diffusion.repository.search',
335 {b'constraints': {b'callsigns': [callsign]}},
335 {b'constraints': {b'callsigns': [callsign]}},
336 )
336 )
337 if len(query[b'data']) == 0:
337 if len(query[b'data']) == 0:
338 return None
338 return None
339 repophid = query[b'data'][0][b'phid']
339 repophid = query[b'data'][0][b'phid']
340 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
340 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
341 return repophid
341 return repophid
342
342
343
343
344 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
344 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
345 _differentialrevisiondescre = re.compile(
345 _differentialrevisiondescre = re.compile(
346 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M
346 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M
347 )
347 )
348
348
349
349
350 def getoldnodedrevmap(repo, nodelist):
350 def getoldnodedrevmap(repo, nodelist):
351 """find previous nodes that has been sent to Phabricator
351 """find previous nodes that has been sent to Phabricator
352
352
353 return {node: (oldnode, Differential diff, Differential Revision ID)}
353 return {node: (oldnode, Differential diff, Differential Revision ID)}
354 for node in nodelist with known previous sent versions, or associated
354 for node in nodelist with known previous sent versions, or associated
355 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
355 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
356 be ``None``.
356 be ``None``.
357
357
358 Examines commit messages like "Differential Revision:" to get the
358 Examines commit messages like "Differential Revision:" to get the
359 association information.
359 association information.
360
360
361 If such commit message line is not found, examines all precursors and their
361 If such commit message line is not found, examines all precursors and their
362 tags. Tags with format like "D1234" are considered a match and the node
362 tags. Tags with format like "D1234" are considered a match and the node
363 with that tag, and the number after "D" (ex. 1234) will be returned.
363 with that tag, and the number after "D" (ex. 1234) will be returned.
364
364
365 The ``old node``, if not None, is guaranteed to be the last diff of
365 The ``old node``, if not None, is guaranteed to be the last diff of
366 corresponding Differential Revision, and exist in the repo.
366 corresponding Differential Revision, and exist in the repo.
367 """
367 """
368 unfi = repo.unfiltered()
368 unfi = repo.unfiltered()
369 nodemap = unfi.changelog.nodemap
369 nodemap = unfi.changelog.nodemap
370
370
371 result = {} # {node: (oldnode?, lastdiff?, drev)}
371 result = {} # {node: (oldnode?, lastdiff?, drev)}
372 toconfirm = {} # {node: (force, {precnode}, drev)}
372 toconfirm = {} # {node: (force, {precnode}, drev)}
373 for node in nodelist:
373 for node in nodelist:
374 ctx = unfi[node]
374 ctx = unfi[node]
375 # For tags like "D123", put them into "toconfirm" to verify later
375 # For tags like "D123", put them into "toconfirm" to verify later
376 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
376 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
377 for n in precnodes:
377 for n in precnodes:
378 if n in nodemap:
378 if n in nodemap:
379 for tag in unfi.nodetags(n):
379 for tag in unfi.nodetags(n):
380 m = _differentialrevisiontagre.match(tag)
380 m = _differentialrevisiontagre.match(tag)
381 if m:
381 if m:
382 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
382 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
383 continue
383 continue
384
384
385 # Check commit message
385 # Check commit message
386 m = _differentialrevisiondescre.search(ctx.description())
386 m = _differentialrevisiondescre.search(ctx.description())
387 if m:
387 if m:
388 toconfirm[node] = (1, set(precnodes), int(m.group(r'id')))
388 toconfirm[node] = (1, set(precnodes), int(m.group(r'id')))
389
389
390 # Double check if tags are genuine by collecting all old nodes from
390 # Double check if tags are genuine by collecting all old nodes from
391 # Phabricator, and expect precursors overlap with it.
391 # Phabricator, and expect precursors overlap with it.
392 if toconfirm:
392 if toconfirm:
393 drevs = [drev for force, precs, drev in toconfirm.values()]
393 drevs = [drev for force, precs, drev in toconfirm.values()]
394 alldiffs = callconduit(
394 alldiffs = callconduit(
395 unfi.ui, b'differential.querydiffs', {b'revisionIDs': drevs}
395 unfi.ui, b'differential.querydiffs', {b'revisionIDs': drevs}
396 )
396 )
397 getnode = lambda d: bin(getdiffmeta(d).get(b'node', b'')) or None
397 getnode = lambda d: bin(getdiffmeta(d).get(b'node', b'')) or None
398 for newnode, (force, precset, drev) in toconfirm.items():
398 for newnode, (force, precset, drev) in toconfirm.items():
399 diffs = [
399 diffs = [
400 d for d in alldiffs.values() if int(d[b'revisionID']) == drev
400 d for d in alldiffs.values() if int(d[b'revisionID']) == drev
401 ]
401 ]
402
402
403 # "precursors" as known by Phabricator
403 # "precursors" as known by Phabricator
404 phprecset = set(getnode(d) for d in diffs)
404 phprecset = set(getnode(d) for d in diffs)
405
405
406 # Ignore if precursors (Phabricator and local repo) do not overlap,
406 # Ignore if precursors (Phabricator and local repo) do not overlap,
407 # and force is not set (when commit message says nothing)
407 # and force is not set (when commit message says nothing)
408 if not force and not bool(phprecset & precset):
408 if not force and not bool(phprecset & precset):
409 tagname = b'D%d' % drev
409 tagname = b'D%d' % drev
410 tags.tag(
410 tags.tag(
411 repo,
411 repo,
412 tagname,
412 tagname,
413 nullid,
413 nullid,
414 message=None,
414 message=None,
415 user=None,
415 user=None,
416 date=None,
416 date=None,
417 local=True,
417 local=True,
418 )
418 )
419 unfi.ui.warn(
419 unfi.ui.warn(
420 _(
420 _(
421 b'D%s: local tag removed - does not match '
421 b'D%s: local tag removed - does not match '
422 b'Differential history\n'
422 b'Differential history\n'
423 )
423 )
424 % drev
424 % drev
425 )
425 )
426 continue
426 continue
427
427
428 # Find the last node using Phabricator metadata, and make sure it
428 # Find the last node using Phabricator metadata, and make sure it
429 # exists in the repo
429 # exists in the repo
430 oldnode = lastdiff = None
430 oldnode = lastdiff = None
431 if diffs:
431 if diffs:
432 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
432 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
433 oldnode = getnode(lastdiff)
433 oldnode = getnode(lastdiff)
434 if oldnode and oldnode not in nodemap:
434 if oldnode and oldnode not in nodemap:
435 oldnode = None
435 oldnode = None
436
436
437 result[newnode] = (oldnode, lastdiff, drev)
437 result[newnode] = (oldnode, lastdiff, drev)
438
438
439 return result
439 return result
440
440
441
441
442 def getdiff(ctx, diffopts):
442 def getdiff(ctx, diffopts):
443 """plain-text diff without header (user, commit message, etc)"""
443 """plain-text diff without header (user, commit message, etc)"""
444 output = util.stringio()
444 output = util.stringio()
445 for chunk, _label in patch.diffui(
445 for chunk, _label in patch.diffui(
446 ctx.repo(), ctx.p1().node(), ctx.node(), None, opts=diffopts
446 ctx.repo(), ctx.p1().node(), ctx.node(), None, opts=diffopts
447 ):
447 ):
448 output.write(chunk)
448 output.write(chunk)
449 return output.getvalue()
449 return output.getvalue()
450
450
451
451
452 class DiffChangeType(object):
452 class DiffChangeType(object):
453 ADD = 1
453 ADD = 1
454 CHANGE = 2
454 CHANGE = 2
455 DELETE = 3
455 DELETE = 3
456 MOVE_AWAY = 4
456 MOVE_AWAY = 4
457 COPY_AWAY = 5
457 COPY_AWAY = 5
458 MOVE_HERE = 6
458 MOVE_HERE = 6
459 COPY_HERE = 7
459 COPY_HERE = 7
460 MULTICOPY = 8
460 MULTICOPY = 8
461
461
462
462
463 class DiffFileType(object):
463 class DiffFileType(object):
464 TEXT = 1
464 TEXT = 1
465 IMAGE = 2
465 IMAGE = 2
466 BINARY = 3
466 BINARY = 3
467
467
468
468
469 @attr.s
469 @attr.s
470 class phabhunk(dict):
470 class phabhunk(dict):
471 """Represents a Differential hunk, which is owned by a Differential change
471 """Represents a Differential hunk, which is owned by a Differential change
472 """
472 """
473
473
474 oldOffset = attr.ib(default=0) # camelcase-required
474 oldOffset = attr.ib(default=0) # camelcase-required
475 oldLength = attr.ib(default=0) # camelcase-required
475 oldLength = attr.ib(default=0) # camelcase-required
476 newOffset = attr.ib(default=0) # camelcase-required
476 newOffset = attr.ib(default=0) # camelcase-required
477 newLength = attr.ib(default=0) # camelcase-required
477 newLength = attr.ib(default=0) # camelcase-required
478 corpus = attr.ib(default='')
478 corpus = attr.ib(default='')
479 # These get added to the phabchange's equivalents
479 # These get added to the phabchange's equivalents
480 addLines = attr.ib(default=0) # camelcase-required
480 addLines = attr.ib(default=0) # camelcase-required
481 delLines = attr.ib(default=0) # camelcase-required
481 delLines = attr.ib(default=0) # camelcase-required
482
482
483
483
484 @attr.s
485 class phabchange(object):
486 """Represents a Differential change, owns Differential hunks and owned by a
487 Differential diff. Each one represents one file in a diff.
488 """
489
490 currentPath = attr.ib(default=None) # camelcase-required
491 oldPath = attr.ib(default=None) # camelcase-required
492 awayPaths = attr.ib(default=attr.Factory(list)) # camelcase-required
493 metadata = attr.ib(default=attr.Factory(dict))
494 oldProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
495 newProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
496 type = attr.ib(default=DiffChangeType.CHANGE)
497 fileType = attr.ib(default=DiffFileType.TEXT) # camelcase-required
498 commitHash = attr.ib(default=None) # camelcase-required
499 addLines = attr.ib(default=0) # camelcase-required
500 delLines = attr.ib(default=0) # camelcase-required
501 hunks = attr.ib(default=attr.Factory(list))
502
503 def copynewmetadatatoold(self):
504 for key in list(self.metadata.keys()):
505 newkey = key.replace(b'new:', b'old:')
506 self.metadata[newkey] = self.metadata[key]
507
508 def addoldmode(self, value):
509 self.oldProperties[b'unix:filemode'] = value
510
511 def addnewmode(self, value):
512 self.newProperties[b'unix:filemode'] = value
513
514 def addhunk(self, hunk):
515 if not isinstance(hunk, phabhunk):
516 raise error.Abort(b'phabchange.addhunk only takes phabhunks')
517 self.hunks.append(hunk)
518 # It's useful to include these stats since the Phab web UI shows them,
519 # and uses them to estimate how large a change a Revision is. Also used
520 # in email subjects for the [+++--] bit.
521 self.addLines += hunk.addLines
522 self.delLines += hunk.delLines
523
524
484 def creatediff(ctx):
525 def creatediff(ctx):
485 """create a Differential Diff"""
526 """create a Differential Diff"""
486 repo = ctx.repo()
527 repo = ctx.repo()
487 repophid = getrepophid(repo)
528 repophid = getrepophid(repo)
488 # Create a "Differential Diff" via "differential.createrawdiff" API
529 # Create a "Differential Diff" via "differential.createrawdiff" API
489 params = {b'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
530 params = {b'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
490 if repophid:
531 if repophid:
491 params[b'repositoryPHID'] = repophid
532 params[b'repositoryPHID'] = repophid
492 diff = callconduit(repo.ui, b'differential.createrawdiff', params)
533 diff = callconduit(repo.ui, b'differential.createrawdiff', params)
493 if not diff:
534 if not diff:
494 raise error.Abort(_(b'cannot create diff for %s') % ctx)
535 raise error.Abort(_(b'cannot create diff for %s') % ctx)
495 return diff
536 return diff
496
537
497
538
498 def writediffproperties(ctx, diff):
539 def writediffproperties(ctx, diff):
499 """write metadata to diff so patches could be applied losslessly"""
540 """write metadata to diff so patches could be applied losslessly"""
500 params = {
541 params = {
501 b'diff_id': diff[b'id'],
542 b'diff_id': diff[b'id'],
502 b'name': b'hg:meta',
543 b'name': b'hg:meta',
503 b'data': templatefilters.json(
544 b'data': templatefilters.json(
504 {
545 {
505 b'user': ctx.user(),
546 b'user': ctx.user(),
506 b'date': b'%d %d' % ctx.date(),
547 b'date': b'%d %d' % ctx.date(),
507 b'branch': ctx.branch(),
548 b'branch': ctx.branch(),
508 b'node': ctx.hex(),
549 b'node': ctx.hex(),
509 b'parent': ctx.p1().hex(),
550 b'parent': ctx.p1().hex(),
510 }
551 }
511 ),
552 ),
512 }
553 }
513 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
554 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
514
555
515 params = {
556 params = {
516 b'diff_id': diff[b'id'],
557 b'diff_id': diff[b'id'],
517 b'name': b'local:commits',
558 b'name': b'local:commits',
518 b'data': templatefilters.json(
559 b'data': templatefilters.json(
519 {
560 {
520 ctx.hex(): {
561 ctx.hex(): {
521 b'author': stringutil.person(ctx.user()),
562 b'author': stringutil.person(ctx.user()),
522 b'authorEmail': stringutil.email(ctx.user()),
563 b'authorEmail': stringutil.email(ctx.user()),
523 b'time': int(ctx.date()[0]),
564 b'time': int(ctx.date()[0]),
524 b'commit': ctx.hex(),
565 b'commit': ctx.hex(),
525 b'parents': [ctx.p1().hex()],
566 b'parents': [ctx.p1().hex()],
526 b'branch': ctx.branch(),
567 b'branch': ctx.branch(),
527 },
568 },
528 }
569 }
529 ),
570 ),
530 }
571 }
531 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
572 callconduit(ctx.repo().ui, b'differential.setdiffproperty', params)
532
573
533
574
534 def createdifferentialrevision(
575 def createdifferentialrevision(
535 ctx,
576 ctx,
536 revid=None,
577 revid=None,
537 parentrevphid=None,
578 parentrevphid=None,
538 oldnode=None,
579 oldnode=None,
539 olddiff=None,
580 olddiff=None,
540 actions=None,
581 actions=None,
541 comment=None,
582 comment=None,
542 ):
583 ):
543 """create or update a Differential Revision
584 """create or update a Differential Revision
544
585
545 If revid is None, create a new Differential Revision, otherwise update
586 If revid is None, create a new Differential Revision, otherwise update
546 revid. If parentrevphid is not None, set it as a dependency.
587 revid. If parentrevphid is not None, set it as a dependency.
547
588
548 If oldnode is not None, check if the patch content (without commit message
589 If oldnode is not None, check if the patch content (without commit message
549 and metadata) has changed before creating another diff.
590 and metadata) has changed before creating another diff.
550
591
551 If actions is not None, they will be appended to the transaction.
592 If actions is not None, they will be appended to the transaction.
552 """
593 """
553 repo = ctx.repo()
594 repo = ctx.repo()
554 if oldnode:
595 if oldnode:
555 diffopts = mdiff.diffopts(git=True, context=32767)
596 diffopts = mdiff.diffopts(git=True, context=32767)
556 oldctx = repo.unfiltered()[oldnode]
597 oldctx = repo.unfiltered()[oldnode]
557 neednewdiff = getdiff(ctx, diffopts) != getdiff(oldctx, diffopts)
598 neednewdiff = getdiff(ctx, diffopts) != getdiff(oldctx, diffopts)
558 else:
599 else:
559 neednewdiff = True
600 neednewdiff = True
560
601
561 transactions = []
602 transactions = []
562 if neednewdiff:
603 if neednewdiff:
563 diff = creatediff(ctx)
604 diff = creatediff(ctx)
564 transactions.append({b'type': b'update', b'value': diff[b'phid']})
605 transactions.append({b'type': b'update', b'value': diff[b'phid']})
565 if comment:
606 if comment:
566 transactions.append({b'type': b'comment', b'value': comment})
607 transactions.append({b'type': b'comment', b'value': comment})
567 else:
608 else:
568 # Even if we don't need to upload a new diff because the patch content
609 # Even if we don't need to upload a new diff because the patch content
569 # does not change. We might still need to update its metadata so
610 # does not change. We might still need to update its metadata so
570 # pushers could know the correct node metadata.
611 # pushers could know the correct node metadata.
571 assert olddiff
612 assert olddiff
572 diff = olddiff
613 diff = olddiff
573 writediffproperties(ctx, diff)
614 writediffproperties(ctx, diff)
574
615
575 # Set the parent Revision every time, so commit re-ordering is picked-up
616 # Set the parent Revision every time, so commit re-ordering is picked-up
576 if parentrevphid:
617 if parentrevphid:
577 transactions.append(
618 transactions.append(
578 {b'type': b'parents.set', b'value': [parentrevphid]}
619 {b'type': b'parents.set', b'value': [parentrevphid]}
579 )
620 )
580
621
581 if actions:
622 if actions:
582 transactions += actions
623 transactions += actions
583
624
584 # Parse commit message and update related fields.
625 # Parse commit message and update related fields.
585 desc = ctx.description()
626 desc = ctx.description()
586 info = callconduit(
627 info = callconduit(
587 repo.ui, b'differential.parsecommitmessage', {b'corpus': desc}
628 repo.ui, b'differential.parsecommitmessage', {b'corpus': desc}
588 )
629 )
589 for k, v in info[b'fields'].items():
630 for k, v in info[b'fields'].items():
590 if k in [b'title', b'summary', b'testPlan']:
631 if k in [b'title', b'summary', b'testPlan']:
591 transactions.append({b'type': k, b'value': v})
632 transactions.append({b'type': k, b'value': v})
592
633
593 params = {b'transactions': transactions}
634 params = {b'transactions': transactions}
594 if revid is not None:
635 if revid is not None:
595 # Update an existing Differential Revision
636 # Update an existing Differential Revision
596 params[b'objectIdentifier'] = revid
637 params[b'objectIdentifier'] = revid
597
638
598 revision = callconduit(repo.ui, b'differential.revision.edit', params)
639 revision = callconduit(repo.ui, b'differential.revision.edit', params)
599 if not revision:
640 if not revision:
600 raise error.Abort(_(b'cannot create revision for %s') % ctx)
641 raise error.Abort(_(b'cannot create revision for %s') % ctx)
601
642
602 return revision, diff
643 return revision, diff
603
644
604
645
605 def userphids(repo, names):
646 def userphids(repo, names):
606 """convert user names to PHIDs"""
647 """convert user names to PHIDs"""
607 names = [name.lower() for name in names]
648 names = [name.lower() for name in names]
608 query = {b'constraints': {b'usernames': names}}
649 query = {b'constraints': {b'usernames': names}}
609 result = callconduit(repo.ui, b'user.search', query)
650 result = callconduit(repo.ui, b'user.search', query)
610 # username not found is not an error of the API. So check if we have missed
651 # username not found is not an error of the API. So check if we have missed
611 # some names here.
652 # some names here.
612 data = result[b'data']
653 data = result[b'data']
613 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
654 resolved = set(entry[b'fields'][b'username'].lower() for entry in data)
614 unresolved = set(names) - resolved
655 unresolved = set(names) - resolved
615 if unresolved:
656 if unresolved:
616 raise error.Abort(
657 raise error.Abort(
617 _(b'unknown username: %s') % b' '.join(sorted(unresolved))
658 _(b'unknown username: %s') % b' '.join(sorted(unresolved))
618 )
659 )
619 return [entry[b'phid'] for entry in data]
660 return [entry[b'phid'] for entry in data]
620
661
621
662
622 @vcrcommand(
663 @vcrcommand(
623 b'phabsend',
664 b'phabsend',
624 [
665 [
625 (b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
666 (b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
626 (b'', b'amend', True, _(b'update commit messages')),
667 (b'', b'amend', True, _(b'update commit messages')),
627 (b'', b'reviewer', [], _(b'specify reviewers')),
668 (b'', b'reviewer', [], _(b'specify reviewers')),
628 (b'', b'blocker', [], _(b'specify blocking reviewers')),
669 (b'', b'blocker', [], _(b'specify blocking reviewers')),
629 (
670 (
630 b'm',
671 b'm',
631 b'comment',
672 b'comment',
632 b'',
673 b'',
633 _(b'add a comment to Revisions with new/updated Diffs'),
674 _(b'add a comment to Revisions with new/updated Diffs'),
634 ),
675 ),
635 (b'', b'confirm', None, _(b'ask for confirmation before sending')),
676 (b'', b'confirm', None, _(b'ask for confirmation before sending')),
636 ],
677 ],
637 _(b'REV [OPTIONS]'),
678 _(b'REV [OPTIONS]'),
638 helpcategory=command.CATEGORY_IMPORT_EXPORT,
679 helpcategory=command.CATEGORY_IMPORT_EXPORT,
639 )
680 )
640 def phabsend(ui, repo, *revs, **opts):
681 def phabsend(ui, repo, *revs, **opts):
641 """upload changesets to Phabricator
682 """upload changesets to Phabricator
642
683
643 If there are multiple revisions specified, they will be send as a stack
684 If there are multiple revisions specified, they will be send as a stack
644 with a linear dependencies relationship using the order specified by the
685 with a linear dependencies relationship using the order specified by the
645 revset.
686 revset.
646
687
647 For the first time uploading changesets, local tags will be created to
688 For the first time uploading changesets, local tags will be created to
648 maintain the association. After the first time, phabsend will check
689 maintain the association. After the first time, phabsend will check
649 obsstore and tags information so it can figure out whether to update an
690 obsstore and tags information so it can figure out whether to update an
650 existing Differential Revision, or create a new one.
691 existing Differential Revision, or create a new one.
651
692
652 If --amend is set, update commit messages so they have the
693 If --amend is set, update commit messages so they have the
653 ``Differential Revision`` URL, remove related tags. This is similar to what
694 ``Differential Revision`` URL, remove related tags. This is similar to what
654 arcanist will do, and is more desired in author-push workflows. Otherwise,
695 arcanist will do, and is more desired in author-push workflows. Otherwise,
655 use local tags to record the ``Differential Revision`` association.
696 use local tags to record the ``Differential Revision`` association.
656
697
657 The --confirm option lets you confirm changesets before sending them. You
698 The --confirm option lets you confirm changesets before sending them. You
658 can also add following to your configuration file to make it default
699 can also add following to your configuration file to make it default
659 behaviour::
700 behaviour::
660
701
661 [phabsend]
702 [phabsend]
662 confirm = true
703 confirm = true
663
704
664 phabsend will check obsstore and the above association to decide whether to
705 phabsend will check obsstore and the above association to decide whether to
665 update an existing Differential Revision, or create a new one.
706 update an existing Differential Revision, or create a new one.
666 """
707 """
667 opts = pycompat.byteskwargs(opts)
708 opts = pycompat.byteskwargs(opts)
668 revs = list(revs) + opts.get(b'rev', [])
709 revs = list(revs) + opts.get(b'rev', [])
669 revs = scmutil.revrange(repo, revs)
710 revs = scmutil.revrange(repo, revs)
670
711
671 if not revs:
712 if not revs:
672 raise error.Abort(_(b'phabsend requires at least one changeset'))
713 raise error.Abort(_(b'phabsend requires at least one changeset'))
673 if opts.get(b'amend'):
714 if opts.get(b'amend'):
674 cmdutil.checkunfinished(repo)
715 cmdutil.checkunfinished(repo)
675
716
676 # {newnode: (oldnode, olddiff, olddrev}
717 # {newnode: (oldnode, olddiff, olddrev}
677 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
718 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
678
719
679 confirm = ui.configbool(b'phabsend', b'confirm')
720 confirm = ui.configbool(b'phabsend', b'confirm')
680 confirm |= bool(opts.get(b'confirm'))
721 confirm |= bool(opts.get(b'confirm'))
681 if confirm:
722 if confirm:
682 confirmed = _confirmbeforesend(repo, revs, oldmap)
723 confirmed = _confirmbeforesend(repo, revs, oldmap)
683 if not confirmed:
724 if not confirmed:
684 raise error.Abort(_(b'phabsend cancelled'))
725 raise error.Abort(_(b'phabsend cancelled'))
685
726
686 actions = []
727 actions = []
687 reviewers = opts.get(b'reviewer', [])
728 reviewers = opts.get(b'reviewer', [])
688 blockers = opts.get(b'blocker', [])
729 blockers = opts.get(b'blocker', [])
689 phids = []
730 phids = []
690 if reviewers:
731 if reviewers:
691 phids.extend(userphids(repo, reviewers))
732 phids.extend(userphids(repo, reviewers))
692 if blockers:
733 if blockers:
693 phids.extend(
734 phids.extend(
694 map(lambda phid: b'blocking(%s)' % phid, userphids(repo, blockers))
735 map(lambda phid: b'blocking(%s)' % phid, userphids(repo, blockers))
695 )
736 )
696 if phids:
737 if phids:
697 actions.append({b'type': b'reviewers.add', b'value': phids})
738 actions.append({b'type': b'reviewers.add', b'value': phids})
698
739
699 drevids = [] # [int]
740 drevids = [] # [int]
700 diffmap = {} # {newnode: diff}
741 diffmap = {} # {newnode: diff}
701
742
702 # Send patches one by one so we know their Differential Revision PHIDs and
743 # Send patches one by one so we know their Differential Revision PHIDs and
703 # can provide dependency relationship
744 # can provide dependency relationship
704 lastrevphid = None
745 lastrevphid = None
705 for rev in revs:
746 for rev in revs:
706 ui.debug(b'sending rev %d\n' % rev)
747 ui.debug(b'sending rev %d\n' % rev)
707 ctx = repo[rev]
748 ctx = repo[rev]
708
749
709 # Get Differential Revision ID
750 # Get Differential Revision ID
710 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
751 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
711 if oldnode != ctx.node() or opts.get(b'amend'):
752 if oldnode != ctx.node() or opts.get(b'amend'):
712 # Create or update Differential Revision
753 # Create or update Differential Revision
713 revision, diff = createdifferentialrevision(
754 revision, diff = createdifferentialrevision(
714 ctx,
755 ctx,
715 revid,
756 revid,
716 lastrevphid,
757 lastrevphid,
717 oldnode,
758 oldnode,
718 olddiff,
759 olddiff,
719 actions,
760 actions,
720 opts.get(b'comment'),
761 opts.get(b'comment'),
721 )
762 )
722 diffmap[ctx.node()] = diff
763 diffmap[ctx.node()] = diff
723 newrevid = int(revision[b'object'][b'id'])
764 newrevid = int(revision[b'object'][b'id'])
724 newrevphid = revision[b'object'][b'phid']
765 newrevphid = revision[b'object'][b'phid']
725 if revid:
766 if revid:
726 action = b'updated'
767 action = b'updated'
727 else:
768 else:
728 action = b'created'
769 action = b'created'
729
770
730 # Create a local tag to note the association, if commit message
771 # Create a local tag to note the association, if commit message
731 # does not have it already
772 # does not have it already
732 m = _differentialrevisiondescre.search(ctx.description())
773 m = _differentialrevisiondescre.search(ctx.description())
733 if not m or int(m.group(r'id')) != newrevid:
774 if not m or int(m.group(r'id')) != newrevid:
734 tagname = b'D%d' % newrevid
775 tagname = b'D%d' % newrevid
735 tags.tag(
776 tags.tag(
736 repo,
777 repo,
737 tagname,
778 tagname,
738 ctx.node(),
779 ctx.node(),
739 message=None,
780 message=None,
740 user=None,
781 user=None,
741 date=None,
782 date=None,
742 local=True,
783 local=True,
743 )
784 )
744 else:
785 else:
745 # Nothing changed. But still set "newrevphid" so the next revision
786 # Nothing changed. But still set "newrevphid" so the next revision
746 # could depend on this one and "newrevid" for the summary line.
787 # could depend on this one and "newrevid" for the summary line.
747 newrevphid = querydrev(repo, b'%d' % revid)[0][b'phid']
788 newrevphid = querydrev(repo, b'%d' % revid)[0][b'phid']
748 newrevid = revid
789 newrevid = revid
749 action = b'skipped'
790 action = b'skipped'
750
791
751 actiondesc = ui.label(
792 actiondesc = ui.label(
752 {
793 {
753 b'created': _(b'created'),
794 b'created': _(b'created'),
754 b'skipped': _(b'skipped'),
795 b'skipped': _(b'skipped'),
755 b'updated': _(b'updated'),
796 b'updated': _(b'updated'),
756 }[action],
797 }[action],
757 b'phabricator.action.%s' % action,
798 b'phabricator.action.%s' % action,
758 )
799 )
759 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
800 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
760 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
801 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
761 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
802 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
762 ui.write(
803 ui.write(
763 _(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc, desc)
804 _(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc, desc)
764 )
805 )
765 drevids.append(newrevid)
806 drevids.append(newrevid)
766 lastrevphid = newrevphid
807 lastrevphid = newrevphid
767
808
768 # Update commit messages and remove tags
809 # Update commit messages and remove tags
769 if opts.get(b'amend'):
810 if opts.get(b'amend'):
770 unfi = repo.unfiltered()
811 unfi = repo.unfiltered()
771 drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
812 drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
772 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
813 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
773 wnode = unfi[b'.'].node()
814 wnode = unfi[b'.'].node()
774 mapping = {} # {oldnode: [newnode]}
815 mapping = {} # {oldnode: [newnode]}
775 for i, rev in enumerate(revs):
816 for i, rev in enumerate(revs):
776 old = unfi[rev]
817 old = unfi[rev]
777 drevid = drevids[i]
818 drevid = drevids[i]
778 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
819 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
779 newdesc = getdescfromdrev(drev)
820 newdesc = getdescfromdrev(drev)
780 # Make sure commit message contain "Differential Revision"
821 # Make sure commit message contain "Differential Revision"
781 if old.description() != newdesc:
822 if old.description() != newdesc:
782 if old.phase() == phases.public:
823 if old.phase() == phases.public:
783 ui.warn(
824 ui.warn(
784 _(b"warning: not updating public commit %s\n")
825 _(b"warning: not updating public commit %s\n")
785 % scmutil.formatchangeid(old)
826 % scmutil.formatchangeid(old)
786 )
827 )
787 continue
828 continue
788 parents = [
829 parents = [
789 mapping.get(old.p1().node(), (old.p1(),))[0],
830 mapping.get(old.p1().node(), (old.p1(),))[0],
790 mapping.get(old.p2().node(), (old.p2(),))[0],
831 mapping.get(old.p2().node(), (old.p2(),))[0],
791 ]
832 ]
792 new = context.metadataonlyctx(
833 new = context.metadataonlyctx(
793 repo,
834 repo,
794 old,
835 old,
795 parents=parents,
836 parents=parents,
796 text=newdesc,
837 text=newdesc,
797 user=old.user(),
838 user=old.user(),
798 date=old.date(),
839 date=old.date(),
799 extra=old.extra(),
840 extra=old.extra(),
800 )
841 )
801
842
802 newnode = new.commit()
843 newnode = new.commit()
803
844
804 mapping[old.node()] = [newnode]
845 mapping[old.node()] = [newnode]
805 # Update diff property
846 # Update diff property
806 # If it fails just warn and keep going, otherwise the DREV
847 # If it fails just warn and keep going, otherwise the DREV
807 # associations will be lost
848 # associations will be lost
808 try:
849 try:
809 writediffproperties(unfi[newnode], diffmap[old.node()])
850 writediffproperties(unfi[newnode], diffmap[old.node()])
810 except util.urlerr.urlerror:
851 except util.urlerr.urlerror:
811 ui.warnnoi18n(
852 ui.warnnoi18n(
812 b'Failed to update metadata for D%s\n' % drevid
853 b'Failed to update metadata for D%s\n' % drevid
813 )
854 )
814 # Remove local tags since it's no longer necessary
855 # Remove local tags since it's no longer necessary
815 tagname = b'D%d' % drevid
856 tagname = b'D%d' % drevid
816 if tagname in repo.tags():
857 if tagname in repo.tags():
817 tags.tag(
858 tags.tag(
818 repo,
859 repo,
819 tagname,
860 tagname,
820 nullid,
861 nullid,
821 message=None,
862 message=None,
822 user=None,
863 user=None,
823 date=None,
864 date=None,
824 local=True,
865 local=True,
825 )
866 )
826 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
867 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
827 if wnode in mapping:
868 if wnode in mapping:
828 unfi.setparents(mapping[wnode][0])
869 unfi.setparents(mapping[wnode][0])
829
870
830
871
831 # Map from "hg:meta" keys to header understood by "hg import". The order is
872 # Map from "hg:meta" keys to header understood by "hg import". The order is
832 # consistent with "hg export" output.
873 # consistent with "hg export" output.
833 _metanamemap = util.sortdict(
874 _metanamemap = util.sortdict(
834 [
875 [
835 (b'user', b'User'),
876 (b'user', b'User'),
836 (b'date', b'Date'),
877 (b'date', b'Date'),
837 (b'branch', b'Branch'),
878 (b'branch', b'Branch'),
838 (b'node', b'Node ID'),
879 (b'node', b'Node ID'),
839 (b'parent', b'Parent '),
880 (b'parent', b'Parent '),
840 ]
881 ]
841 )
882 )
842
883
843
884
844 def _confirmbeforesend(repo, revs, oldmap):
885 def _confirmbeforesend(repo, revs, oldmap):
845 url, token = readurltoken(repo.ui)
886 url, token = readurltoken(repo.ui)
846 ui = repo.ui
887 ui = repo.ui
847 for rev in revs:
888 for rev in revs:
848 ctx = repo[rev]
889 ctx = repo[rev]
849 desc = ctx.description().splitlines()[0]
890 desc = ctx.description().splitlines()[0]
850 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
891 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
851 if drevid:
892 if drevid:
852 drevdesc = ui.label(b'D%s' % drevid, b'phabricator.drev')
893 drevdesc = ui.label(b'D%s' % drevid, b'phabricator.drev')
853 else:
894 else:
854 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
895 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
855
896
856 ui.write(
897 ui.write(
857 _(b'%s - %s: %s\n')
898 _(b'%s - %s: %s\n')
858 % (
899 % (
859 drevdesc,
900 drevdesc,
860 ui.label(bytes(ctx), b'phabricator.node'),
901 ui.label(bytes(ctx), b'phabricator.node'),
861 ui.label(desc, b'phabricator.desc'),
902 ui.label(desc, b'phabricator.desc'),
862 )
903 )
863 )
904 )
864
905
865 if ui.promptchoice(
906 if ui.promptchoice(
866 _(b'Send the above changes to %s (yn)?$$ &Yes $$ &No') % url
907 _(b'Send the above changes to %s (yn)?$$ &Yes $$ &No') % url
867 ):
908 ):
868 return False
909 return False
869
910
870 return True
911 return True
871
912
872
913
873 _knownstatusnames = {
914 _knownstatusnames = {
874 b'accepted',
915 b'accepted',
875 b'needsreview',
916 b'needsreview',
876 b'needsrevision',
917 b'needsrevision',
877 b'closed',
918 b'closed',
878 b'abandoned',
919 b'abandoned',
879 }
920 }
880
921
881
922
882 def _getstatusname(drev):
923 def _getstatusname(drev):
883 """get normalized status name from a Differential Revision"""
924 """get normalized status name from a Differential Revision"""
884 return drev[b'statusName'].replace(b' ', b'').lower()
925 return drev[b'statusName'].replace(b' ', b'').lower()
885
926
886
927
887 # Small language to specify differential revisions. Support symbols: (), :X,
928 # Small language to specify differential revisions. Support symbols: (), :X,
888 # +, and -.
929 # +, and -.
889
930
890 _elements = {
931 _elements = {
891 # token-type: binding-strength, primary, prefix, infix, suffix
932 # token-type: binding-strength, primary, prefix, infix, suffix
892 b'(': (12, None, (b'group', 1, b')'), None, None),
933 b'(': (12, None, (b'group', 1, b')'), None, None),
893 b':': (8, None, (b'ancestors', 8), None, None),
934 b':': (8, None, (b'ancestors', 8), None, None),
894 b'&': (5, None, None, (b'and_', 5), None),
935 b'&': (5, None, None, (b'and_', 5), None),
895 b'+': (4, None, None, (b'add', 4), None),
936 b'+': (4, None, None, (b'add', 4), None),
896 b'-': (4, None, None, (b'sub', 4), None),
937 b'-': (4, None, None, (b'sub', 4), None),
897 b')': (0, None, None, None, None),
938 b')': (0, None, None, None, None),
898 b'symbol': (0, b'symbol', None, None, None),
939 b'symbol': (0, b'symbol', None, None, None),
899 b'end': (0, None, None, None, None),
940 b'end': (0, None, None, None, None),
900 }
941 }
901
942
902
943
903 def _tokenize(text):
944 def _tokenize(text):
904 view = memoryview(text) # zero-copy slice
945 view = memoryview(text) # zero-copy slice
905 special = b'():+-& '
946 special = b'():+-& '
906 pos = 0
947 pos = 0
907 length = len(text)
948 length = len(text)
908 while pos < length:
949 while pos < length:
909 symbol = b''.join(
950 symbol = b''.join(
910 itertools.takewhile(
951 itertools.takewhile(
911 lambda ch: ch not in special, pycompat.iterbytestr(view[pos:])
952 lambda ch: ch not in special, pycompat.iterbytestr(view[pos:])
912 )
953 )
913 )
954 )
914 if symbol:
955 if symbol:
915 yield (b'symbol', symbol, pos)
956 yield (b'symbol', symbol, pos)
916 pos += len(symbol)
957 pos += len(symbol)
917 else: # special char, ignore space
958 else: # special char, ignore space
918 if text[pos] != b' ':
959 if text[pos] != b' ':
919 yield (text[pos], None, pos)
960 yield (text[pos], None, pos)
920 pos += 1
961 pos += 1
921 yield (b'end', None, pos)
962 yield (b'end', None, pos)
922
963
923
964
924 def _parse(text):
965 def _parse(text):
925 tree, pos = parser.parser(_elements).parse(_tokenize(text))
966 tree, pos = parser.parser(_elements).parse(_tokenize(text))
926 if pos != len(text):
967 if pos != len(text):
927 raise error.ParseError(b'invalid token', pos)
968 raise error.ParseError(b'invalid token', pos)
928 return tree
969 return tree
929
970
930
971
931 def _parsedrev(symbol):
972 def _parsedrev(symbol):
932 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
973 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
933 if symbol.startswith(b'D') and symbol[1:].isdigit():
974 if symbol.startswith(b'D') and symbol[1:].isdigit():
934 return int(symbol[1:])
975 return int(symbol[1:])
935 if symbol.isdigit():
976 if symbol.isdigit():
936 return int(symbol)
977 return int(symbol)
937
978
938
979
939 def _prefetchdrevs(tree):
980 def _prefetchdrevs(tree):
940 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
981 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
941 drevs = set()
982 drevs = set()
942 ancestordrevs = set()
983 ancestordrevs = set()
943 op = tree[0]
984 op = tree[0]
944 if op == b'symbol':
985 if op == b'symbol':
945 r = _parsedrev(tree[1])
986 r = _parsedrev(tree[1])
946 if r:
987 if r:
947 drevs.add(r)
988 drevs.add(r)
948 elif op == b'ancestors':
989 elif op == b'ancestors':
949 r, a = _prefetchdrevs(tree[1])
990 r, a = _prefetchdrevs(tree[1])
950 drevs.update(r)
991 drevs.update(r)
951 ancestordrevs.update(r)
992 ancestordrevs.update(r)
952 ancestordrevs.update(a)
993 ancestordrevs.update(a)
953 else:
994 else:
954 for t in tree[1:]:
995 for t in tree[1:]:
955 r, a = _prefetchdrevs(t)
996 r, a = _prefetchdrevs(t)
956 drevs.update(r)
997 drevs.update(r)
957 ancestordrevs.update(a)
998 ancestordrevs.update(a)
958 return drevs, ancestordrevs
999 return drevs, ancestordrevs
959
1000
960
1001
961 def querydrev(repo, spec):
1002 def querydrev(repo, spec):
962 """return a list of "Differential Revision" dicts
1003 """return a list of "Differential Revision" dicts
963
1004
964 spec is a string using a simple query language, see docstring in phabread
1005 spec is a string using a simple query language, see docstring in phabread
965 for details.
1006 for details.
966
1007
967 A "Differential Revision dict" looks like:
1008 A "Differential Revision dict" looks like:
968
1009
969 {
1010 {
970 "id": "2",
1011 "id": "2",
971 "phid": "PHID-DREV-672qvysjcczopag46qty",
1012 "phid": "PHID-DREV-672qvysjcczopag46qty",
972 "title": "example",
1013 "title": "example",
973 "uri": "https://phab.example.com/D2",
1014 "uri": "https://phab.example.com/D2",
974 "dateCreated": "1499181406",
1015 "dateCreated": "1499181406",
975 "dateModified": "1499182103",
1016 "dateModified": "1499182103",
976 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
1017 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
977 "status": "0",
1018 "status": "0",
978 "statusName": "Needs Review",
1019 "statusName": "Needs Review",
979 "properties": [],
1020 "properties": [],
980 "branch": null,
1021 "branch": null,
981 "summary": "",
1022 "summary": "",
982 "testPlan": "",
1023 "testPlan": "",
983 "lineCount": "2",
1024 "lineCount": "2",
984 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
1025 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
985 "diffs": [
1026 "diffs": [
986 "3",
1027 "3",
987 "4",
1028 "4",
988 ],
1029 ],
989 "commits": [],
1030 "commits": [],
990 "reviewers": [],
1031 "reviewers": [],
991 "ccs": [],
1032 "ccs": [],
992 "hashes": [],
1033 "hashes": [],
993 "auxiliary": {
1034 "auxiliary": {
994 "phabricator:projects": [],
1035 "phabricator:projects": [],
995 "phabricator:depends-on": [
1036 "phabricator:depends-on": [
996 "PHID-DREV-gbapp366kutjebt7agcd"
1037 "PHID-DREV-gbapp366kutjebt7agcd"
997 ]
1038 ]
998 },
1039 },
999 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
1040 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
1000 "sourcePath": null
1041 "sourcePath": null
1001 }
1042 }
1002 """
1043 """
1003
1044
1004 def fetch(params):
1045 def fetch(params):
1005 """params -> single drev or None"""
1046 """params -> single drev or None"""
1006 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
1047 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
1007 if key in prefetched:
1048 if key in prefetched:
1008 return prefetched[key]
1049 return prefetched[key]
1009 drevs = callconduit(repo.ui, b'differential.query', params)
1050 drevs = callconduit(repo.ui, b'differential.query', params)
1010 # Fill prefetched with the result
1051 # Fill prefetched with the result
1011 for drev in drevs:
1052 for drev in drevs:
1012 prefetched[drev[b'phid']] = drev
1053 prefetched[drev[b'phid']] = drev
1013 prefetched[int(drev[b'id'])] = drev
1054 prefetched[int(drev[b'id'])] = drev
1014 if key not in prefetched:
1055 if key not in prefetched:
1015 raise error.Abort(
1056 raise error.Abort(
1016 _(b'cannot get Differential Revision %r') % params
1057 _(b'cannot get Differential Revision %r') % params
1017 )
1058 )
1018 return prefetched[key]
1059 return prefetched[key]
1019
1060
1020 def getstack(topdrevids):
1061 def getstack(topdrevids):
1021 """given a top, get a stack from the bottom, [id] -> [id]"""
1062 """given a top, get a stack from the bottom, [id] -> [id]"""
1022 visited = set()
1063 visited = set()
1023 result = []
1064 result = []
1024 queue = [{b'ids': [i]} for i in topdrevids]
1065 queue = [{b'ids': [i]} for i in topdrevids]
1025 while queue:
1066 while queue:
1026 params = queue.pop()
1067 params = queue.pop()
1027 drev = fetch(params)
1068 drev = fetch(params)
1028 if drev[b'id'] in visited:
1069 if drev[b'id'] in visited:
1029 continue
1070 continue
1030 visited.add(drev[b'id'])
1071 visited.add(drev[b'id'])
1031 result.append(int(drev[b'id']))
1072 result.append(int(drev[b'id']))
1032 auxiliary = drev.get(b'auxiliary', {})
1073 auxiliary = drev.get(b'auxiliary', {})
1033 depends = auxiliary.get(b'phabricator:depends-on', [])
1074 depends = auxiliary.get(b'phabricator:depends-on', [])
1034 for phid in depends:
1075 for phid in depends:
1035 queue.append({b'phids': [phid]})
1076 queue.append({b'phids': [phid]})
1036 result.reverse()
1077 result.reverse()
1037 return smartset.baseset(result)
1078 return smartset.baseset(result)
1038
1079
1039 # Initialize prefetch cache
1080 # Initialize prefetch cache
1040 prefetched = {} # {id or phid: drev}
1081 prefetched = {} # {id or phid: drev}
1041
1082
1042 tree = _parse(spec)
1083 tree = _parse(spec)
1043 drevs, ancestordrevs = _prefetchdrevs(tree)
1084 drevs, ancestordrevs = _prefetchdrevs(tree)
1044
1085
1045 # developer config: phabricator.batchsize
1086 # developer config: phabricator.batchsize
1046 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
1087 batchsize = repo.ui.configint(b'phabricator', b'batchsize')
1047
1088
1048 # Prefetch Differential Revisions in batch
1089 # Prefetch Differential Revisions in batch
1049 tofetch = set(drevs)
1090 tofetch = set(drevs)
1050 for r in ancestordrevs:
1091 for r in ancestordrevs:
1051 tofetch.update(range(max(1, r - batchsize), r + 1))
1092 tofetch.update(range(max(1, r - batchsize), r + 1))
1052 if drevs:
1093 if drevs:
1053 fetch({b'ids': list(tofetch)})
1094 fetch({b'ids': list(tofetch)})
1054 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
1095 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
1055
1096
1056 # Walk through the tree, return smartsets
1097 # Walk through the tree, return smartsets
1057 def walk(tree):
1098 def walk(tree):
1058 op = tree[0]
1099 op = tree[0]
1059 if op == b'symbol':
1100 if op == b'symbol':
1060 drev = _parsedrev(tree[1])
1101 drev = _parsedrev(tree[1])
1061 if drev:
1102 if drev:
1062 return smartset.baseset([drev])
1103 return smartset.baseset([drev])
1063 elif tree[1] in _knownstatusnames:
1104 elif tree[1] in _knownstatusnames:
1064 drevs = [
1105 drevs = [
1065 r
1106 r
1066 for r in validids
1107 for r in validids
1067 if _getstatusname(prefetched[r]) == tree[1]
1108 if _getstatusname(prefetched[r]) == tree[1]
1068 ]
1109 ]
1069 return smartset.baseset(drevs)
1110 return smartset.baseset(drevs)
1070 else:
1111 else:
1071 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
1112 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
1072 elif op in {b'and_', b'add', b'sub'}:
1113 elif op in {b'and_', b'add', b'sub'}:
1073 assert len(tree) == 3
1114 assert len(tree) == 3
1074 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
1115 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
1075 elif op == b'group':
1116 elif op == b'group':
1076 return walk(tree[1])
1117 return walk(tree[1])
1077 elif op == b'ancestors':
1118 elif op == b'ancestors':
1078 return getstack(walk(tree[1]))
1119 return getstack(walk(tree[1]))
1079 else:
1120 else:
1080 raise error.ProgrammingError(b'illegal tree: %r' % tree)
1121 raise error.ProgrammingError(b'illegal tree: %r' % tree)
1081
1122
1082 return [prefetched[r] for r in walk(tree)]
1123 return [prefetched[r] for r in walk(tree)]
1083
1124
1084
1125
1085 def getdescfromdrev(drev):
1126 def getdescfromdrev(drev):
1086 """get description (commit message) from "Differential Revision"
1127 """get description (commit message) from "Differential Revision"
1087
1128
1088 This is similar to differential.getcommitmessage API. But we only care
1129 This is similar to differential.getcommitmessage API. But we only care
1089 about limited fields: title, summary, test plan, and URL.
1130 about limited fields: title, summary, test plan, and URL.
1090 """
1131 """
1091 title = drev[b'title']
1132 title = drev[b'title']
1092 summary = drev[b'summary'].rstrip()
1133 summary = drev[b'summary'].rstrip()
1093 testplan = drev[b'testPlan'].rstrip()
1134 testplan = drev[b'testPlan'].rstrip()
1094 if testplan:
1135 if testplan:
1095 testplan = b'Test Plan:\n%s' % testplan
1136 testplan = b'Test Plan:\n%s' % testplan
1096 uri = b'Differential Revision: %s' % drev[b'uri']
1137 uri = b'Differential Revision: %s' % drev[b'uri']
1097 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
1138 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
1098
1139
1099
1140
1100 def getdiffmeta(diff):
1141 def getdiffmeta(diff):
1101 """get commit metadata (date, node, user, p1) from a diff object
1142 """get commit metadata (date, node, user, p1) from a diff object
1102
1143
1103 The metadata could be "hg:meta", sent by phabsend, like:
1144 The metadata could be "hg:meta", sent by phabsend, like:
1104
1145
1105 "properties": {
1146 "properties": {
1106 "hg:meta": {
1147 "hg:meta": {
1107 "date": "1499571514 25200",
1148 "date": "1499571514 25200",
1108 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
1149 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
1109 "user": "Foo Bar <foo@example.com>",
1150 "user": "Foo Bar <foo@example.com>",
1110 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
1151 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
1111 }
1152 }
1112 }
1153 }
1113
1154
1114 Or converted from "local:commits", sent by "arc", like:
1155 Or converted from "local:commits", sent by "arc", like:
1115
1156
1116 "properties": {
1157 "properties": {
1117 "local:commits": {
1158 "local:commits": {
1118 "98c08acae292b2faf60a279b4189beb6cff1414d": {
1159 "98c08acae292b2faf60a279b4189beb6cff1414d": {
1119 "author": "Foo Bar",
1160 "author": "Foo Bar",
1120 "time": 1499546314,
1161 "time": 1499546314,
1121 "branch": "default",
1162 "branch": "default",
1122 "tag": "",
1163 "tag": "",
1123 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
1164 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
1124 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
1165 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
1125 "local": "1000",
1166 "local": "1000",
1126 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
1167 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
1127 "summary": "...",
1168 "summary": "...",
1128 "message": "...",
1169 "message": "...",
1129 "authorEmail": "foo@example.com"
1170 "authorEmail": "foo@example.com"
1130 }
1171 }
1131 }
1172 }
1132 }
1173 }
1133
1174
1134 Note: metadata extracted from "local:commits" will lose time zone
1175 Note: metadata extracted from "local:commits" will lose time zone
1135 information.
1176 information.
1136 """
1177 """
1137 props = diff.get(b'properties') or {}
1178 props = diff.get(b'properties') or {}
1138 meta = props.get(b'hg:meta')
1179 meta = props.get(b'hg:meta')
1139 if not meta:
1180 if not meta:
1140 if props.get(b'local:commits'):
1181 if props.get(b'local:commits'):
1141 commit = sorted(props[b'local:commits'].values())[0]
1182 commit = sorted(props[b'local:commits'].values())[0]
1142 meta = {}
1183 meta = {}
1143 if b'author' in commit and b'authorEmail' in commit:
1184 if b'author' in commit and b'authorEmail' in commit:
1144 meta[b'user'] = b'%s <%s>' % (
1185 meta[b'user'] = b'%s <%s>' % (
1145 commit[b'author'],
1186 commit[b'author'],
1146 commit[b'authorEmail'],
1187 commit[b'authorEmail'],
1147 )
1188 )
1148 if b'time' in commit:
1189 if b'time' in commit:
1149 meta[b'date'] = b'%d 0' % int(commit[b'time'])
1190 meta[b'date'] = b'%d 0' % int(commit[b'time'])
1150 if b'branch' in commit:
1191 if b'branch' in commit:
1151 meta[b'branch'] = commit[b'branch']
1192 meta[b'branch'] = commit[b'branch']
1152 node = commit.get(b'commit', commit.get(b'rev'))
1193 node = commit.get(b'commit', commit.get(b'rev'))
1153 if node:
1194 if node:
1154 meta[b'node'] = node
1195 meta[b'node'] = node
1155 if len(commit.get(b'parents', ())) >= 1:
1196 if len(commit.get(b'parents', ())) >= 1:
1156 meta[b'parent'] = commit[b'parents'][0]
1197 meta[b'parent'] = commit[b'parents'][0]
1157 else:
1198 else:
1158 meta = {}
1199 meta = {}
1159 if b'date' not in meta and b'dateCreated' in diff:
1200 if b'date' not in meta and b'dateCreated' in diff:
1160 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
1201 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
1161 if b'branch' not in meta and diff.get(b'branch'):
1202 if b'branch' not in meta and diff.get(b'branch'):
1162 meta[b'branch'] = diff[b'branch']
1203 meta[b'branch'] = diff[b'branch']
1163 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
1204 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
1164 meta[b'parent'] = diff[b'sourceControlBaseRevision']
1205 meta[b'parent'] = diff[b'sourceControlBaseRevision']
1165 return meta
1206 return meta
1166
1207
1167
1208
1168 def readpatch(repo, drevs, write):
1209 def readpatch(repo, drevs, write):
1169 """generate plain-text patch readable by 'hg import'
1210 """generate plain-text patch readable by 'hg import'
1170
1211
1171 write is usually ui.write. drevs is what "querydrev" returns, results of
1212 write is usually ui.write. drevs is what "querydrev" returns, results of
1172 "differential.query".
1213 "differential.query".
1173 """
1214 """
1174 # Prefetch hg:meta property for all diffs
1215 # Prefetch hg:meta property for all diffs
1175 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
1216 diffids = sorted(set(max(int(v) for v in drev[b'diffs']) for drev in drevs))
1176 diffs = callconduit(repo.ui, b'differential.querydiffs', {b'ids': diffids})
1217 diffs = callconduit(repo.ui, b'differential.querydiffs', {b'ids': diffids})
1177
1218
1178 # Generate patch for each drev
1219 # Generate patch for each drev
1179 for drev in drevs:
1220 for drev in drevs:
1180 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
1221 repo.ui.note(_(b'reading D%s\n') % drev[b'id'])
1181
1222
1182 diffid = max(int(v) for v in drev[b'diffs'])
1223 diffid = max(int(v) for v in drev[b'diffs'])
1183 body = callconduit(
1224 body = callconduit(
1184 repo.ui, b'differential.getrawdiff', {b'diffID': diffid}
1225 repo.ui, b'differential.getrawdiff', {b'diffID': diffid}
1185 )
1226 )
1186 desc = getdescfromdrev(drev)
1227 desc = getdescfromdrev(drev)
1187 header = b'# HG changeset patch\n'
1228 header = b'# HG changeset patch\n'
1188
1229
1189 # Try to preserve metadata from hg:meta property. Write hg patch
1230 # Try to preserve metadata from hg:meta property. Write hg patch
1190 # headers that can be read by the "import" command. See patchheadermap
1231 # headers that can be read by the "import" command. See patchheadermap
1191 # and extract in mercurial/patch.py for supported headers.
1232 # and extract in mercurial/patch.py for supported headers.
1192 meta = getdiffmeta(diffs[b'%d' % diffid])
1233 meta = getdiffmeta(diffs[b'%d' % diffid])
1193 for k in _metanamemap.keys():
1234 for k in _metanamemap.keys():
1194 if k in meta:
1235 if k in meta:
1195 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
1236 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
1196
1237
1197 content = b'%s%s\n%s' % (header, desc, body)
1238 content = b'%s%s\n%s' % (header, desc, body)
1198 write(content)
1239 write(content)
1199
1240
1200
1241
1201 @vcrcommand(
1242 @vcrcommand(
1202 b'phabread',
1243 b'phabread',
1203 [(b'', b'stack', False, _(b'read dependencies'))],
1244 [(b'', b'stack', False, _(b'read dependencies'))],
1204 _(b'DREVSPEC [OPTIONS]'),
1245 _(b'DREVSPEC [OPTIONS]'),
1205 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1246 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1206 )
1247 )
1207 def phabread(ui, repo, spec, **opts):
1248 def phabread(ui, repo, spec, **opts):
1208 """print patches from Phabricator suitable for importing
1249 """print patches from Phabricator suitable for importing
1209
1250
1210 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
1251 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
1211 the number ``123``. It could also have common operators like ``+``, ``-``,
1252 the number ``123``. It could also have common operators like ``+``, ``-``,
1212 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
1253 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
1213 select a stack.
1254 select a stack.
1214
1255
1215 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
1256 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
1216 could be used to filter patches by status. For performance reason, they
1257 could be used to filter patches by status. For performance reason, they
1217 only represent a subset of non-status selections and cannot be used alone.
1258 only represent a subset of non-status selections and cannot be used alone.
1218
1259
1219 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
1260 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
1220 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
1261 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
1221 stack up to D9.
1262 stack up to D9.
1222
1263
1223 If --stack is given, follow dependencies information and read all patches.
1264 If --stack is given, follow dependencies information and read all patches.
1224 It is equivalent to the ``:`` operator.
1265 It is equivalent to the ``:`` operator.
1225 """
1266 """
1226 opts = pycompat.byteskwargs(opts)
1267 opts = pycompat.byteskwargs(opts)
1227 if opts.get(b'stack'):
1268 if opts.get(b'stack'):
1228 spec = b':(%s)' % spec
1269 spec = b':(%s)' % spec
1229 drevs = querydrev(repo, spec)
1270 drevs = querydrev(repo, spec)
1230 readpatch(repo, drevs, ui.write)
1271 readpatch(repo, drevs, ui.write)
1231
1272
1232
1273
1233 @vcrcommand(
1274 @vcrcommand(
1234 b'phabupdate',
1275 b'phabupdate',
1235 [
1276 [
1236 (b'', b'accept', False, _(b'accept revisions')),
1277 (b'', b'accept', False, _(b'accept revisions')),
1237 (b'', b'reject', False, _(b'reject revisions')),
1278 (b'', b'reject', False, _(b'reject revisions')),
1238 (b'', b'abandon', False, _(b'abandon revisions')),
1279 (b'', b'abandon', False, _(b'abandon revisions')),
1239 (b'', b'reclaim', False, _(b'reclaim revisions')),
1280 (b'', b'reclaim', False, _(b'reclaim revisions')),
1240 (b'm', b'comment', b'', _(b'comment on the last revision')),
1281 (b'm', b'comment', b'', _(b'comment on the last revision')),
1241 ],
1282 ],
1242 _(b'DREVSPEC [OPTIONS]'),
1283 _(b'DREVSPEC [OPTIONS]'),
1243 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1284 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1244 )
1285 )
1245 def phabupdate(ui, repo, spec, **opts):
1286 def phabupdate(ui, repo, spec, **opts):
1246 """update Differential Revision in batch
1287 """update Differential Revision in batch
1247
1288
1248 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
1289 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
1249 """
1290 """
1250 opts = pycompat.byteskwargs(opts)
1291 opts = pycompat.byteskwargs(opts)
1251 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1292 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1252 if len(flags) > 1:
1293 if len(flags) > 1:
1253 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1294 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1254
1295
1255 actions = []
1296 actions = []
1256 for f in flags:
1297 for f in flags:
1257 actions.append({b'type': f, b'value': b'true'})
1298 actions.append({b'type': f, b'value': b'true'})
1258
1299
1259 drevs = querydrev(repo, spec)
1300 drevs = querydrev(repo, spec)
1260 for i, drev in enumerate(drevs):
1301 for i, drev in enumerate(drevs):
1261 if i + 1 == len(drevs) and opts.get(b'comment'):
1302 if i + 1 == len(drevs) and opts.get(b'comment'):
1262 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1303 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1263 if actions:
1304 if actions:
1264 params = {
1305 params = {
1265 b'objectIdentifier': drev[b'phid'],
1306 b'objectIdentifier': drev[b'phid'],
1266 b'transactions': actions,
1307 b'transactions': actions,
1267 }
1308 }
1268 callconduit(ui, b'differential.revision.edit', params)
1309 callconduit(ui, b'differential.revision.edit', params)
1269
1310
1270
1311
1271 @eh.templatekeyword(b'phabreview', requires={b'ctx'})
1312 @eh.templatekeyword(b'phabreview', requires={b'ctx'})
1272 def template_review(context, mapping):
1313 def template_review(context, mapping):
1273 """:phabreview: Object describing the review for this changeset.
1314 """:phabreview: Object describing the review for this changeset.
1274 Has attributes `url` and `id`.
1315 Has attributes `url` and `id`.
1275 """
1316 """
1276 ctx = context.resource(mapping, b'ctx')
1317 ctx = context.resource(mapping, b'ctx')
1277 m = _differentialrevisiondescre.search(ctx.description())
1318 m = _differentialrevisiondescre.search(ctx.description())
1278 if m:
1319 if m:
1279 return templateutil.hybriddict(
1320 return templateutil.hybriddict(
1280 {b'url': m.group(r'url'), b'id': b"D%s" % m.group(r'id'),}
1321 {b'url': m.group(r'url'), b'id': b"D%s" % m.group(r'id'),}
1281 )
1322 )
1282 else:
1323 else:
1283 tags = ctx.repo().nodetags(ctx.node())
1324 tags = ctx.repo().nodetags(ctx.node())
1284 for t in tags:
1325 for t in tags:
1285 if _differentialrevisiontagre.match(t):
1326 if _differentialrevisiontagre.match(t):
1286 url = ctx.repo().ui.config(b'phabricator', b'url')
1327 url = ctx.repo().ui.config(b'phabricator', b'url')
1287 if not url.endswith(b'/'):
1328 if not url.endswith(b'/'):
1288 url += b'/'
1329 url += b'/'
1289 url += t
1330 url += t
1290
1331
1291 return templateutil.hybriddict({b'url': url, b'id': t,})
1332 return templateutil.hybriddict({b'url': url, b'id': t,})
1292 return None
1333 return None
General Comments 0
You need to be logged in to leave comments. Login now