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