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