|
|
# phabricator.py - simple Phabricator integration
|
|
|
#
|
|
|
# Copyright 2017 Facebook, Inc.
|
|
|
#
|
|
|
# This software may be used and distributed according to the terms of the
|
|
|
# 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, and a ``phabread``
|
|
|
command which prints a stack of revisions in a format suitable
|
|
|
for :hg:`import`.
|
|
|
|
|
|
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]
|
|
|
# Phabricator URL
|
|
|
url = https://phab.example.com/
|
|
|
|
|
|
# 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,
|
|
|
)
|
|
|
|
|
|
cmdtable = {}
|
|
|
command = registrar.command(cmdtable)
|
|
|
|
|
|
def urlencodenested(params):
|
|
|
"""like urlencode, but works with nested parameters.
|
|
|
|
|
|
For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
|
|
|
flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
|
|
|
urlencode. Note: the encoding is consistent with PHP's http_build_query.
|
|
|
"""
|
|
|
flatparams = util.sortdict()
|
|
|
def process(prefix, obj):
|
|
|
items = {list: enumerate, dict: lambda x: x.items()}.get(type(obj))
|
|
|
if items is None:
|
|
|
flatparams[prefix] = obj
|
|
|
else:
|
|
|
for k, v in items(obj):
|
|
|
if prefix:
|
|
|
process('%s[%s]' % (prefix, k), v)
|
|
|
else:
|
|
|
process(k, v)
|
|
|
process('', params)
|
|
|
return util.urlreq.urlencode(flatparams)
|
|
|
|
|
|
def readurltoken(repo):
|
|
|
"""return conduit url, token and make sure they exist
|
|
|
|
|
|
Currently read from [phabricator] config section. In the future, it might
|
|
|
make sense to read from .arcconfig and .arcrc as well.
|
|
|
"""
|
|
|
values = []
|
|
|
section = 'phabricator'
|
|
|
for name in ['url', 'token']:
|
|
|
value = repo.ui.config(section, name)
|
|
|
if not value:
|
|
|
raise error.Abort(_('config %s.%s is required') % (section, name))
|
|
|
values.append(value)
|
|
|
return values
|
|
|
|
|
|
def callconduit(repo, name, params):
|
|
|
"""call Conduit API, params is a dict. return json.loads result, or None"""
|
|
|
host, token = readurltoken(repo)
|
|
|
url, authinfo = util.url('/'.join([host, 'api', name])).authinfo()
|
|
|
urlopener = urlmod.opener(repo.ui, authinfo)
|
|
|
repo.ui.debug('Conduit Call: %s %s\n' % (url, params))
|
|
|
params = params.copy()
|
|
|
params['api.token'] = token
|
|
|
request = util.urlreq.request(url, data=urlencodenested(params))
|
|
|
body = urlopener.open(request).read()
|
|
|
repo.ui.debug('Conduit Response: %s\n' % body)
|
|
|
parsed = json.loads(body)
|
|
|
if parsed.get(r'error_code'):
|
|
|
msg = (_('Conduit Error (%s): %s')
|
|
|
% (parsed[r'error_code'], parsed[r'error_info']))
|
|
|
raise error.Abort(msg)
|
|
|
return parsed[r'result']
|
|
|
|
|
|
@command('debugcallconduit', [], _('METHOD'))
|
|
|
def debugcallconduit(ui, repo, name):
|
|
|
"""call Conduit API
|
|
|
|
|
|
Call parameters are read from stdin as a JSON blob. Result will be written
|
|
|
to stdout as a JSON blob.
|
|
|
"""
|
|
|
params = json.loads(ui.fin.read())
|
|
|
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
|
|
|
|
|
|
_differentialrevisiontagre = re.compile('\AD([1-9][0-9]*)\Z')
|
|
|
_differentialrevisiondescre = re.compile(
|
|
|
'^Differential Revision:.*D([1-9][0-9]*)$', re.M)
|
|
|
|
|
|
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.
|
|
|
|
|
|
If tags are not found, examine commit message. The "Differential Revision:"
|
|
|
line could associate this changeset to a Differential Revision.
|
|
|
"""
|
|
|
unfi = ctx.repo().unfiltered()
|
|
|
nodemap = unfi.changelog.nodemap
|
|
|
|
|
|
# Check tags like "D123"
|
|
|
for n in obsolete.allprecursors(unfi.obsstore, [ctx.node()]):
|
|
|
if n in nodemap:
|
|
|
for tag in unfi.nodetags(n):
|
|
|
m = _differentialrevisiontagre.match(tag)
|
|
|
if m:
|
|
|
return n, int(m.group(1))
|
|
|
|
|
|
# Check commit message
|
|
|
m = _differentialrevisiondescre.search(ctx.description())
|
|
|
if m:
|
|
|
return None, 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(),
|
|
|
'node': ctx.hex(),
|
|
|
'parent': ctx.p1().hex(),
|
|
|
}),
|
|
|
}
|
|
|
callconduit(ctx.repo(), 'differential.setdiffproperty', params)
|
|
|
|
|
|
def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=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.
|
|
|
|
|
|
If oldnode is not None, check if the patch content (without commit message
|
|
|
and metadata) has changed before creating another diff.
|
|
|
"""
|
|
|
repo = ctx.repo()
|
|
|
if oldnode:
|
|
|
diffopts = mdiff.diffopts(git=True, context=1)
|
|
|
oldctx = repo.unfiltered()[oldnode]
|
|
|
neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
|
|
|
else:
|
|
|
neednewdiff = True
|
|
|
|
|
|
transactions = []
|
|
|
if neednewdiff:
|
|
|
diff = creatediff(ctx)
|
|
|
writediffproperties(ctx, diff)
|
|
|
transactions.append({'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)
|
|
|
|
|
|
if not revs:
|
|
|
raise error.Abort(_('phabsend requires at least one changeset'))
|
|
|
|
|
|
# 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,
|
|
|
oldnode)
|
|
|
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
|
|
|
|
|
|
# Map from "hg:meta" keys to header understood by "hg import". The order is
|
|
|
# consistent with "hg export" output.
|
|
|
_metanamemap = util.sortdict([(r'user', 'User'), (r'date', 'Date'),
|
|
|
(r'node', 'Node ID'), (r'parent', 'Parent ')])
|
|
|
|
|
|
def querydrev(repo, params, stack=False):
|
|
|
"""return a list of "Differential Revision" dicts
|
|
|
|
|
|
params is the input of "differential.query" API, and is expected to match
|
|
|
just a single Differential Revision.
|
|
|
|
|
|
A "Differential Revision dict" looks like:
|
|
|
|
|
|
{
|
|
|
"id": "2",
|
|
|
"phid": "PHID-DREV-672qvysjcczopag46qty",
|
|
|
"title": "example",
|
|
|
"uri": "https://phab.example.com/D2",
|
|
|
"dateCreated": "1499181406",
|
|
|
"dateModified": "1499182103",
|
|
|
"authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
|
|
|
"status": "0",
|
|
|
"statusName": "Needs Review",
|
|
|
"properties": [],
|
|
|
"branch": null,
|
|
|
"summary": "",
|
|
|
"testPlan": "",
|
|
|
"lineCount": "2",
|
|
|
"activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
|
|
|
"diffs": [
|
|
|
"3",
|
|
|
"4",
|
|
|
],
|
|
|
"commits": [],
|
|
|
"reviewers": [],
|
|
|
"ccs": [],
|
|
|
"hashes": [],
|
|
|
"auxiliary": {
|
|
|
"phabricator:projects": [],
|
|
|
"phabricator:depends-on": [
|
|
|
"PHID-DREV-gbapp366kutjebt7agcd"
|
|
|
]
|
|
|
},
|
|
|
"repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
|
|
|
"sourcePath": null
|
|
|
}
|
|
|
|
|
|
If stack is True, return a list of "Differential Revision dict"s in an
|
|
|
order that the latter ones depend on the former ones. Otherwise, return a
|
|
|
list of a unique "Differential Revision dict".
|
|
|
"""
|
|
|
prefetched = {} # {id or phid: drev}
|
|
|
def fetch(params):
|
|
|
"""params -> single drev or None"""
|
|
|
key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
|
|
|
if key in prefetched:
|
|
|
return prefetched[key]
|
|
|
# Otherwise, send the request. If we're fetching a stack, be smarter
|
|
|
# and fetch more ids in one batch, even if it could be unnecessary.
|
|
|
batchparams = params
|
|
|
if stack and len(params.get(r'ids', [])) == 1:
|
|
|
i = int(params[r'ids'][0])
|
|
|
# developer config: phabricator.batchsize
|
|
|
batchsize = repo.ui.configint('phabricator', 'batchsize', 12)
|
|
|
batchparams = {'ids': range(max(1, i - batchsize), i + 1)}
|
|
|
drevs = callconduit(repo, 'differential.query', batchparams)
|
|
|
# Fill prefetched with the result
|
|
|
for drev in drevs:
|
|
|
prefetched[drev[r'phid']] = drev
|
|
|
prefetched[int(drev[r'id'])] = drev
|
|
|
if key not in prefetched:
|
|
|
raise error.Abort(_('cannot get Differential Revision %r') % params)
|
|
|
return prefetched[key]
|
|
|
|
|
|
visited = set()
|
|
|
result = []
|
|
|
queue = [params]
|
|
|
while queue:
|
|
|
params = queue.pop()
|
|
|
drev = fetch(params)
|
|
|
if drev[r'id'] in visited:
|
|
|
continue
|
|
|
visited.add(drev[r'id'])
|
|
|
result.append(drev)
|
|
|
if stack:
|
|
|
auxiliary = drev.get(r'auxiliary', {})
|
|
|
depends = auxiliary.get(r'phabricator:depends-on', [])
|
|
|
for phid in depends:
|
|
|
queue.append({'phids': [phid]})
|
|
|
result.reverse()
|
|
|
return result
|
|
|
|
|
|
def getdescfromdrev(drev):
|
|
|
"""get description (commit message) from "Differential Revision"
|
|
|
|
|
|
This is similar to differential.getcommitmessage API. But we only care
|
|
|
about limited fields: title, summary, test plan, and URL.
|
|
|
"""
|
|
|
title = drev[r'title']
|
|
|
summary = drev[r'summary'].rstrip()
|
|
|
testplan = drev[r'testPlan'].rstrip()
|
|
|
if testplan:
|
|
|
testplan = 'Test Plan:\n%s' % testplan
|
|
|
uri = 'Differential Revision: %s' % drev[r'uri']
|
|
|
return '\n\n'.join(filter(None, [title, summary, testplan, uri]))
|
|
|
|
|
|
def readpatch(repo, params, write, stack=False):
|
|
|
"""generate plain-text patch readable by 'hg import'
|
|
|
|
|
|
write is usually ui.write. params is passed to "differential.query". If
|
|
|
stack is True, also write dependent patches.
|
|
|
"""
|
|
|
# Differential Revisions
|
|
|
drevs = querydrev(repo, params, stack)
|
|
|
|
|
|
# Prefetch hg:meta property for all diffs
|
|
|
diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
|
|
|
diffs = callconduit(repo, 'differential.querydiffs', {'ids': diffids})
|
|
|
|
|
|
# Generate patch for each drev
|
|
|
for drev in drevs:
|
|
|
repo.ui.note(_('reading D%s\n') % drev[r'id'])
|
|
|
|
|
|
diffid = max(int(v) for v in drev[r'diffs'])
|
|
|
body = callconduit(repo, 'differential.getrawdiff', {'diffID': diffid})
|
|
|
desc = getdescfromdrev(drev)
|
|
|
header = '# HG changeset patch\n'
|
|
|
|
|
|
# Try to preserve metadata from hg:meta property. Write hg patch
|
|
|
# headers that can be read by the "import" command. See patchheadermap
|
|
|
# and extract in mercurial/patch.py for supported headers.
|
|
|
props = diffs[str(diffid)][r'properties'] # could be empty list or dict
|
|
|
if props and r'hg:meta' in props:
|
|
|
meta = props[r'hg:meta']
|
|
|
for k in _metanamemap.keys():
|
|
|
if k in meta:
|
|
|
header += '# %s %s\n' % (_metanamemap[k], meta[k])
|
|
|
|
|
|
write(('%s%s\n%s') % (header, desc, body))
|
|
|
|
|
|
@command('phabread',
|
|
|
[('', 'stack', False, _('read dependencies'))],
|
|
|
_('REVID [OPTIONS]'))
|
|
|
def phabread(ui, repo, revid, **opts):
|
|
|
"""print patches from Phabricator suitable for importing
|
|
|
|
|
|
REVID could be a Differential Revision identity, like ``D123``, or just the
|
|
|
number ``123``, or a full URL like ``https://phab.example.com/D123``.
|
|
|
|
|
|
If --stack is given, follow dependencies information and read all patches.
|
|
|
"""
|
|
|
try:
|
|
|
revid = int(revid.split('/')[-1].replace('D', ''))
|
|
|
except ValueError:
|
|
|
raise error.Abort(_('invalid Revision ID: %s') % revid)
|
|
|
readpatch(repo, {'ids': [revid]}, ui.write, opts.get('stack'))
|
|
|
|