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