contrib/phabricator.py
changeset 33199 228ad1e58a85
parent 33198 36b3febd739f
child 33200 04cf9927f350
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