view hgext/phabricator.py @ 50734:d5cd1fd690f3 stable

hgweb: drop references to deprecated cgitb cgitb is going away and gives warnings when importing, and that make tests fail: $TESTTMP/hgweb.cgi:5: DeprecationWarning: 'cgitb' is deprecated and slated for removal in Python 3.13 The lack of a "nice" high level error handler is not a huge problem, neither for users (where it is disabled anyway) or for tests (where we don't use a browser and the plain tracebacks often are more readable). It is inevitable that it is going away, and there is no obvious alternative. Remove it and move on.
author Mads Kiilerich <mads@kiilerich.com>
date Mon, 26 Jun 2023 18:00:28 +0200
parents 92743e6d1a0c
children 18c8c18993f0
line wrap: on
line source

# 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 (EXPERIMENTAL)

This extension provides a ``phabsend`` command which sends a stack of
changesets to Phabricator, and a ``phabread`` command which prints a stack of
revisions in a format suitable for :hg:`import`, and a ``phabupdate`` command
to update statuses in batch.

A "phabstatus" view for :hg:`show` is also provided; it displays status
information of Phabricator differentials associated with unfinished
changesets.

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/

    # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
    # callsign is "FOO".
    callsign = FOO

    # curl command to use. If not set (default), use builtin HTTP library to
    # communicate. If set, use the specified curl command. This could be useful
    # if you need to specify advanced options that is not easily supported by
    # the internal library.
    curlcmd = curl --connect-timeout 2 --retry 3 --silent

    # retry failed command N time (default 0). Useful when using the extension
    # over flakly connection.
    #
    # We wait `retry.interval` between each retry, in seconds.
    # (default 1 second).
    retry = 3
    retry.interval = 10

    # the retry option can combine well with the http.timeout one.
    #
    # For example to give up on http request after 20 seconds:
    [http]
    timeout=20

    [auth]
    example.schemes = https
    example.prefix = phab.example.com

    # API token. Get it from https://$HOST/conduit/login/
    example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
"""


import base64
import contextlib
import hashlib
import io
import itertools
import json
import mimetypes
import operator
import re
import time

from mercurial.node import bin, short
from mercurial.i18n import _
from mercurial.pycompat import getattr
from mercurial.thirdparty import attr
from mercurial import (
    cmdutil,
    context,
    copies,
    encoding,
    error,
    exthelper,
    graphmod,
    httpconnection as httpconnectionmod,
    localrepo,
    logcmdutil,
    match,
    mdiff,
    obsutil,
    parser,
    patch,
    phases,
    pycompat,
    rewriteutil,
    scmutil,
    smartset,
    tags,
    templatefilters,
    templateutil,
    url as urlmod,
    util,
)
from mercurial.utils import (
    procutil,
    stringutil,
    urlutil,
)
from . import show


# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
# be specifying the version(s) of Mercurial they are tested with, or
# leave the attribute unspecified.
testedwith = b'ships-with-hg-core'

eh = exthelper.exthelper()

cmdtable = eh.cmdtable
command = eh.command
configtable = eh.configtable
templatekeyword = eh.templatekeyword
uisetup = eh.finaluisetup

# developer config: phabricator.batchsize
eh.configitem(
    b'phabricator',
    b'batchsize',
    default=12,
)
eh.configitem(
    b'phabricator',
    b'callsign',
    default=None,
)
eh.configitem(
    b'phabricator',
    b'curlcmd',
    default=None,
)
# developer config: phabricator.debug
eh.configitem(
    b'phabricator',
    b'debug',
    default=False,
)
# developer config: phabricator.repophid
eh.configitem(
    b'phabricator',
    b'repophid',
    default=None,
)
eh.configitem(
    b'phabricator',
    b'retry',
    default=0,
)
eh.configitem(
    b'phabricator',
    b'retry.interval',
    default=1,
)
eh.configitem(
    b'phabricator',
    b'url',
    default=None,
)
eh.configitem(
    b'phabsend',
    b'confirm',
    default=False,
)
eh.configitem(
    b'phabimport',
    b'secret',
    default=False,
)
eh.configitem(
    b'phabimport',
    b'obsolete',
    default=False,
)

colortable = {
    b'phabricator.action.created': b'green',
    b'phabricator.action.skipped': b'magenta',
    b'phabricator.action.updated': b'magenta',
    b'phabricator.drev': b'bold',
    b'phabricator.status.abandoned': b'magenta dim',
    b'phabricator.status.accepted': b'green bold',
    b'phabricator.status.closed': b'green',
    b'phabricator.status.needsreview': b'yellow',
    b'phabricator.status.needsrevision': b'red',
    b'phabricator.status.changesplanned': b'red',
}

_VCR_FLAGS = [
    (
        b'',
        b'test-vcr',
        b'',
        _(
            b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
            b', otherwise will mock all http requests using the specified vcr file.'
            b' (ADVANCED)'
        ),
    ),
]


@eh.wrapfunction(localrepo, "loadhgrc")
def _loadhgrc(orig, ui, wdirvfs, hgvfs, requirements, *args, **opts):
    """Load ``.arcconfig`` content into a ui instance on repository open."""
    result = False
    arcconfig = {}

    try:
        # json.loads only accepts bytes from 3.6+
        rawparams = encoding.unifromlocal(wdirvfs.read(b".arcconfig"))
        # json.loads only returns unicode strings
        arcconfig = pycompat.rapply(
            lambda x: encoding.unitolocal(x) if isinstance(x, str) else x,
            pycompat.json_loads(rawparams),
        )

        result = True
    except ValueError:
        ui.warn(_(b"invalid JSON in %s\n") % wdirvfs.join(b".arcconfig"))
    except IOError:
        pass

    cfg = util.sortdict()

    if b"repository.callsign" in arcconfig:
        cfg[(b"phabricator", b"callsign")] = arcconfig[b"repository.callsign"]

    if b"phabricator.uri" in arcconfig:
        cfg[(b"phabricator", b"url")] = arcconfig[b"phabricator.uri"]

    if cfg:
        ui.applyconfig(cfg, source=wdirvfs.join(b".arcconfig"))

    return (
        orig(ui, wdirvfs, hgvfs, requirements, *args, **opts) or result
    )  # Load .hg/hgrc


def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
    fullflags = flags + _VCR_FLAGS

    def hgmatcher(r1, r2):
        if r1.uri != r2.uri or r1.method != r2.method:
            return False
        r1params = util.urlreq.parseqs(r1.body)
        r2params = util.urlreq.parseqs(r2.body)
        for key in r1params:
            if key not in r2params:
                return False
            value = r1params[key][0]
            # we want to compare json payloads without worrying about ordering
            if value.startswith(b'{') and value.endswith(b'}'):
                r1json = pycompat.json_loads(value)
                r2json = pycompat.json_loads(r2params[key][0])
                if r1json != r2json:
                    return False
            elif r2params[key][0] != value:
                return False
        return True

    def sanitiserequest(request):
        request.body = re.sub(
            br'cli-[a-z0-9]+', br'cli-hahayouwish', request.body
        )
        return request

    def sanitiseresponse(response):
        if 'set-cookie' in response['headers']:
            del response['headers']['set-cookie']
        return response

    def decorate(fn):
        def inner(*args, **kwargs):
            vcr = kwargs.pop('test_vcr')
            if vcr:
                cassette = pycompat.fsdecode(vcr)
                import hgdemandimport

                with hgdemandimport.deactivated():
                    # pytype: disable=import-error
                    import vcr as vcrmod
                    import vcr.stubs as stubs

                    # pytype: enable=import-error

                    vcr = vcrmod.VCR(
                        serializer='json',
                        before_record_request=sanitiserequest,
                        before_record_response=sanitiseresponse,
                        custom_patches=[
                            (
                                urlmod,
                                'httpconnection',
                                stubs.VCRHTTPConnection,
                            ),
                            (
                                urlmod,
                                'httpsconnection',
                                stubs.VCRHTTPSConnection,
                            ),
                        ],
                    )
                    vcr.register_matcher('hgmatcher', hgmatcher)
                    with vcr.use_cassette(cassette, match_on=['hgmatcher']):
                        return fn(*args, **kwargs)
            return fn(*args, **kwargs)

        cmd = util.checksignature(inner, depth=2)
        cmd.__name__ = fn.__name__
        cmd.__doc__ = fn.__doc__

        return command(
            name,
            fullflags,
            spec,
            helpcategory=helpcategory,
            optionalrepo=optionalrepo,
        )(cmd)

    return decorate


def _debug(ui, *msg, **opts):
    """write debug output for Phabricator if ``phabricator.debug`` is set

    Specifically, this avoids dumping Conduit and HTTP auth chatter that is
    printed with the --debug argument.
    """
    if ui.configbool(b"phabricator", b"debug"):
        flag = ui.debugflag
        try:
            ui.debugflag = True
            ui.write(*msg, **opts)
        finally:
            ui.debugflag = flag


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: bytes, obj):
        if isinstance(obj, bool):
            obj = {True: b'true', False: b'false'}[obj]  # Python -> PHP form
        lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
        # .items() will only be called for a dict type
        # pytype: disable=attribute-error
        items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
        # pytype: enable=attribute-error
        if items is None:
            flatparams[prefix] = obj
        else:
            for k, v in items(obj):
                if prefix:
                    process(b'%s[%s]' % (prefix, k), v)
                else:
                    process(k, v)

    process(b'', params)
    return urlutil.urlreq.urlencode(flatparams)


def readurltoken(ui):
    """return conduit url, token and make sure they exist

    Currently read from [auth] config section. In the future, it might
    make sense to read from .arcconfig and .arcrc as well.
    """
    url = ui.config(b'phabricator', b'url')
    if not url:
        raise error.Abort(
            _(b'config %s.%s is required') % (b'phabricator', b'url')
        )

    res = httpconnectionmod.readauthforuri(ui, url, urlutil.url(url).user)
    token = None

    if res:
        group, auth = res

        ui.debug(b"using auth.%s.* for authentication\n" % group)

        token = auth.get(b'phabtoken')

    if not token:
        raise error.Abort(
            _(b'Can\'t find conduit token associated to %s') % (url,)
        )

    return url, token


def callconduit(ui, name, params):
    """call Conduit API, params is a dict. return json.loads result, or None"""
    host, token = readurltoken(ui)
    url, authinfo = urlutil.url(b'/'.join([host, b'api', name])).authinfo()
    ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
    params = params.copy()
    params[b'__conduit__'] = {
        b'token': token,
    }
    rawdata = {
        b'params': templatefilters.json(params),
        b'output': b'json',
        b'__conduit__': 1,
    }
    data = urlencodenested(rawdata)
    curlcmd = ui.config(b'phabricator', b'curlcmd')
    if curlcmd:
        sin, sout = procutil.popen2(
            b'%s -d @- %s' % (curlcmd, procutil.shellquote(url))
        )
        sin.write(data)
        sin.close()
        body = sout.read()
    else:
        urlopener = urlmod.opener(ui, authinfo)
        request = util.urlreq.request(pycompat.strurl(url), data=data)
        max_try = ui.configint(b'phabricator', b'retry') + 1
        timeout = ui.configwith(float, b'http', b'timeout')
        for try_count in range(max_try):
            try:
                with contextlib.closing(
                    urlopener.open(request, timeout=timeout)
                ) as rsp:
                    body = rsp.read()
                break
            except util.urlerr.urlerror as err:
                if try_count == max_try - 1:
                    raise
                ui.debug(
                    b'Conduit Request failed (try %d/%d): %r\n'
                    % (try_count + 1, max_try, err)
                )
                # failing request might come from overloaded server
                retry_interval = ui.configint(b'phabricator', b'retry.interval')
                time.sleep(retry_interval)
    ui.debug(b'Conduit Response: %s\n' % body)
    parsed = pycompat.rapply(
        lambda x: encoding.unitolocal(x) if isinstance(x, str) else x,
        # json.loads only accepts bytes from py3.6+
        pycompat.json_loads(encoding.unifromlocal(body)),
    )
    if parsed.get(b'error_code'):
        msg = _(b'Conduit Error (%s): %s') % (
            parsed[b'error_code'],
            parsed[b'error_info'],
        )
        raise error.Abort(msg)
    return parsed[b'result']


@vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
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.
    """
    # json.loads only accepts bytes from 3.6+
    rawparams = encoding.unifromlocal(ui.fin.read())
    # json.loads only returns unicode strings
    params = pycompat.rapply(
        lambda x: encoding.unitolocal(x) if isinstance(x, str) else x,
        pycompat.json_loads(rawparams),
    )
    # json.dumps only accepts unicode strings
    result = pycompat.rapply(
        lambda x: encoding.unifromlocal(x) if isinstance(x, bytes) else x,
        callconduit(ui, name, params),
    )
    s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
    ui.write(b'%s\n' % encoding.unitolocal(s))


def getrepophid(repo):
    """given callsign, return repository PHID or None"""
    # developer config: phabricator.repophid
    repophid = repo.ui.config(b'phabricator', b'repophid')
    if repophid:
        return repophid
    callsign = repo.ui.config(b'phabricator', b'callsign')
    if not callsign:
        return None
    query = callconduit(
        repo.ui,
        b'diffusion.repository.search',
        {b'constraints': {b'callsigns': [callsign]}},
    )
    if len(query[b'data']) == 0:
        return None
    repophid = query[b'data'][0][b'phid']
    repo.ui.setconfig(b'phabricator', b'repophid', repophid)
    return repophid


_differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
_differentialrevisiondescre = re.compile(
    br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M
)


def getoldnodedrevmap(repo, nodelist):
    """find previous nodes that has been sent to Phabricator

    return {node: (oldnode, Differential diff, Differential Revision ID)}
    for node in nodelist with known previous sent versions, or associated
    Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
    be ``None``.

    Examines commit messages like "Differential Revision:" to get the
    association information.

    If such commit message line is not found, 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.

    The ``old node``, if not None, is guaranteed to be the last diff of
    corresponding Differential Revision, and exist in the repo.
    """
    unfi = repo.unfiltered()
    has_node = unfi.changelog.index.has_node

    result = {}  # {node: (oldnode?, lastdiff?, drev)}
    # ordered for test stability when printing new -> old mapping below
    toconfirm = util.sortdict()  # {node: (force, {precnode}, drev)}
    for node in nodelist:
        ctx = unfi[node]
        # For tags like "D123", put them into "toconfirm" to verify later
        precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
        for n in precnodes:
            if has_node(n):
                for tag in unfi.nodetags(n):
                    m = _differentialrevisiontagre.match(tag)
                    if m:
                        toconfirm[node] = (0, set(precnodes), int(m.group(1)))
                        break
                else:
                    continue  # move to next predecessor
                break  # found a tag, stop
        else:
            # Check commit message
            m = _differentialrevisiondescre.search(ctx.description())
            if m:
                toconfirm[node] = (1, set(precnodes), int(m.group('id')))

    # Double check if tags are genuine by collecting all old nodes from
    # Phabricator, and expect precursors overlap with it.
    if toconfirm:
        drevs = [drev for force, precs, drev in toconfirm.values()]
        alldiffs = callconduit(
            unfi.ui, b'differential.querydiffs', {b'revisionIDs': drevs}
        )

        def getnodes(d, precset):
            # Ignore other nodes that were combined into the Differential
            # that aren't predecessors of the current local node.
            return [n for n in getlocalcommits(d) if n in precset]

        for newnode, (force, precset, drev) in toconfirm.items():
            diffs = [
                d for d in alldiffs.values() if int(d[b'revisionID']) == drev
            ]

            # local predecessors known by Phabricator
            phprecset = {n for d in diffs for n in getnodes(d, precset)}

            # Ignore if precursors (Phabricator and local repo) do not overlap,
            # and force is not set (when commit message says nothing)
            if not force and not phprecset:
                tagname = b'D%d' % drev
                tags.tag(
                    repo,
                    tagname,
                    repo.nullid,
                    message=None,
                    user=None,
                    date=None,
                    local=True,
                )
                unfi.ui.warn(
                    _(
                        b'D%d: local tag removed - does not match '
                        b'Differential history\n'
                    )
                    % drev
                )
                continue

            # Find the last node using Phabricator metadata, and make sure it
            # exists in the repo
            oldnode = lastdiff = None
            if diffs:
                lastdiff = max(diffs, key=lambda d: int(d[b'id']))
                oldnodes = getnodes(lastdiff, precset)

                _debug(
                    unfi.ui,
                    b"%s mapped to old nodes %s\n"
                    % (
                        short(newnode),
                        stringutil.pprint([short(n) for n in sorted(oldnodes)]),
                    ),
                )

                # If this commit was the result of `hg fold` after submission,
                # and now resubmitted with --fold, the easiest thing to do is
                # to leave the node clear.  This only results in creating a new
                # diff for the _same_ Differential Revision if this commit is
                # the first or last in the selected range.  If we picked a node
                # from the list instead, it would have to be the lowest if at
                # the beginning of the --fold range, or the highest at the end.
                # Otherwise, one or more of the nodes wouldn't be considered in
                # the diff, and the Differential wouldn't be properly updated.
                # If this commit is the result of `hg split` in the same
                # scenario, there is a single oldnode here (and multiple
                # newnodes mapped to it).  That makes it the same as the normal
                # case, as the edges of the newnode range cleanly maps to one
                # oldnode each.
                if len(oldnodes) == 1:
                    oldnode = oldnodes[0]
                if oldnode and not has_node(oldnode):
                    oldnode = None

            result[newnode] = (oldnode, lastdiff, drev)

    return result


def getdrevmap(repo, revs):
    """Return a dict mapping each rev in `revs` to their Differential Revision
    ID or None.
    """
    result = {}
    for rev in revs:
        result[rev] = None
        ctx = repo[rev]
        # Check commit message
        m = _differentialrevisiondescre.search(ctx.description())
        if m:
            result[rev] = int(m.group('id'))
            continue
        # Check tags
        for tag in repo.nodetags(ctx.node()):
            m = _differentialrevisiontagre.match(tag)
            if m:
                result[rev] = int(m.group(1))
                break

    return result


def getdiff(basectx, ctx, diffopts):
    """plain-text diff without header (user, commit message, etc)"""
    output = util.stringio()
    for chunk, _label in patch.diffui(
        ctx.repo(), basectx.p1().node(), ctx.node(), None, opts=diffopts
    ):
        output.write(chunk)
    return output.getvalue()


class DiffChangeType:
    ADD = 1
    CHANGE = 2
    DELETE = 3
    MOVE_AWAY = 4
    COPY_AWAY = 5
    MOVE_HERE = 6
    COPY_HERE = 7
    MULTICOPY = 8


class DiffFileType:
    TEXT = 1
    IMAGE = 2
    BINARY = 3


@attr.s
class phabhunk(dict):
    """Represents a Differential hunk, which is owned by a Differential change"""

    oldOffset = attr.ib(default=0)  # camelcase-required
    oldLength = attr.ib(default=0)  # camelcase-required
    newOffset = attr.ib(default=0)  # camelcase-required
    newLength = attr.ib(default=0)  # camelcase-required
    corpus = attr.ib(default='')
    # These get added to the phabchange's equivalents
    addLines = attr.ib(default=0)  # camelcase-required
    delLines = attr.ib(default=0)  # camelcase-required


@attr.s
class phabchange:
    """Represents a Differential change, owns Differential hunks and owned by a
    Differential diff.  Each one represents one file in a diff.
    """

    currentPath = attr.ib(default=None)  # camelcase-required
    oldPath = attr.ib(default=None)  # camelcase-required
    awayPaths = attr.ib(default=attr.Factory(list))  # camelcase-required
    metadata = attr.ib(default=attr.Factory(dict))
    oldProperties = attr.ib(default=attr.Factory(dict))  # camelcase-required
    newProperties = attr.ib(default=attr.Factory(dict))  # camelcase-required
    type = attr.ib(default=DiffChangeType.CHANGE)
    fileType = attr.ib(default=DiffFileType.TEXT)  # camelcase-required
    commitHash = attr.ib(default=None)  # camelcase-required
    addLines = attr.ib(default=0)  # camelcase-required
    delLines = attr.ib(default=0)  # camelcase-required
    hunks = attr.ib(default=attr.Factory(list))

    def copynewmetadatatoold(self):
        for key in list(self.metadata.keys()):
            newkey = key.replace(b'new:', b'old:')
            self.metadata[newkey] = self.metadata[key]

    def addoldmode(self, value):
        self.oldProperties[b'unix:filemode'] = value

    def addnewmode(self, value):
        self.newProperties[b'unix:filemode'] = value

    def addhunk(self, hunk):
        if not isinstance(hunk, phabhunk):
            raise error.Abort(b'phabchange.addhunk only takes phabhunks')
        self.hunks.append(pycompat.byteskwargs(attr.asdict(hunk)))
        # It's useful to include these stats since the Phab web UI shows them,
        # and uses them to estimate how large a change a Revision is. Also used
        # in email subjects for the [+++--] bit.
        self.addLines += hunk.addLines
        self.delLines += hunk.delLines


@attr.s
class phabdiff:
    """Represents a Differential diff, owns Differential changes.  Corresponds
    to a commit.
    """

    # Doesn't seem to be any reason to send this (output of uname -n)
    sourceMachine = attr.ib(default=b'')  # camelcase-required
    sourcePath = attr.ib(default=b'/')  # camelcase-required
    sourceControlBaseRevision = attr.ib(default=b'0' * 40)  # camelcase-required
    sourceControlPath = attr.ib(default=b'/')  # camelcase-required
    sourceControlSystem = attr.ib(default=b'hg')  # camelcase-required
    branch = attr.ib(default=b'default')
    bookmark = attr.ib(default=None)
    creationMethod = attr.ib(default=b'phabsend')  # camelcase-required
    lintStatus = attr.ib(default=b'none')  # camelcase-required
    unitStatus = attr.ib(default=b'none')  # camelcase-required
    changes = attr.ib(default=attr.Factory(dict))
    repositoryPHID = attr.ib(default=None)  # camelcase-required

    def addchange(self, change):
        if not isinstance(change, phabchange):
            raise error.Abort(b'phabdiff.addchange only takes phabchanges')
        self.changes[change.currentPath] = pycompat.byteskwargs(
            attr.asdict(change)
        )


def maketext(pchange, basectx, ctx, fname):
    """populate the phabchange for a text file"""
    repo = ctx.repo()
    fmatcher = match.exact([fname])
    diffopts = mdiff.diffopts(git=True, context=32767)
    _pfctx, _fctx, header, fhunks = next(
        patch.diffhunks(repo, basectx.p1(), ctx, fmatcher, opts=diffopts)
    )

    for fhunk in fhunks:
        (oldOffset, oldLength, newOffset, newLength), lines = fhunk
        corpus = b''.join(lines[1:])
        shunk = list(header)
        shunk.extend(lines)
        _mf, _mt, addLines, delLines, _hb = patch.diffstatsum(
            patch.diffstatdata(util.iterlines(shunk))
        )
        pchange.addhunk(
            phabhunk(
                oldOffset,
                oldLength,
                newOffset,
                newLength,
                corpus,
                addLines,
                delLines,
            )
        )


def uploadchunks(fctx, fphid):
    """upload large binary files as separate chunks.
    Phab requests chunking over 8MiB, and splits into 4MiB chunks
    """
    ui = fctx.repo().ui
    chunks = callconduit(ui, b'file.querychunks', {b'filePHID': fphid})
    with ui.makeprogress(
        _(b'uploading file chunks'), unit=_(b'chunks'), total=len(chunks)
    ) as progress:
        for chunk in chunks:
            progress.increment()
            if chunk[b'complete']:
                continue
            bstart = int(chunk[b'byteStart'])
            bend = int(chunk[b'byteEnd'])
            callconduit(
                ui,
                b'file.uploadchunk',
                {
                    b'filePHID': fphid,
                    b'byteStart': bstart,
                    b'data': base64.b64encode(fctx.data()[bstart:bend]),
                    b'dataEncoding': b'base64',
                },
            )


def uploadfile(fctx):
    """upload binary files to Phabricator"""
    repo = fctx.repo()
    ui = repo.ui
    fname = fctx.path()
    size = fctx.size()
    fhash = pycompat.bytestr(hashlib.sha256(fctx.data()).hexdigest())

    # an allocate call is required first to see if an upload is even required
    # (Phab might already have it) and to determine if chunking is needed
    allocateparams = {
        b'name': fname,
        b'contentLength': size,
        b'contentHash': fhash,
    }
    filealloc = callconduit(ui, b'file.allocate', allocateparams)
    fphid = filealloc[b'filePHID']

    if filealloc[b'upload']:
        ui.write(_(b'uploading %s\n') % bytes(fctx))
        if not fphid:
            uploadparams = {
                b'name': fname,
                b'data_base64': base64.b64encode(fctx.data()),
            }
            fphid = callconduit(ui, b'file.upload', uploadparams)
        else:
            uploadchunks(fctx, fphid)
    else:
        ui.debug(b'server already has %s\n' % bytes(fctx))

    if not fphid:
        raise error.Abort(b'Upload of %s failed.' % bytes(fctx))

    return fphid


def addoldbinary(pchange, oldfctx, fctx):
    """add the metadata for the previous version of a binary file to the
    phabchange for the new version

    ``oldfctx`` is the previous version of the file; ``fctx`` is the new
    version of the file, or None if the file is being removed.
    """
    if not fctx or fctx.cmp(oldfctx):
        # Files differ, add the old one
        pchange.metadata[b'old:file:size'] = oldfctx.size()
        mimeguess, _enc = mimetypes.guess_type(
            encoding.unifromlocal(oldfctx.path())
        )
        if mimeguess:
            pchange.metadata[b'old:file:mime-type'] = pycompat.bytestr(
                mimeguess
            )
        fphid = uploadfile(oldfctx)
        pchange.metadata[b'old:binary-phid'] = fphid
    else:
        # If it's left as IMAGE/BINARY web UI might try to display it
        pchange.fileType = DiffFileType.TEXT
        pchange.copynewmetadatatoold()


def makebinary(pchange, fctx):
    """populate the phabchange for a binary file"""
    pchange.fileType = DiffFileType.BINARY
    fphid = uploadfile(fctx)
    pchange.metadata[b'new:binary-phid'] = fphid
    pchange.metadata[b'new:file:size'] = fctx.size()
    mimeguess, _enc = mimetypes.guess_type(encoding.unifromlocal(fctx.path()))
    if mimeguess:
        mimeguess = pycompat.bytestr(mimeguess)
        pchange.metadata[b'new:file:mime-type'] = mimeguess
        if mimeguess.startswith(b'image/'):
            pchange.fileType = DiffFileType.IMAGE


# Copied from mercurial/patch.py
gitmode = {b'l': b'120000', b'x': b'100755', b'': b'100644'}


def notutf8(fctx):
    """detect non-UTF-8 text files since Phabricator requires them to be marked
    as binary
    """
    try:
        fctx.data().decode('utf-8')
        return False
    except UnicodeDecodeError:
        fctx.repo().ui.write(
            _(b'file %s detected as non-UTF-8, marked as binary\n')
            % fctx.path()
        )
        return True


def addremoved(pdiff, basectx, ctx, removed):
    """add removed files to the phabdiff. Shouldn't include moves"""
    for fname in removed:
        pchange = phabchange(
            currentPath=fname, oldPath=fname, type=DiffChangeType.DELETE
        )
        oldfctx = basectx.p1()[fname]
        pchange.addoldmode(gitmode[oldfctx.flags()])
        if not (oldfctx.isbinary() or notutf8(oldfctx)):
            maketext(pchange, basectx, ctx, fname)

        pdiff.addchange(pchange)


def addmodified(pdiff, basectx, ctx, modified):
    """add modified files to the phabdiff"""
    for fname in modified:
        fctx = ctx[fname]
        oldfctx = basectx.p1()[fname]
        pchange = phabchange(currentPath=fname, oldPath=fname)
        filemode = gitmode[fctx.flags()]
        originalmode = gitmode[oldfctx.flags()]
        if filemode != originalmode:
            pchange.addoldmode(originalmode)
            pchange.addnewmode(filemode)

        if (
            fctx.isbinary()
            or notutf8(fctx)
            or oldfctx.isbinary()
            or notutf8(oldfctx)
        ):
            makebinary(pchange, fctx)
            addoldbinary(pchange, oldfctx, fctx)
        else:
            maketext(pchange, basectx, ctx, fname)

        pdiff.addchange(pchange)


def addadded(pdiff, basectx, ctx, added, removed):
    """add file adds to the phabdiff, both new files and copies/moves"""
    # Keep track of files that've been recorded as moved/copied, so if there are
    # additional copies we can mark them (moves get removed from removed)
    copiedchanges = {}
    movedchanges = {}

    copy = {}
    if basectx != ctx:
        copy = copies.pathcopies(basectx.p1(), ctx)

    for fname in added:
        fctx = ctx[fname]
        oldfctx = None
        pchange = phabchange(currentPath=fname)

        filemode = gitmode[fctx.flags()]

        if copy:
            originalfname = copy.get(fname, fname)
        else:
            originalfname = fname
            if fctx.renamed():
                originalfname = fctx.renamed()[0]

        renamed = fname != originalfname

        if renamed:
            oldfctx = basectx.p1()[originalfname]
            originalmode = gitmode[oldfctx.flags()]
            pchange.oldPath = originalfname

            if originalfname in removed:
                origpchange = phabchange(
                    currentPath=originalfname,
                    oldPath=originalfname,
                    type=DiffChangeType.MOVE_AWAY,
                    awayPaths=[fname],
                )
                movedchanges[originalfname] = origpchange
                removed.remove(originalfname)
                pchange.type = DiffChangeType.MOVE_HERE
            elif originalfname in movedchanges:
                movedchanges[originalfname].type = DiffChangeType.MULTICOPY
                movedchanges[originalfname].awayPaths.append(fname)
                pchange.type = DiffChangeType.COPY_HERE
            else:  # pure copy
                if originalfname not in copiedchanges:
                    origpchange = phabchange(
                        currentPath=originalfname, type=DiffChangeType.COPY_AWAY
                    )
                    copiedchanges[originalfname] = origpchange
                else:
                    origpchange = copiedchanges[originalfname]
                origpchange.awayPaths.append(fname)
                pchange.type = DiffChangeType.COPY_HERE

            if filemode != originalmode:
                pchange.addoldmode(originalmode)
                pchange.addnewmode(filemode)
        else:  # Brand-new file
            pchange.addnewmode(gitmode[fctx.flags()])
            pchange.type = DiffChangeType.ADD

        if (
            fctx.isbinary()
            or notutf8(fctx)
            or (oldfctx and (oldfctx.isbinary() or notutf8(oldfctx)))
        ):
            makebinary(pchange, fctx)
            if renamed:
                addoldbinary(pchange, oldfctx, fctx)
        else:
            maketext(pchange, basectx, ctx, fname)

        pdiff.addchange(pchange)

    for _path, copiedchange in copiedchanges.items():
        pdiff.addchange(copiedchange)
    for _path, movedchange in movedchanges.items():
        pdiff.addchange(movedchange)


def creatediff(basectx, ctx):
    """create a Differential Diff"""
    repo = ctx.repo()
    repophid = getrepophid(repo)
    # Create a "Differential Diff" via "differential.creatediff" API
    pdiff = phabdiff(
        sourceControlBaseRevision=b'%s' % basectx.p1().hex(),
        branch=b'%s' % ctx.branch(),
    )
    modified, added, removed, _d, _u, _i, _c = basectx.p1().status(ctx)
    # addadded will remove moved files from removed, so addremoved won't get
    # them
    addadded(pdiff, basectx, ctx, added, removed)
    addmodified(pdiff, basectx, ctx, modified)
    addremoved(pdiff, basectx, ctx, removed)
    if repophid:
        pdiff.repositoryPHID = repophid
    diff = callconduit(
        repo.ui,
        b'differential.creatediff',
        pycompat.byteskwargs(attr.asdict(pdiff)),
    )
    if not diff:
        if basectx != ctx:
            msg = _(b'cannot create diff for %s::%s') % (basectx, ctx)
        else:
            msg = _(b'cannot create diff for %s') % ctx
        raise error.Abort(msg)
    return diff


def writediffproperties(ctxs, diff):
    """write metadata to diff so patches could be applied losslessly

    ``ctxs`` is the list of commits that created the diff, in ascending order.
    The list is generally a single commit, but may be several when using
    ``phabsend --fold``.
    """
    # creatediff returns with a diffid but query returns with an id
    diffid = diff.get(b'diffid', diff.get(b'id'))
    basectx = ctxs[0]
    tipctx = ctxs[-1]

    params = {
        b'diff_id': diffid,
        b'name': b'hg:meta',
        b'data': templatefilters.json(
            {
                b'user': tipctx.user(),
                b'date': b'%d %d' % tipctx.date(),
                b'branch': tipctx.branch(),
                b'node': tipctx.hex(),
                b'parent': basectx.p1().hex(),
            }
        ),
    }
    callconduit(basectx.repo().ui, b'differential.setdiffproperty', params)

    commits = {}
    for ctx in ctxs:
        commits[ctx.hex()] = {
            b'author': stringutil.person(ctx.user()),
            b'authorEmail': stringutil.email(ctx.user()),
            b'time': int(ctx.date()[0]),
            b'commit': ctx.hex(),
            b'parents': [ctx.p1().hex()],
            b'branch': ctx.branch(),
        }
    params = {
        b'diff_id': diffid,
        b'name': b'local:commits',
        b'data': templatefilters.json(commits),
    }
    callconduit(basectx.repo().ui, b'differential.setdiffproperty', params)


def createdifferentialrevision(
    ctxs,
    revid=None,
    parentrevphid=None,
    oldbasenode=None,
    oldnode=None,
    olddiff=None,
    actions=None,
    comment=None,
):
    """create or update a Differential Revision

    If revid is None, create a new Differential Revision, otherwise update
    revid. If parentrevphid is not None, set it as a dependency.

    If there is a single commit for the new Differential Revision, ``ctxs`` will
    be a list of that single context.  Otherwise, it is a list that covers the
    range of changes for the differential, where ``ctxs[0]`` is the first change
    to include and ``ctxs[-1]`` is the last.

    If oldnode is not None, check if the patch content (without commit message
    and metadata) has changed before creating another diff.  For a Revision with
    a single commit, ``oldbasenode`` and ``oldnode`` have the same value.  For a
    Revision covering multiple commits, ``oldbasenode`` corresponds to
    ``ctxs[0]`` the previous time this Revision was posted, and ``oldnode``
    corresponds to ``ctxs[-1]``.

    If actions is not None, they will be appended to the transaction.
    """
    ctx = ctxs[-1]
    basectx = ctxs[0]

    repo = ctx.repo()
    if oldnode:
        diffopts = mdiff.diffopts(git=True, context=32767)
        unfi = repo.unfiltered()
        oldctx = unfi[oldnode]
        oldbasectx = unfi[oldbasenode]
        neednewdiff = getdiff(basectx, ctx, diffopts) != getdiff(
            oldbasectx, oldctx, diffopts
        )
    else:
        neednewdiff = True

    transactions = []
    if neednewdiff:
        diff = creatediff(basectx, ctx)
        transactions.append({b'type': b'update', b'value': diff[b'phid']})
        if comment:
            transactions.append({b'type': b'comment', b'value': comment})
    else:
        # Even if we don't need to upload a new diff because the patch content
        # does not change. We might still need to update its metadata so
        # pushers could know the correct node metadata.
        assert olddiff
        diff = olddiff
    writediffproperties(ctxs, diff)

    # Set the parent Revision every time, so commit re-ordering is picked-up
    if parentrevphid:
        transactions.append(
            {b'type': b'parents.set', b'value': [parentrevphid]}
        )

    if actions:
        transactions += actions

    # When folding multiple local commits into a single review, arcanist will
    # take the summary line of the first commit as the title, and then
    # concatenate the rest of the remaining messages (including each of their
    # first lines) to the rest of the first commit message (each separated by
    # an empty line), and use that as the summary field.  Do the same here.
    # For commits with only a one line message, there is no summary field, as
    # this gets assigned to the title.
    fields = util.sortdict()  # sorted for stable wire protocol in tests

    for i, _ctx in enumerate(ctxs):
        # Parse commit message and update related fields.
        desc = _ctx.description()
        info = callconduit(
            repo.ui, b'differential.parsecommitmessage', {b'corpus': desc}
        )

        for k in [b'title', b'summary', b'testPlan']:
            v = info[b'fields'].get(k)
            if not v:
                continue

            if i == 0:
                # Title, summary and test plan (if present) are taken verbatim
                # for the first commit.
                fields[k] = v.rstrip()
                continue
            elif k == b'title':
                # Add subsequent titles (i.e. the first line of the commit
                # message) back to the summary.
                k = b'summary'

            # Append any current field to the existing composite field
            fields[k] = b'\n\n'.join(filter(None, [fields.get(k), v.rstrip()]))

    for k, v in fields.items():
        transactions.append({b'type': k, b'value': v})

    params = {b'transactions': transactions}
    if revid is not None:
        # Update an existing Differential Revision
        params[b'objectIdentifier'] = revid

    revision = callconduit(repo.ui, b'differential.revision.edit', params)
    if not revision:
        if len(ctxs) == 1:
            msg = _(b'cannot create revision for %s') % ctx
        else:
            msg = _(b'cannot create revision for %s::%s') % (basectx, ctx)
        raise error.Abort(msg)

    return revision, diff


def userphids(ui, names):
    """convert user names to PHIDs"""
    names = [name.lower() for name in names]
    query = {b'constraints': {b'usernames': names}}
    result = callconduit(ui, b'user.search', query)
    # username not found is not an error of the API. So check if we have missed
    # some names here.
    data = result[b'data']
    resolved = {entry[b'fields'][b'username'].lower() for entry in data}
    unresolved = set(names) - resolved
    if unresolved:
        raise error.Abort(
            _(b'unknown username: %s') % b' '.join(sorted(unresolved))
        )
    return [entry[b'phid'] for entry in data]


def _print_phabsend_action(ui, ctx, newrevid, action):
    """print the ``action`` that occurred when posting ``ctx`` for review

    This is a utility function for the sending phase of ``phabsend``, which
    makes it easier to show a status for all local commits with `--fold``.
    """
    actiondesc = ui.label(
        {
            b'created': _(b'created'),
            b'skipped': _(b'skipped'),
            b'updated': _(b'updated'),
        }[action],
        b'phabricator.action.%s' % action,
    )
    drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
    summary = cmdutil.format_changeset_summary(ui, ctx, b'phabsend')
    ui.write(_(b'%s - %s - %s\n') % (drevdesc, actiondesc, summary))


def _amend_diff_properties(unfi, drevid, newnodes, diff):
    """update the local commit list for the ``diff`` associated with ``drevid``

    This is a utility function for the amend phase of ``phabsend``, which
    converts failures to warning messages.
    """
    _debug(
        unfi.ui,
        b"new commits: %s\n" % stringutil.pprint([short(n) for n in newnodes]),
    )

    try:
        writediffproperties([unfi[newnode] for newnode in newnodes], diff)
    except util.urlerr.urlerror:
        # If it fails just warn and keep going, otherwise the DREV
        # associations will be lost
        unfi.ui.warnnoi18n(b'Failed to update metadata for D%d\n' % drevid)


@vcrcommand(
    b'phabsend',
    [
        (b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
        (b'', b'amend', True, _(b'update commit messages')),
        (b'', b'reviewer', [], _(b'specify reviewers')),
        (b'', b'blocker', [], _(b'specify blocking reviewers')),
        (
            b'm',
            b'comment',
            b'',
            _(b'add a comment to Revisions with new/updated Diffs'),
        ),
        (b'', b'confirm', None, _(b'ask for confirmation before sending')),
        (b'', b'fold', False, _(b'combine the revisions into one review')),
    ],
    _(b'REV [OPTIONS]'),
    helpcategory=command.CATEGORY_IMPORT_EXPORT,
)
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.

    If --amend is set, update commit messages so they have the
    ``Differential Revision`` URL, remove related tags. This is similar to what
    arcanist will do, and is more desired in author-push workflows. Otherwise,
    use local tags to record the ``Differential Revision`` association.

    The --confirm option lets you confirm changesets before sending them. You
    can also add following to your configuration file to make it default
    behaviour::

        [phabsend]
        confirm = true

    By default, a separate review will be created for each commit that is
    selected, and will have the same parent/child relationship in Phabricator.
    If ``--fold`` is set, multiple commits are rolled up into a single review
    as if diffed from the parent of the first revision to the last.  The commit
    messages are concatenated in the summary field on Phabricator.

    phabsend will check obsstore and the above association to decide whether to
    update an existing Differential Revision, or create a new one.
    """
    opts = pycompat.byteskwargs(opts)
    revs = list(revs) + opts.get(b'rev', [])
    revs = logcmdutil.revrange(repo, revs)
    revs.sort()  # ascending order to preserve topological parent/child in phab

    if not revs:
        raise error.Abort(_(b'phabsend requires at least one changeset'))
    if opts.get(b'amend'):
        cmdutil.checkunfinished(repo)

    ctxs = [repo[rev] for rev in revs]

    if any(c for c in ctxs if c.obsolete()):
        raise error.Abort(_(b"obsolete commits cannot be posted for review"))

    # Ensure the local commits are an unbroken range.  The semantics of the
    # --fold option implies this, and the auto restacking of orphans requires
    # it.  Otherwise A+C in A->B->C will cause B to be orphaned, and C' to
    # get A' as a parent.
    def _fail_nonlinear_revs(revs, revtype):
        badnodes = [repo[r].node() for r in revs]
        raise error.Abort(
            _(b"cannot phabsend multiple %s revisions: %s")
            % (revtype, scmutil.nodesummaries(repo, badnodes)),
            hint=_(b"the revisions must form a linear chain"),
        )

    heads = repo.revs(b'heads(%ld)', revs)
    if len(heads) > 1:
        _fail_nonlinear_revs(heads, b"head")

    roots = repo.revs(b'roots(%ld)', revs)
    if len(roots) > 1:
        _fail_nonlinear_revs(roots, b"root")

    fold = opts.get(b'fold')
    if fold:
        if len(revs) == 1:
            # TODO: just switch to --no-fold instead?
            raise error.Abort(_(b"cannot fold a single revision"))

        # There's no clear way to manage multiple commits with a Dxxx tag, so
        # require the amend option.  (We could append "_nnn", but then it
        # becomes jumbled if earlier commits are added to an update.)  It should
        # lock the repo and ensure that the range is editable, but that would
        # make the code pretty convoluted.  The default behavior of `arc` is to
        # create a new review anyway.
        if not opts.get(b"amend"):
            raise error.Abort(_(b"cannot fold with --no-amend"))

        # It might be possible to bucketize the revisions by the DREV value, and
        # iterate over those groups when posting, and then again when amending.
        # But for simplicity, require all selected revisions to be for the same
        # DREV (if present).  Adding local revisions to an existing DREV is
        # acceptable.
        drevmatchers = [
            _differentialrevisiondescre.search(ctx.description())
            for ctx in ctxs
        ]
        if len({m.group('url') for m in drevmatchers if m}) > 1:
            raise error.Abort(
                _(b"cannot fold revisions with different DREV values")
            )

    # {newnode: (oldnode, olddiff, olddrev}
    oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])

    confirm = ui.configbool(b'phabsend', b'confirm')
    confirm |= bool(opts.get(b'confirm'))
    if confirm:
        confirmed = _confirmbeforesend(repo, revs, oldmap)
        if not confirmed:
            raise error.Abort(_(b'phabsend cancelled'))

    actions = []
    reviewers = opts.get(b'reviewer', [])
    blockers = opts.get(b'blocker', [])
    phids = []
    if reviewers:
        phids.extend(userphids(repo.ui, reviewers))
    if blockers:
        phids.extend(
            map(
                lambda phid: b'blocking(%s)' % phid,
                userphids(repo.ui, blockers),
            )
        )
    if phids:
        actions.append({b'type': b'reviewers.add', b'value': phids})

    drevids = []  # [int]
    diffmap = {}  # {newnode: diff}

    # Send patches one by one so we know their Differential Revision PHIDs and
    # can provide dependency relationship
    lastrevphid = None
    for ctx in ctxs:
        if fold:
            ui.debug(b'sending rev %d::%d\n' % (ctx.rev(), ctxs[-1].rev()))
        else:
            ui.debug(b'sending rev %d\n' % ctx.rev())

        # Get Differential Revision ID
        oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
        oldbasenode, oldbasediff, oldbaserevid = oldnode, olddiff, revid

        if fold:
            oldbasenode, oldbasediff, oldbaserevid = oldmap.get(
                ctxs[-1].node(), (None, None, None)
            )

        if oldnode != ctx.node() or opts.get(b'amend'):
            # Create or update Differential Revision
            revision, diff = createdifferentialrevision(
                ctxs if fold else [ctx],
                revid,
                lastrevphid,
                oldbasenode,
                oldnode,
                olddiff,
                actions,
                opts.get(b'comment'),
            )

            if fold:
                for ctx in ctxs:
                    diffmap[ctx.node()] = diff
            else:
                diffmap[ctx.node()] = diff

            newrevid = int(revision[b'object'][b'id'])
            newrevphid = revision[b'object'][b'phid']
            if revid:
                action = b'updated'
            else:
                action = b'created'

            # Create a local tag to note the association, if commit message
            # does not have it already
            if not fold:
                m = _differentialrevisiondescre.search(ctx.description())
                if not m or int(m.group('id')) != newrevid:
                    tagname = b'D%d' % newrevid
                    tags.tag(
                        repo,
                        tagname,
                        ctx.node(),
                        message=None,
                        user=None,
                        date=None,
                        local=True,
                    )
        else:
            # Nothing changed. But still set "newrevphid" so the next revision
            # could depend on this one and "newrevid" for the summary line.
            newrevphid = querydrev(repo.ui, b'%d' % revid)[0][b'phid']
            newrevid = revid
            action = b'skipped'

        drevids.append(newrevid)
        lastrevphid = newrevphid

        if fold:
            for c in ctxs:
                if oldmap.get(c.node(), (None, None, None))[2]:
                    action = b'updated'
                else:
                    action = b'created'
                _print_phabsend_action(ui, c, newrevid, action)
            break

        _print_phabsend_action(ui, ctx, newrevid, action)

    # Update commit messages and remove tags
    if opts.get(b'amend'):
        unfi = repo.unfiltered()
        drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
        with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
            # Eagerly evaluate commits to restabilize before creating new
            # commits.  The selected revisions are excluded because they are
            # automatically restacked as part of the submission process.
            restack = [
                c
                for c in repo.set(
                    b"(%ld::) - (%ld) - unstable() - obsolete() - public()",
                    revs,
                    revs,
                )
            ]
            wnode = unfi[b'.'].node()
            mapping = {}  # {oldnode: [newnode]}
            newnodes = []

            drevid = drevids[0]

            for i, rev in enumerate(revs):
                old = unfi[rev]
                if not fold:
                    drevid = drevids[i]
                drev = [d for d in drevs if int(d[b'id']) == drevid][0]

                newdesc = get_amended_desc(drev, old, fold)
                # Make sure commit message contain "Differential Revision"
                if (
                    old.description() != newdesc
                    or old.p1().node() in mapping
                    or old.p2().node() in mapping
                ):
                    if old.phase() == phases.public:
                        ui.warn(
                            _(b"warning: not updating public commit %s\n")
                            % scmutil.formatchangeid(old)
                        )
                        continue
                    parents = [
                        mapping.get(old.p1().node(), (old.p1(),))[0],
                        mapping.get(old.p2().node(), (old.p2(),))[0],
                    ]
                    newdesc = rewriteutil.update_hash_refs(
                        repo,
                        newdesc,
                        mapping,
                    )
                    new = context.metadataonlyctx(
                        repo,
                        old,
                        parents=parents,
                        text=newdesc,
                        user=old.user(),
                        date=old.date(),
                        extra=old.extra(),
                    )

                    newnode = new.commit()

                    mapping[old.node()] = [newnode]

                    if fold:
                        # Defer updating the (single) Diff until all nodes are
                        # collected.  No tags were created, so none need to be
                        # removed.
                        newnodes.append(newnode)
                        continue

                    _amend_diff_properties(
                        unfi, drevid, [newnode], diffmap[old.node()]
                    )

                    # Remove local tags since it's no longer necessary
                    tagname = b'D%d' % drevid
                    if tagname in repo.tags():
                        tags.tag(
                            repo,
                            tagname,
                            repo.nullid,
                            message=None,
                            user=None,
                            date=None,
                            local=True,
                        )
                elif fold:
                    # When folding multiple commits into one review with
                    # --fold, track even the commits that weren't amended, so
                    # that their association isn't lost if the properties are
                    # rewritten below.
                    newnodes.append(old.node())

            # If the submitted commits are public, no amend takes place so
            # there are no newnodes and therefore no diff update to do.
            if fold and newnodes:
                diff = diffmap[old.node()]

                # The diff object in diffmap doesn't have the local commits
                # because that could be returned from differential.creatediff,
                # not differential.querydiffs.  So use the queried diff (if
                # present), or force the amend (a new revision is being posted.)
                if not olddiff or set(newnodes) != getlocalcommits(olddiff):
                    _debug(ui, b"updating local commit list for D%d\n" % drevid)
                    _amend_diff_properties(unfi, drevid, newnodes, diff)
                else:
                    _debug(
                        ui,
                        b"local commit list for D%d is already up-to-date\n"
                        % drevid,
                    )
            elif fold:
                _debug(ui, b"no newnodes to update\n")

            # Restack any children of first-time submissions that were orphaned
            # in the process.  The ctx won't report that it is an orphan until
            # the cleanup takes place below.
            for old in restack:
                parents = [
                    mapping.get(old.p1().node(), (old.p1(),))[0],
                    mapping.get(old.p2().node(), (old.p2(),))[0],
                ]
                new = context.metadataonlyctx(
                    repo,
                    old,
                    parents=parents,
                    text=rewriteutil.update_hash_refs(
                        repo, old.description(), mapping
                    ),
                    user=old.user(),
                    date=old.date(),
                    extra=old.extra(),
                )

                newnode = new.commit()

                # Don't obsolete unselected descendants of nodes that have not
                # been changed in this transaction- that results in an error.
                if newnode != old.node():
                    mapping[old.node()] = [newnode]
                    _debug(
                        ui,
                        b"restabilizing %s as %s\n"
                        % (short(old.node()), short(newnode)),
                    )
                else:
                    _debug(
                        ui,
                        b"not restabilizing unchanged %s\n" % short(old.node()),
                    )

            scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
            if wnode in mapping:
                unfi.setparents(mapping[wnode][0])


# Map from "hg:meta" keys to header understood by "hg import". The order is
# consistent with "hg export" output.
_metanamemap = util.sortdict(
    [
        (b'user', b'User'),
        (b'date', b'Date'),
        (b'branch', b'Branch'),
        (b'node', b'Node ID'),
        (b'parent', b'Parent '),
    ]
)


def _confirmbeforesend(repo, revs, oldmap):
    url, token = readurltoken(repo.ui)
    ui = repo.ui
    for rev in revs:
        ctx = repo[rev]
        oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
        if drevid:
            drevdesc = ui.label(b'D%d' % drevid, b'phabricator.drev')
        else:
            drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')

        ui.write(
            _(b'%s - %s\n')
            % (
                drevdesc,
                cmdutil.format_changeset_summary(ui, ctx, b'phabsend'),
            )
        )

    if ui.promptchoice(
        _(b'Send the above changes to %s (Y/n)?$$ &Yes $$ &No') % url
    ):
        return False

    return True


_knownstatusnames = {
    b'accepted',
    b'needsreview',
    b'needsrevision',
    b'closed',
    b'abandoned',
    b'changesplanned',
}


def _getstatusname(drev):
    """get normalized status name from a Differential Revision"""
    return drev[b'statusName'].replace(b' ', b'').lower()


# Small language to specify differential revisions. Support symbols: (), :X,
# +, and -.

_elements = {
    # token-type: binding-strength, primary, prefix, infix, suffix
    b'(': (12, None, (b'group', 1, b')'), None, None),
    b':': (8, None, (b'ancestors', 8), None, None),
    b'&': (5, None, None, (b'and_', 5), None),
    b'+': (4, None, None, (b'add', 4), None),
    b'-': (4, None, None, (b'sub', 4), None),
    b')': (0, None, None, None, None),
    b'symbol': (0, b'symbol', None, None, None),
    b'end': (0, None, None, None, None),
}


def _tokenize(text):
    view = memoryview(text)  # zero-copy slice
    special = b'():+-& '
    pos = 0
    length = len(text)
    while pos < length:
        symbol = b''.join(
            itertools.takewhile(
                lambda ch: ch not in special, pycompat.iterbytestr(view[pos:])
            )
        )
        if symbol:
            yield (b'symbol', symbol, pos)
            pos += len(symbol)
        else:  # special char, ignore space
            if text[pos : pos + 1] != b' ':
                yield (text[pos : pos + 1], None, pos)
            pos += 1
    yield (b'end', None, pos)


def _parse(text):
    tree, pos = parser.parser(_elements).parse(_tokenize(text))
    if pos != len(text):
        raise error.ParseError(b'invalid token', pos)
    return tree


def _parsedrev(symbol):
    """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
    if symbol.startswith(b'D') and symbol[1:].isdigit():
        return int(symbol[1:])
    if symbol.isdigit():
        return int(symbol)


def _prefetchdrevs(tree):
    """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
    drevs = set()
    ancestordrevs = set()
    op = tree[0]
    if op == b'symbol':
        r = _parsedrev(tree[1])
        if r:
            drevs.add(r)
    elif op == b'ancestors':
        r, a = _prefetchdrevs(tree[1])
        drevs.update(r)
        ancestordrevs.update(r)
        ancestordrevs.update(a)
    else:
        for t in tree[1:]:
            r, a = _prefetchdrevs(t)
            drevs.update(r)
            ancestordrevs.update(a)
    return drevs, ancestordrevs


def querydrev(ui, spec):
    """return a list of "Differential Revision" dicts

    spec is a string using a simple query language, see docstring in phabread
    for details.

    A "Differential Revision dict" looks like:

        {
            "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
            "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
            "auxiliary": {
              "phabricator:depends-on": [
                "PHID-DREV-gbapp366kutjebt7agcd"
              ]
              "phabricator:projects": [],
            },
            "branch": "default",
            "ccs": [],
            "commits": [],
            "dateCreated": "1499181406",
            "dateModified": "1499182103",
            "diffs": [
              "3",
              "4",
            ],
            "hashes": [],
            "id": "2",
            "lineCount": "2",
            "phid": "PHID-DREV-672qvysjcczopag46qty",
            "properties": {},
            "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
            "reviewers": [],
            "sourcePath": null
            "status": "0",
            "statusName": "Needs Review",
            "summary": "",
            "testPlan": "",
            "title": "example",
            "uri": "https://phab.example.com/D2",
        }
    """
    # TODO: replace differential.query and differential.querydiffs with
    # differential.diff.search because the former (and their output) are
    # frozen, and planned to be deprecated and removed.

    def fetch(params):
        """params -> single drev or None"""
        key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
        if key in prefetched:
            return prefetched[key]
        drevs = callconduit(ui, b'differential.query', params)
        # Fill prefetched with the result
        for drev in drevs:
            prefetched[drev[b'phid']] = drev
            prefetched[int(drev[b'id'])] = drev
        if key not in prefetched:
            raise error.Abort(
                _(b'cannot get Differential Revision %r') % params
            )
        return prefetched[key]

    def getstack(topdrevids):
        """given a top, get a stack from the bottom, [id] -> [id]"""
        visited = set()
        result = []
        queue = [{b'ids': [i]} for i in topdrevids]
        while queue:
            params = queue.pop()
            drev = fetch(params)
            if drev[b'id'] in visited:
                continue
            visited.add(drev[b'id'])
            result.append(int(drev[b'id']))
            auxiliary = drev.get(b'auxiliary', {})
            depends = auxiliary.get(b'phabricator:depends-on', [])
            for phid in depends:
                queue.append({b'phids': [phid]})
        result.reverse()
        return smartset.baseset(result)

    # Initialize prefetch cache
    prefetched = {}  # {id or phid: drev}

    tree = _parse(spec)
    drevs, ancestordrevs = _prefetchdrevs(tree)

    # developer config: phabricator.batchsize
    batchsize = ui.configint(b'phabricator', b'batchsize')

    # Prefetch Differential Revisions in batch
    tofetch = set(drevs)
    for r in ancestordrevs:
        tofetch.update(range(max(1, r - batchsize), r + 1))
    if drevs:
        fetch({b'ids': list(tofetch)})
    validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))

    # Walk through the tree, return smartsets
    def walk(tree):
        op = tree[0]
        if op == b'symbol':
            drev = _parsedrev(tree[1])
            if drev:
                return smartset.baseset([drev])
            elif tree[1] in _knownstatusnames:
                drevs = [
                    r
                    for r in validids
                    if _getstatusname(prefetched[r]) == tree[1]
                ]
                return smartset.baseset(drevs)
            else:
                raise error.Abort(_(b'unknown symbol: %s') % tree[1])
        elif op in {b'and_', b'add', b'sub'}:
            assert len(tree) == 3
            return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
        elif op == b'group':
            return walk(tree[1])
        elif op == b'ancestors':
            return getstack(walk(tree[1]))
        else:
            raise error.ProgrammingError(b'illegal tree: %r' % tree)

    return [prefetched[r] for r in walk(tree)]


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[b'title']
    summary = drev[b'summary'].rstrip()
    testplan = drev[b'testPlan'].rstrip()
    if testplan:
        testplan = b'Test Plan:\n%s' % testplan
    uri = b'Differential Revision: %s' % drev[b'uri']
    return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))


def get_amended_desc(drev, ctx, folded):
    """similar to ``getdescfromdrev``, but supports a folded series of commits

    This is used when determining if an individual commit needs to have its
    message amended after posting it for review.  The determination is made for
    each individual commit, even when they were folded into one review.
    """
    if not folded:
        return getdescfromdrev(drev)

    uri = b'Differential Revision: %s' % drev[b'uri']

    # Since the commit messages were combined when posting multiple commits
    # with --fold, the fields can't be read from Phabricator here, or *all*
    # affected local revisions will end up with the same commit message after
    # the URI is amended in.  Append in the DREV line, or update it if it
    # exists.  At worst, this means commit message or test plan updates on
    # Phabricator aren't propagated back to the repository, but that seems
    # reasonable for the case where local commits are effectively combined
    # in Phabricator.
    m = _differentialrevisiondescre.search(ctx.description())
    if not m:
        return b'\n\n'.join([ctx.description(), uri])

    return _differentialrevisiondescre.sub(uri, ctx.description())


def getlocalcommits(diff):
    """get the set of local commits from a diff object

    See ``getdiffmeta()`` for an example diff object.
    """
    props = diff.get(b'properties') or {}
    commits = props.get(b'local:commits') or {}
    if len(commits) > 1:
        return {bin(c) for c in commits.keys()}

    # Storing the diff metadata predates storing `local:commits`, so continue
    # to use that in the --no-fold case.
    return {bin(getdiffmeta(diff).get(b'node', b'')) or None}


def getdiffmeta(diff):
    """get commit metadata (date, node, user, p1) from a diff object

    The metadata could be "hg:meta", sent by phabsend, like:

        "properties": {
          "hg:meta": {
            "branch": "default",
            "date": "1499571514 25200",
            "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
            "user": "Foo Bar <foo@example.com>",
            "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
          }
        }

    Or converted from "local:commits", sent by "arc", like:

        "properties": {
          "local:commits": {
            "98c08acae292b2faf60a279b4189beb6cff1414d": {
              "author": "Foo Bar",
              "authorEmail": "foo@example.com"
              "branch": "default",
              "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
              "local": "1000",
              "message": "...",
              "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
              "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
              "summary": "...",
              "tag": "",
              "time": 1499546314,
            }
          }
        }

    Note: metadata extracted from "local:commits" will lose time zone
    information.
    """
    props = diff.get(b'properties') or {}
    meta = props.get(b'hg:meta')
    if not meta:
        if props.get(b'local:commits'):
            commit = sorted(props[b'local:commits'].values())[0]
            meta = {}
            if b'author' in commit and b'authorEmail' in commit:
                meta[b'user'] = b'%s <%s>' % (
                    commit[b'author'],
                    commit[b'authorEmail'],
                )
            if b'time' in commit:
                meta[b'date'] = b'%d 0' % int(commit[b'time'])
            if b'branch' in commit:
                meta[b'branch'] = commit[b'branch']
            node = commit.get(b'commit', commit.get(b'rev'))
            if node:
                meta[b'node'] = node
            if len(commit.get(b'parents', ())) >= 1:
                meta[b'parent'] = commit[b'parents'][0]
        else:
            meta = {}
    if b'date' not in meta and b'dateCreated' in diff:
        meta[b'date'] = b'%s 0' % diff[b'dateCreated']
    if b'branch' not in meta and diff.get(b'branch'):
        meta[b'branch'] = diff[b'branch']
    if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
        meta[b'parent'] = diff[b'sourceControlBaseRevision']
    return meta


def _getdrevs(ui, stack, specs):
    """convert user supplied DREVSPECs into "Differential Revision" dicts

    See ``hg help phabread`` for how to specify each DREVSPEC.
    """
    if len(specs) > 0:

        def _formatspec(s):
            if stack:
                s = b':(%s)' % s
            return b'(%s)' % s

        spec = b'+'.join(pycompat.maplist(_formatspec, specs))

        drevs = querydrev(ui, spec)
        if drevs:
            return drevs

    raise error.Abort(_(b"empty DREVSPEC set"))


def readpatch(ui, drevs, write):
    """generate plain-text patch readable by 'hg import'

    write takes a list of (DREV, bytes), where DREV is the differential number
    (as bytes, without the "D" prefix) and the bytes are the text of a patch
    to be imported. drevs is what "querydrev" returns, results of
    "differential.query".
    """
    # Prefetch hg:meta property for all diffs
    diffids = sorted({max(int(v) for v in drev[b'diffs']) for drev in drevs})
    diffs = callconduit(ui, b'differential.querydiffs', {b'ids': diffids})

    patches = []

    # Generate patch for each drev
    for drev in drevs:
        ui.note(_(b'reading D%s\n') % drev[b'id'])

        diffid = max(int(v) for v in drev[b'diffs'])
        body = callconduit(ui, b'differential.getrawdiff', {b'diffID': diffid})
        desc = getdescfromdrev(drev)
        header = b'# 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.
        meta = getdiffmeta(diffs[b'%d' % diffid])
        for k in _metanamemap.keys():
            if k in meta:
                header += b'# %s %s\n' % (_metanamemap[k], meta[k])

        content = b'%s%s\n%s' % (header, desc, body)
        patches.append((drev[b'id'], content))

    # Write patches to the supplied callback
    write(patches)


@vcrcommand(
    b'phabread',
    [(b'', b'stack', False, _(b'read dependencies'))],
    _(b'DREVSPEC... [OPTIONS]'),
    helpcategory=command.CATEGORY_IMPORT_EXPORT,
    optionalrepo=True,
)
def phabread(ui, repo, *specs, **opts):
    """print patches from Phabricator suitable for importing

    DREVSPEC could be a Differential Revision identity, like ``D123``, or just
    the number ``123``. It could also have common operators like ``+``, ``-``,
    ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
    select a stack.  If multiple DREVSPEC values are given, the result is the
    union of each individually evaluated value.  No attempt is currently made
    to reorder the values to run from parent to child.

    ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
    could be used to filter patches by status. For performance reason, they
    only represent a subset of non-status selections and cannot be used alone.

    For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
    D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
    stack up to D9.

    If --stack is given, follow dependencies information and read all patches.
    It is equivalent to the ``:`` operator.
    """
    opts = pycompat.byteskwargs(opts)
    drevs = _getdrevs(ui, opts.get(b'stack'), specs)

    def _write(patches):
        for drev, content in patches:
            ui.write(content)

    readpatch(ui, drevs, _write)


@vcrcommand(
    b'phabimport',
    [(b'', b'stack', False, _(b'import dependencies as well'))],
    _(b'DREVSPEC... [OPTIONS]'),
    helpcategory=command.CATEGORY_IMPORT_EXPORT,
)
def phabimport(ui, repo, *specs, **opts):
    """import patches from Phabricator for the specified Differential Revisions

    The patches are read and applied starting at the parent of the working
    directory.

    See ``hg help phabread`` for how to specify DREVSPEC.
    """
    opts = pycompat.byteskwargs(opts)

    # --bypass avoids losing exec and symlink bits when importing on Windows,
    # and allows importing with a dirty wdir.  It also aborts instead of leaving
    # rejects.
    opts[b'bypass'] = True

    # Mandatory default values, synced with commands.import
    opts[b'strip'] = 1
    opts[b'prefix'] = b''
    # Evolve 9.3.0 assumes this key is present in cmdutil.tryimportone()
    opts[b'obsolete'] = False

    if ui.configbool(b'phabimport', b'secret'):
        opts[b'secret'] = True
    if ui.configbool(b'phabimport', b'obsolete'):
        opts[b'obsolete'] = True  # Handled by evolve wrapping tryimportone()

    def _write(patches):
        parents = repo[None].parents()

        with repo.wlock(), repo.lock(), repo.transaction(b'phabimport'):
            for drev, contents in patches:
                ui.status(_(b'applying patch from D%s\n') % drev)

                with patch.extract(ui, io.BytesIO(contents)) as patchdata:
                    msg, node, rej = cmdutil.tryimportone(
                        ui,
                        repo,
                        patchdata,
                        parents,
                        opts,
                        [],
                        None,  # Never update wdir to another revision
                    )

                    if not node:
                        raise error.Abort(_(b'D%s: no diffs found') % drev)

                    ui.note(msg + b'\n')
                    parents = [repo[node]]

    drevs = _getdrevs(ui, opts.get(b'stack'), specs)

    readpatch(repo.ui, drevs, _write)


@vcrcommand(
    b'phabupdate',
    [
        (b'', b'accept', False, _(b'accept revisions')),
        (b'', b'reject', False, _(b'reject revisions')),
        (b'', b'request-review', False, _(b'request review on revisions')),
        (b'', b'abandon', False, _(b'abandon revisions')),
        (b'', b'reclaim', False, _(b'reclaim revisions')),
        (b'', b'close', False, _(b'close revisions')),
        (b'', b'reopen', False, _(b'reopen revisions')),
        (b'', b'plan-changes', False, _(b'plan changes for revisions')),
        (b'', b'resign', False, _(b'resign as a reviewer from revisions')),
        (b'', b'commandeer', False, _(b'commandeer revisions')),
        (b'm', b'comment', b'', _(b'comment on the last revision')),
        (b'r', b'rev', b'', _(b'local revision to update'), _(b'REV')),
    ],
    _(b'[DREVSPEC...| -r REV...] [OPTIONS]'),
    helpcategory=command.CATEGORY_IMPORT_EXPORT,
    optionalrepo=True,
)
def phabupdate(ui, repo, *specs, **opts):
    """update Differential Revision in batch

    DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
    """
    opts = pycompat.byteskwargs(opts)
    transactions = [
        b'abandon',
        b'accept',
        b'close',
        b'commandeer',
        b'plan-changes',
        b'reclaim',
        b'reject',
        b'reopen',
        b'request-review',
        b'resign',
    ]
    flags = [n for n in transactions if opts.get(n.replace(b'-', b'_'))]
    if len(flags) > 1:
        raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))

    actions = []
    for f in flags:
        actions.append({b'type': f, b'value': True})

    revs = opts.get(b'rev')
    if revs:
        if not repo:
            raise error.InputError(_(b'--rev requires a repository'))

        if specs:
            raise error.InputError(_(b'cannot specify both DREVSPEC and --rev'))

        drevmap = getdrevmap(repo, logcmdutil.revrange(repo, [revs]))
        specs = []
        unknown = []
        for r, d in drevmap.items():
            if d is None:
                unknown.append(repo[r])
            else:
                specs.append(b'D%d' % d)
        if unknown:
            raise error.InputError(
                _(b'selected revisions without a Differential: %s')
                % scmutil.nodesummaries(repo, unknown)
            )

    drevs = _getdrevs(ui, opts.get(b'stack'), specs)
    for i, drev in enumerate(drevs):
        if i + 1 == len(drevs) and opts.get(b'comment'):
            actions.append({b'type': b'comment', b'value': opts[b'comment']})
        if actions:
            params = {
                b'objectIdentifier': drev[b'phid'],
                b'transactions': actions,
            }
            callconduit(ui, b'differential.revision.edit', params)


@eh.templatekeyword(b'phabreview', requires={b'ctx'})
def template_review(context, mapping):
    """:phabreview: Object describing the review for this changeset.
    Has attributes `url` and `id`.
    """
    ctx = context.resource(mapping, b'ctx')
    m = _differentialrevisiondescre.search(ctx.description())
    if m:
        return templateutil.hybriddict(
            {
                b'url': m.group('url'),
                b'id': b"D%s" % m.group('id'),
            }
        )
    else:
        tags = ctx.repo().nodetags(ctx.node())
        for t in tags:
            if _differentialrevisiontagre.match(t):
                url = ctx.repo().ui.config(b'phabricator', b'url')
                if not url.endswith(b'/'):
                    url += b'/'
                url += t

                return templateutil.hybriddict(
                    {
                        b'url': url,
                        b'id': t,
                    }
                )
    return None


@eh.templatekeyword(b'phabstatus', requires={b'ctx', b'repo', b'ui'})
def template_status(context, mapping):
    """:phabstatus: String. Status of Phabricator differential."""
    ctx = context.resource(mapping, b'ctx')
    repo = context.resource(mapping, b'repo')
    ui = context.resource(mapping, b'ui')

    rev = ctx.rev()
    try:
        drevid = getdrevmap(repo, [rev])[rev]
    except KeyError:
        return None
    drevs = callconduit(ui, b'differential.query', {b'ids': [drevid]})
    for drev in drevs:
        if int(drev[b'id']) == drevid:
            return templateutil.hybriddict(
                {
                    b'url': drev[b'uri'],
                    b'status': drev[b'statusName'],
                }
            )
    return None


@show.showview(b'phabstatus', csettopic=b'work')
def phabstatusshowview(ui, repo, displayer):
    """Phabricator differiential status"""
    revs = repo.revs('sort(_underway(), topo)')
    drevmap = getdrevmap(repo, revs)
    unknownrevs, drevids, revsbydrevid = [], set(), {}
    for rev, drevid in drevmap.items():
        if drevid is not None:
            drevids.add(drevid)
            revsbydrevid.setdefault(drevid, set()).add(rev)
        else:
            unknownrevs.append(rev)

    drevs = callconduit(ui, b'differential.query', {b'ids': list(drevids)})
    drevsbyrev = {}
    for drev in drevs:
        for rev in revsbydrevid[int(drev[b'id'])]:
            drevsbyrev[rev] = drev

    def phabstatus(ctx):
        drev = drevsbyrev[ctx.rev()]
        status = ui.label(
            b'%(statusName)s' % drev,
            b'phabricator.status.%s' % _getstatusname(drev),
        )
        ui.write(b"\n%s %s\n" % (drev[b'uri'], status))

    revs -= smartset.baseset(unknownrevs)
    revdag = graphmod.dagwalker(repo, revs)

    ui.setconfig(b'experimental', b'graphshorten', True)
    displayer._exthook = phabstatus
    nodelen = show.longestshortest(repo, revs)
    logcmdutil.displaygraph(
        ui,
        repo,
        revdag,
        displayer,
        graphmod.asciiedges,
        props={b'nodelen': nodelen},
    )