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