Mercurial > public > mercurial-scm > hg
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 |