# HG changeset patch # User Jun Wu # Date 1499051289 25200 # Node ID 228ad1e58a856ac32f4d7659affe335d0e2cf23b # Parent 36b3febd739fb4cbee802652186f0ff6e09439fa 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. diff -r 36b3febd739f -r 228ad1e58a85 contrib/phabricator.py --- a/contrib/phabricator.py Sun Jul 02 20:08:09 2017 -0700 +++ b/contrib/phabricator.py Sun Jul 02 20:08:09 2017 -0700 @@ -6,6 +6,13 @@ # GNU General Public License version 2 or any later version. """simple Phabricator integration +This extension provides a ``phabsend`` command which sends a stack of +changesets to Phabricator without amending commit messages. + +By default, Phabricator requires ``Test Plan`` which might prevent some +changeset from being sent. The requirement could be disabled by changing +``differential.require-test-plan-field`` config server side. + Config:: [phabricator] @@ -14,16 +21,27 @@ # API token. Get it from https://$HOST/conduit/login/ token = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx + + # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its + # callsign is "FOO". + callsign = FOO """ from __future__ import absolute_import import json +import re from mercurial.i18n import _ from mercurial import ( + encoding, error, + mdiff, + obsolete, + patch, registrar, + scmutil, + tags, url as urlmod, util, ) @@ -96,3 +114,162 @@ result = callconduit(repo, name, params) s = json.dumps(result, sort_keys=True, indent=2, separators=(',', ': ')) ui.write('%s\n' % s) + +def getrepophid(repo): + """given callsign, return repository PHID or None""" + # developer config: phabricator.repophid + repophid = repo.ui.config('phabricator', 'repophid') + if repophid: + return repophid + callsign = repo.ui.config('phabricator', 'callsign') + if not callsign: + return None + query = callconduit(repo, 'diffusion.repository.search', + {'constraints': {'callsigns': [callsign]}}) + if len(query[r'data']) == 0: + return None + repophid = encoding.strtolocal(query[r'data'][0][r'phid']) + repo.ui.setconfig('phabricator', 'repophid', repophid) + return repophid + +_differentialrevisionre = re.compile('\AD([1-9][0-9]*)\Z') + +def getmapping(ctx): + """return (node, associated Differential Revision ID) or (None, None) + + Examines all precursors and their tags. Tags with format like "D1234" are + considered a match and the node with that tag, and the number after "D" + (ex. 1234) will be returned. + """ + unfi = ctx.repo().unfiltered() + nodemap = unfi.changelog.nodemap + for n in obsolete.allprecursors(unfi.obsstore, [ctx.node()]): + if n in nodemap: + for tag in unfi.nodetags(n): + m = _differentialrevisionre.match(tag) + if m: + return n, int(m.group(1)) + return None, None + +def getdiff(ctx, diffopts): + """plain-text diff without header (user, commit message, etc)""" + output = util.stringio() + for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(), + None, opts=diffopts): + output.write(chunk) + return output.getvalue() + +def creatediff(ctx): + """create a Differential Diff""" + repo = ctx.repo() + repophid = getrepophid(repo) + # Create a "Differential Diff" via "differential.createrawdiff" API + params = {'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))} + if repophid: + params['repositoryPHID'] = repophid + diff = callconduit(repo, 'differential.createrawdiff', params) + if not diff: + raise error.Abort(_('cannot create diff for %s') % ctx) + return diff + +def writediffproperties(ctx, diff): + """write metadata to diff so patches could be applied losslessly""" + params = { + 'diff_id': diff[r'id'], + 'name': 'hg:meta', + 'data': json.dumps({ + 'user': ctx.user(), + 'date': '%d %d' % ctx.date(), + }), + } + callconduit(ctx.repo(), 'differential.setdiffproperty', params) + +def createdifferentialrevision(ctx, revid=None, parentrevid=None): + """create or update a Differential Revision + + If revid is None, create a new Differential Revision, otherwise update + revid. If parentrevid is not None, set it as a dependency. + """ + repo = ctx.repo() + diff = creatediff(ctx) + writediffproperties(ctx, diff) + + transactions = [{'type': 'update', 'value': diff[r'phid']}] + + # Use a temporary summary to set dependency. There might be better ways but + # I cannot find them for now. But do not do that if we are updating an + # existing revision (revid is not None) since that introduces visible + # churns (someone edited "Summary" twice) on the web page. + if parentrevid and revid is None: + summary = 'Depends on D%s' % parentrevid + transactions += [{'type': 'summary', 'value': summary}, + {'type': 'summary', 'value': ' '}] + + # Parse commit message and update related fields. + desc = ctx.description() + info = callconduit(repo, 'differential.parsecommitmessage', + {'corpus': desc}) + for k, v in info[r'fields'].items(): + if k in ['title', 'summary', 'testPlan']: + transactions.append({'type': k, 'value': v}) + + params = {'transactions': transactions} + if revid is not None: + # Update an existing Differential Revision + params['objectIdentifier'] = revid + + revision = callconduit(repo, 'differential.revision.edit', params) + if not revision: + raise error.Abort(_('cannot create revision for %s') % ctx) + + return revision + +@command('phabsend', + [('r', 'rev', [], _('revisions to send'), _('REV'))], + _('REV [OPTIONS]')) +def phabsend(ui, repo, *revs, **opts): + """upload changesets to Phabricator + + If there are multiple revisions specified, they will be send as a stack + with a linear dependencies relationship using the order specified by the + revset. + + For the first time uploading changesets, local tags will be created to + maintain the association. After the first time, phabsend will check + obsstore and tags information so it can figure out whether to update an + existing Differential Revision, or create a new one. + """ + revs = list(revs) + opts.get('rev', []) + revs = scmutil.revrange(repo, revs) + + # Send patches one by one so we know their Differential Revision IDs and + # can provide dependency relationship + lastrevid = None + for rev in revs: + ui.debug('sending rev %d\n' % rev) + ctx = repo[rev] + + # Get Differential Revision ID + oldnode, revid = getmapping(ctx) + if oldnode != ctx.node(): + # Create or update Differential Revision + revision = createdifferentialrevision(ctx, revid, lastrevid) + newrevid = int(revision[r'object'][r'id']) + if revid: + action = _('updated') + else: + action = _('created') + + # Create a local tag to note the association + tagname = 'D%d' % newrevid + tags.tag(repo, tagname, ctx.node(), message=None, user=None, + date=None, local=True) + else: + # Nothing changed. But still set "newrevid" so the next revision + # could depend on this one. + newrevid = revid + action = _('skipped') + + ui.write(_('D%s: %s - %s: %s\n') % (newrevid, action, ctx, + ctx.description().split('\n')[0])) + lastrevid = newrevid