phabricator: add phabsend command to send a stack
authorJun Wu <quark@fb.com>
Sun, 02 Jul 2017 20:08:09 -0700
changeset 33201 228ad1e58a85
parent 33200 36b3febd739f
child 33202 04cf9927f350
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.
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