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