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