comparison contrib/phabricator.py @ 33199:228ad1e58a85

phabricator: add phabsend command to send a stack The `phabsend` command is intended to provide `hg email`-like experience - sending a stack, setup dependency information and do not amend existing changesets. It uses differential.createrawdiff and differential.revision.edit Conduit API to create or update a Differential Revision. Local tags like `D123` are written indicating certain changesets were sent to Phabricator. The `phabsend` command will use obsstore and tags information to decide whether to update or create Differential Revisions.
author Jun Wu <quark@fb.com>
date Sun, 02 Jul 2017 20:08:09 -0700
parents 36b3febd739f
children 04cf9927f350
comparison
equal deleted inserted replaced
33198:36b3febd739f 33199:228ad1e58a85
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
10 changesets to Phabricator without amending commit messages.
11
12 By default, Phabricator requires ``Test Plan`` which might prevent some
13 changeset from being sent. The requirement could be disabled by changing
14 ``differential.require-test-plan-field`` config server side.
15
9 Config:: 16 Config::
10 17
11 [phabricator] 18 [phabricator]
12 # Phabricator URL 19 # Phabricator URL
13 url = https://phab.example.com/ 20 url = https://phab.example.com/
14 21
15 # API token. Get it from https://$HOST/conduit/login/ 22 # API token. Get it from https://$HOST/conduit/login/
16 token = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx 23 token = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
24
25 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
26 # callsign is "FOO".
27 callsign = FOO
17 """ 28 """
18 29
19 from __future__ import absolute_import 30 from __future__ import absolute_import
20 31
21 import json 32 import json
33 import re
22 34
23 from mercurial.i18n import _ 35 from mercurial.i18n import _
24 from mercurial import ( 36 from mercurial import (
37 encoding,
25 error, 38 error,
39 mdiff,
40 obsolete,
41 patch,
26 registrar, 42 registrar,
43 scmutil,
44 tags,
27 url as urlmod, 45 url as urlmod,
28 util, 46 util,
29 ) 47 )
30 48
31 cmdtable = {} 49 cmdtable = {}
94 """ 112 """
95 params = json.loads(ui.fin.read()) 113 params = json.loads(ui.fin.read())
96 result = callconduit(repo, name, params) 114 result = callconduit(repo, name, params)
97 s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': ')) 115 s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': '))
98 ui.write('%s\n' % s) 116 ui.write('%s\n' % s)
117
118 def getrepophid(repo):
119 """given callsign, return repository PHID or None"""
120 # developer config: phabricator.repophid
121 repophid = repo.ui.config('phabricator', 'repophid')
122 if repophid:
123 return repophid
124 callsign = repo.ui.config('phabricator', 'callsign')
125 if not callsign:
126 return None
127 query = callconduit(repo, 'diffusion.repository.search',
128 {'constraints': {'callsigns': [callsign]}})
129 if len(query[r'data']) == 0:
130 return None
131 repophid = encoding.strtolocal(query[r'data'][0][r'phid'])
132 repo.ui.setconfig('phabricator', 'repophid', repophid)
133 return repophid
134
135 _differentialrevisionre = re.compile('\AD([1-9][0-9]*)\Z')
136
137 def getmapping(ctx):
138 """return (node, associated Differential Revision ID) or (None, None)
139
140 Examines all precursors and their tags. Tags with format like "D1234" are
141 considered a match and the node with that tag, and the number after "D"
142 (ex. 1234) will be returned.
143 """
144 unfi = ctx.repo().unfiltered()
145 nodemap = unfi.changelog.nodemap
146 for n in obsolete.allprecursors(unfi.obsstore, [ctx.node()]):
147 if n in nodemap:
148 for tag in unfi.nodetags(n):
149 m = _differentialrevisionre.match(tag)
150 if m:
151 return n, int(m.group(1))
152 return None, None
153
154 def getdiff(ctx, diffopts):
155 """plain-text diff without header (user, commit message, etc)"""
156 output = util.stringio()
157 for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
158 None, opts=diffopts):
159 output.write(chunk)
160 return output.getvalue()
161
162 def creatediff(ctx):
163 """create a Differential Diff"""
164 repo = ctx.repo()
165 repophid = getrepophid(repo)
166 # Create a "Differential Diff" via "differential.createrawdiff" API
167 params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
168 if repophid:
169 params['repositoryPHID'] = repophid
170 diff = callconduit(repo, 'differential.createrawdiff', params)
171 if not diff:
172 raise error.Abort(_('cannot create diff for %s') % ctx)
173 return diff
174
175 def writediffproperties(ctx, diff):
176 """write metadata to diff so patches could be applied losslessly"""
177 params = {
178 'diff_id': diff[r'id'],
179 'name': 'hg:meta',
180 'data': json.dumps({
181 'user': ctx.user(),
182 'date': '%d %d' % ctx.date(),
183 }),
184 }
185 callconduit(ctx.repo(), 'differential.setdiffproperty', params)
186
187 def createdifferentialrevision(ctx, revid=None, parentrevid=None):
188 """create or update a Differential Revision
189
190 If revid is None, create a new Differential Revision, otherwise update
191 revid. If parentrevid is not None, set it as a dependency.
192 """
193 repo = ctx.repo()
194 diff = creatediff(ctx)
195 writediffproperties(ctx, diff)
196
197 transactions = [{'type': 'update', 'value': diff[r'phid']}]
198
199 # Use a temporary summary to set dependency. There might be better ways but
200 # I cannot find them for now. But do not do that if we are updating an
201 # existing revision (revid is not None) since that introduces visible
202 # churns (someone edited "Summary" twice) on the web page.
203 if parentrevid and revid is None:
204 summary = 'Depends on D%s' % parentrevid
205 transactions += [{'type': 'summary', 'value': summary},
206 {'type': 'summary', 'value': ' '}]
207
208 # Parse commit message and update related fields.
209 desc = ctx.description()
210 info = callconduit(repo, 'differential.parsecommitmessage',
211 {'corpus': desc})
212 for k, v in info[r'fields'].items():
213 if k in ['title', 'summary', 'testPlan']:
214 transactions.append({'type': k, 'value': v})
215
216 params = {'transactions': transactions}
217 if revid is not None:
218 # Update an existing Differential Revision
219 params['objectIdentifier'] = revid
220
221 revision = callconduit(repo, 'differential.revision.edit', params)
222 if not revision:
223 raise error.Abort(_('cannot create revision for %s') % ctx)
224
225 return revision
226
227 @command('phabsend',
228 [('r', 'rev', [], _('revisions to send'), _('REV'))],
229 _('REV [OPTIONS]'))
230 def phabsend(ui, repo, *revs, **opts):
231 """upload changesets to Phabricator
232
233 If there are multiple revisions specified, they will be send as a stack
234 with a linear dependencies relationship using the order specified by the
235 revset.
236
237 For the first time uploading changesets, local tags will be created to
238 maintain the association. After the first time, phabsend will check
239 obsstore and tags information so it can figure out whether to update an
240 existing Differential Revision, or create a new one.
241 """
242 revs = list(revs) + opts.get('rev', [])
243 revs = scmutil.revrange(repo, revs)
244
245 # Send patches one by one so we know their Differential Revision IDs and
246 # can provide dependency relationship
247 lastrevid = None
248 for rev in revs:
249 ui.debug('sending rev %d\n' % rev)
250 ctx = repo[rev]
251
252 # Get Differential Revision ID
253 oldnode, revid = getmapping(ctx)
254 if oldnode != ctx.node():
255 # Create or update Differential Revision
256 revision = createdifferentialrevision(ctx, revid, lastrevid)
257 newrevid = int(revision[r'object'][r'id'])
258 if revid:
259 action = _('updated')
260 else:
261 action = _('created')
262
263 # Create a local tag to note the association
264 tagname = 'D%d' % newrevid
265 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
266 date=None, local=True)
267 else:
268 # Nothing changed. But still set "newrevid" so the next revision
269 # could depend on this one.
270 newrevid = revid
271 action = _('skipped')
272
273 ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx,
274 ctx.description().split('\n')[0]))
275 lastrevid = newrevid