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