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