contrib/phabricator.py
branchstable
changeset 40404 956ec6f1320d
parent 40131 535fc8a22365
parent 40403 bf249bb60087
child 40405 4185bc53d1e3
equal deleted inserted replaced
40131:535fc8a22365 40404:956ec6f1320d
     1 # phabricator.py - simple Phabricator integration
       
     2 #
       
     3 # Copyright 2017 Facebook, Inc.
       
     4 #
       
     5 # This software may be used and distributed according to the terms of the
       
     6 # GNU General Public License version 2 or any later version.
       
     7 """simple Phabricator integration
       
     8 
       
     9 This extension provides a ``phabsend`` command which sends a stack of
       
    10 changesets to Phabricator, and a ``phabread`` command which prints a stack of
       
    11 revisions in a format suitable for :hg:`import`, and a ``phabupdate`` command
       
    12 to update statuses in batch.
       
    13 
       
    14 By default, Phabricator requires ``Test Plan`` which might prevent some
       
    15 changeset from being sent. The requirement could be disabled by changing
       
    16 ``differential.require-test-plan-field`` config server side.
       
    17 
       
    18 Config::
       
    19 
       
    20     [phabricator]
       
    21     # Phabricator URL
       
    22     url = https://phab.example.com/
       
    23 
       
    24     # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
       
    25     # callsign is "FOO".
       
    26     callsign = FOO
       
    27 
       
    28     # curl command to use. If not set (default), use builtin HTTP library to
       
    29     # communicate. If set, use the specified curl command. This could be useful
       
    30     # if you need to specify advanced options that is not easily supported by
       
    31     # the internal library.
       
    32     curlcmd = curl --connect-timeout 2 --retry 3 --silent
       
    33 
       
    34     [auth]
       
    35     example.schemes = https
       
    36     example.prefix = phab.example.com
       
    37 
       
    38     # API token. Get it from https://$HOST/conduit/login/
       
    39     example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
       
    40 """
       
    41 
       
    42 from __future__ import absolute_import
       
    43 
       
    44 import itertools
       
    45 import json
       
    46 import operator
       
    47 import re
       
    48 
       
    49 from mercurial.node import bin, nullid
       
    50 from mercurial.i18n import _
       
    51 from mercurial import (
       
    52     cmdutil,
       
    53     context,
       
    54     encoding,
       
    55     error,
       
    56     httpconnection as httpconnectionmod,
       
    57     mdiff,
       
    58     obsutil,
       
    59     parser,
       
    60     patch,
       
    61     registrar,
       
    62     scmutil,
       
    63     smartset,
       
    64     tags,
       
    65     url as urlmod,
       
    66     util,
       
    67 )
       
    68 from mercurial.utils import (
       
    69     procutil,
       
    70     stringutil,
       
    71 )
       
    72 
       
    73 cmdtable = {}
       
    74 command = registrar.command(cmdtable)
       
    75 
       
    76 configtable = {}
       
    77 configitem = registrar.configitem(configtable)
       
    78 
       
    79 # developer config: phabricator.batchsize
       
    80 configitem(b'phabricator', b'batchsize',
       
    81     default=12,
       
    82 )
       
    83 configitem(b'phabricator', b'callsign',
       
    84     default=None,
       
    85 )
       
    86 configitem(b'phabricator', b'curlcmd',
       
    87     default=None,
       
    88 )
       
    89 # developer config: phabricator.repophid
       
    90 configitem(b'phabricator', b'repophid',
       
    91     default=None,
       
    92 )
       
    93 configitem(b'phabricator', b'url',
       
    94     default=None,
       
    95 )
       
    96 configitem(b'phabsend', b'confirm',
       
    97     default=False,
       
    98 )
       
    99 
       
   100 colortable = {
       
   101     b'phabricator.action.created': b'green',
       
   102     b'phabricator.action.skipped': b'magenta',
       
   103     b'phabricator.action.updated': b'magenta',
       
   104     b'phabricator.desc': b'',
       
   105     b'phabricator.drev': b'bold',
       
   106     b'phabricator.node': b'',
       
   107 }
       
   108 
       
   109 def urlencodenested(params):
       
   110     """like urlencode, but works with nested parameters.
       
   111 
       
   112     For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
       
   113     flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
       
   114     urlencode. Note: the encoding is consistent with PHP's http_build_query.
       
   115     """
       
   116     flatparams = util.sortdict()
       
   117     def process(prefix, obj):
       
   118         items = {list: enumerate, dict: lambda x: x.items()}.get(type(obj))
       
   119         if items is None:
       
   120             flatparams[prefix] = obj
       
   121         else:
       
   122             for k, v in items(obj):
       
   123                 if prefix:
       
   124                     process(b'%s[%s]' % (prefix, k), v)
       
   125                 else:
       
   126                     process(k, v)
       
   127     process(b'', params)
       
   128     return util.urlreq.urlencode(flatparams)
       
   129 
       
   130 printed_token_warning = False
       
   131 
       
   132 def readlegacytoken(repo, url):
       
   133     """Transitional support for old phabricator tokens.
       
   134 
       
   135     Remove before the 4.7 release.
       
   136     """
       
   137     groups = {}
       
   138     for key, val in repo.ui.configitems(b'phabricator.auth'):
       
   139         if b'.' not in key:
       
   140             repo.ui.warn(_(b"ignoring invalid [phabricator.auth] key '%s'\n")
       
   141                          % key)
       
   142             continue
       
   143         group, setting = key.rsplit(b'.', 1)
       
   144         groups.setdefault(group, {})[setting] = val
       
   145 
       
   146     token = None
       
   147     for group, auth in groups.iteritems():
       
   148         if url != auth.get(b'url'):
       
   149             continue
       
   150         token = auth.get(b'token')
       
   151         if token:
       
   152             break
       
   153 
       
   154     global printed_token_warning
       
   155 
       
   156     if token and not printed_token_warning:
       
   157         printed_token_warning = True
       
   158         repo.ui.warn(_(b'phabricator.auth.token is deprecated - please '
       
   159                        b'migrate to auth.phabtoken.\n'))
       
   160     return token
       
   161 
       
   162 def readurltoken(repo):
       
   163     """return conduit url, token and make sure they exist
       
   164 
       
   165     Currently read from [auth] config section. In the future, it might
       
   166     make sense to read from .arcconfig and .arcrc as well.
       
   167     """
       
   168     url = repo.ui.config(b'phabricator', b'url')
       
   169     if not url:
       
   170         raise error.Abort(_(b'config %s.%s is required')
       
   171                           % (b'phabricator', b'url'))
       
   172 
       
   173     res = httpconnectionmod.readauthforuri(repo.ui, url, util.url(url).user)
       
   174     token = None
       
   175 
       
   176     if res:
       
   177         group, auth = res
       
   178 
       
   179         repo.ui.debug(b"using auth.%s.* for authentication\n" % group)
       
   180 
       
   181         token = auth.get(b'phabtoken')
       
   182 
       
   183     if not token:
       
   184         token = readlegacytoken(repo, url)
       
   185         if not token:
       
   186             raise error.Abort(_(b'Can\'t find conduit token associated to %s')
       
   187                               % (url,))
       
   188 
       
   189     return url, token
       
   190 
       
   191 def callconduit(repo, name, params):
       
   192     """call Conduit API, params is a dict. return json.loads result, or None"""
       
   193     host, token = readurltoken(repo)
       
   194     url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
       
   195     repo.ui.debug(b'Conduit Call: %s %s\n' % (url, params))
       
   196     params = params.copy()
       
   197     params[b'api.token'] = token
       
   198     data = urlencodenested(params)
       
   199     curlcmd = repo.ui.config(b'phabricator', b'curlcmd')
       
   200     if curlcmd:
       
   201         sin, sout = procutil.popen2(b'%s -d @- %s'
       
   202                                     % (curlcmd, procutil.shellquote(url)))
       
   203         sin.write(data)
       
   204         sin.close()
       
   205         body = sout.read()
       
   206     else:
       
   207         urlopener = urlmod.opener(repo.ui, authinfo)
       
   208         request = util.urlreq.request(url, data=data)
       
   209         body = urlopener.open(request).read()
       
   210     repo.ui.debug(b'Conduit Response: %s\n' % body)
       
   211     parsed = json.loads(body)
       
   212     if parsed.get(r'error_code'):
       
   213         msg = (_(b'Conduit Error (%s): %s')
       
   214                % (parsed[r'error_code'], parsed[r'error_info']))
       
   215         raise error.Abort(msg)
       
   216     return parsed[r'result']
       
   217 
       
   218 @command(b'debugcallconduit', [], _(b'METHOD'))
       
   219 def debugcallconduit(ui, repo, name):
       
   220     """call Conduit API
       
   221 
       
   222     Call parameters are read from stdin as a JSON blob. Result will be written
       
   223     to stdout as a JSON blob.
       
   224     """
       
   225     params = json.loads(ui.fin.read())
       
   226     result = callconduit(repo, name, params)
       
   227     s = json.dumps(result, sort_keys=True, indent=2, separators=(b',', b': '))
       
   228     ui.write(b'%s\n' % s)
       
   229 
       
   230 def getrepophid(repo):
       
   231     """given callsign, return repository PHID or None"""
       
   232     # developer config: phabricator.repophid
       
   233     repophid = repo.ui.config(b'phabricator', b'repophid')
       
   234     if repophid:
       
   235         return repophid
       
   236     callsign = repo.ui.config(b'phabricator', b'callsign')
       
   237     if not callsign:
       
   238         return None
       
   239     query = callconduit(repo, b'diffusion.repository.search',
       
   240                         {b'constraints': {b'callsigns': [callsign]}})
       
   241     if len(query[r'data']) == 0:
       
   242         return None
       
   243     repophid = encoding.strtolocal(query[r'data'][0][r'phid'])
       
   244     repo.ui.setconfig(b'phabricator', b'repophid', repophid)
       
   245     return repophid
       
   246 
       
   247 _differentialrevisiontagre = re.compile(b'\AD([1-9][0-9]*)\Z')
       
   248 _differentialrevisiondescre = re.compile(
       
   249     b'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M)
       
   250 
       
   251 def getoldnodedrevmap(repo, nodelist):
       
   252     """find previous nodes that has been sent to Phabricator
       
   253 
       
   254     return {node: (oldnode, Differential diff, Differential Revision ID)}
       
   255     for node in nodelist with known previous sent versions, or associated
       
   256     Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
       
   257     be ``None``.
       
   258 
       
   259     Examines commit messages like "Differential Revision:" to get the
       
   260     association information.
       
   261 
       
   262     If such commit message line is not found, examines all precursors and their
       
   263     tags. Tags with format like "D1234" are considered a match and the node
       
   264     with that tag, and the number after "D" (ex. 1234) will be returned.
       
   265 
       
   266     The ``old node``, if not None, is guaranteed to be the last diff of
       
   267     corresponding Differential Revision, and exist in the repo.
       
   268     """
       
   269     url, token = readurltoken(repo)
       
   270     unfi = repo.unfiltered()
       
   271     nodemap = unfi.changelog.nodemap
       
   272 
       
   273     result = {} # {node: (oldnode?, lastdiff?, drev)}
       
   274     toconfirm = {} # {node: (force, {precnode}, drev)}
       
   275     for node in nodelist:
       
   276         ctx = unfi[node]
       
   277         # For tags like "D123", put them into "toconfirm" to verify later
       
   278         precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
       
   279         for n in precnodes:
       
   280             if n in nodemap:
       
   281                 for tag in unfi.nodetags(n):
       
   282                     m = _differentialrevisiontagre.match(tag)
       
   283                     if m:
       
   284                         toconfirm[node] = (0, set(precnodes), int(m.group(1)))
       
   285                         continue
       
   286 
       
   287         # Check commit message
       
   288         m = _differentialrevisiondescre.search(ctx.description())
       
   289         if m:
       
   290             toconfirm[node] = (1, set(precnodes), int(m.group(b'id')))
       
   291 
       
   292     # Double check if tags are genuine by collecting all old nodes from
       
   293     # Phabricator, and expect precursors overlap with it.
       
   294     if toconfirm:
       
   295         drevs = [drev for force, precs, drev in toconfirm.values()]
       
   296         alldiffs = callconduit(unfi, b'differential.querydiffs',
       
   297                                {b'revisionIDs': drevs})
       
   298         getnode = lambda d: bin(encoding.unitolocal(
       
   299             getdiffmeta(d).get(r'node', b''))) or None
       
   300         for newnode, (force, precset, drev) in toconfirm.items():
       
   301             diffs = [d for d in alldiffs.values()
       
   302                      if int(d[r'revisionID']) == drev]
       
   303 
       
   304             # "precursors" as known by Phabricator
       
   305             phprecset = set(getnode(d) for d in diffs)
       
   306 
       
   307             # Ignore if precursors (Phabricator and local repo) do not overlap,
       
   308             # and force is not set (when commit message says nothing)
       
   309             if not force and not bool(phprecset & precset):
       
   310                 tagname = b'D%d' % drev
       
   311                 tags.tag(repo, tagname, nullid, message=None, user=None,
       
   312                          date=None, local=True)
       
   313                 unfi.ui.warn(_(b'D%s: local tag removed - does not match '
       
   314                                b'Differential history\n') % drev)
       
   315                 continue
       
   316 
       
   317             # Find the last node using Phabricator metadata, and make sure it
       
   318             # exists in the repo
       
   319             oldnode = lastdiff = None
       
   320             if diffs:
       
   321                 lastdiff = max(diffs, key=lambda d: int(d[r'id']))
       
   322                 oldnode = getnode(lastdiff)
       
   323                 if oldnode and oldnode not in nodemap:
       
   324                     oldnode = None
       
   325 
       
   326             result[newnode] = (oldnode, lastdiff, drev)
       
   327 
       
   328     return result
       
   329 
       
   330 def getdiff(ctx, diffopts):
       
   331     """plain-text diff without header (user, commit message, etc)"""
       
   332     output = util.stringio()
       
   333     for chunk, _label in patch.diffui(ctx.repo(), ctx.p1().node(), ctx.node(),
       
   334                                       None, opts=diffopts):
       
   335         output.write(chunk)
       
   336     return output.getvalue()
       
   337 
       
   338 def creatediff(ctx):
       
   339     """create a Differential Diff"""
       
   340     repo = ctx.repo()
       
   341     repophid = getrepophid(repo)
       
   342     # Create a "Differential Diff" via "differential.createrawdiff" API
       
   343     params = {b'diff': getdiff(ctx, mdiff.diffopts(git=True, context=32767))}
       
   344     if repophid:
       
   345         params[b'repositoryPHID'] = repophid
       
   346     diff = callconduit(repo, b'differential.createrawdiff', params)
       
   347     if not diff:
       
   348         raise error.Abort(_(b'cannot create diff for %s') % ctx)
       
   349     return diff
       
   350 
       
   351 def writediffproperties(ctx, diff):
       
   352     """write metadata to diff so patches could be applied losslessly"""
       
   353     params = {
       
   354         b'diff_id': diff[r'id'],
       
   355         b'name': b'hg:meta',
       
   356         b'data': json.dumps({
       
   357             b'user': ctx.user(),
       
   358             b'date': b'%d %d' % ctx.date(),
       
   359             b'node': ctx.hex(),
       
   360             b'parent': ctx.p1().hex(),
       
   361         }),
       
   362     }
       
   363     callconduit(ctx.repo(), b'differential.setdiffproperty', params)
       
   364 
       
   365     params = {
       
   366         b'diff_id': diff[r'id'],
       
   367         b'name': b'local:commits',
       
   368         b'data': json.dumps({
       
   369             ctx.hex(): {
       
   370                 b'author': stringutil.person(ctx.user()),
       
   371                 b'authorEmail': stringutil.email(ctx.user()),
       
   372                 b'time': ctx.date()[0],
       
   373             },
       
   374         }),
       
   375     }
       
   376     callconduit(ctx.repo(), b'differential.setdiffproperty', params)
       
   377 
       
   378 def createdifferentialrevision(ctx, revid=None, parentrevid=None, oldnode=None,
       
   379                                olddiff=None, actions=None):
       
   380     """create or update a Differential Revision
       
   381 
       
   382     If revid is None, create a new Differential Revision, otherwise update
       
   383     revid. If parentrevid is not None, set it as a dependency.
       
   384 
       
   385     If oldnode is not None, check if the patch content (without commit message
       
   386     and metadata) has changed before creating another diff.
       
   387 
       
   388     If actions is not None, they will be appended to the transaction.
       
   389     """
       
   390     repo = ctx.repo()
       
   391     if oldnode:
       
   392         diffopts = mdiff.diffopts(git=True, context=32767)
       
   393         oldctx = repo.unfiltered()[oldnode]
       
   394         neednewdiff = (getdiff(ctx, diffopts) != getdiff(oldctx, diffopts))
       
   395     else:
       
   396         neednewdiff = True
       
   397 
       
   398     transactions = []
       
   399     if neednewdiff:
       
   400         diff = creatediff(ctx)
       
   401         transactions.append({b'type': b'update', b'value': diff[r'phid']})
       
   402     else:
       
   403         # Even if we don't need to upload a new diff because the patch content
       
   404         # does not change. We might still need to update its metadata so
       
   405         # pushers could know the correct node metadata.
       
   406         assert olddiff
       
   407         diff = olddiff
       
   408     writediffproperties(ctx, diff)
       
   409 
       
   410     # Use a temporary summary to set dependency. There might be better ways but
       
   411     # I cannot find them for now. But do not do that if we are updating an
       
   412     # existing revision (revid is not None) since that introduces visible
       
   413     # churns (someone edited "Summary" twice) on the web page.
       
   414     if parentrevid and revid is None:
       
   415         summary = b'Depends on D%s' % parentrevid
       
   416         transactions += [{b'type': b'summary', b'value': summary},
       
   417                          {b'type': b'summary', b'value': b' '}]
       
   418 
       
   419     if actions:
       
   420         transactions += actions
       
   421 
       
   422     # Parse commit message and update related fields.
       
   423     desc = ctx.description()
       
   424     info = callconduit(repo, b'differential.parsecommitmessage',
       
   425                        {b'corpus': desc})
       
   426     for k, v in info[r'fields'].items():
       
   427         if k in [b'title', b'summary', b'testPlan']:
       
   428             transactions.append({b'type': k, b'value': v})
       
   429 
       
   430     params = {b'transactions': transactions}
       
   431     if revid is not None:
       
   432         # Update an existing Differential Revision
       
   433         params[b'objectIdentifier'] = revid
       
   434 
       
   435     revision = callconduit(repo, b'differential.revision.edit', params)
       
   436     if not revision:
       
   437         raise error.Abort(_(b'cannot create revision for %s') % ctx)
       
   438 
       
   439     return revision, diff
       
   440 
       
   441 def userphids(repo, names):
       
   442     """convert user names to PHIDs"""
       
   443     query = {b'constraints': {b'usernames': names}}
       
   444     result = callconduit(repo, b'user.search', query)
       
   445     # username not found is not an error of the API. So check if we have missed
       
   446     # some names here.
       
   447     data = result[r'data']
       
   448     resolved = set(entry[r'fields'][r'username'] for entry in data)
       
   449     unresolved = set(names) - resolved
       
   450     if unresolved:
       
   451         raise error.Abort(_(b'unknown username: %s')
       
   452                           % b' '.join(sorted(unresolved)))
       
   453     return [entry[r'phid'] for entry in data]
       
   454 
       
   455 @command(b'phabsend',
       
   456          [(b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
       
   457           (b'', b'amend', True, _(b'update commit messages')),
       
   458           (b'', b'reviewer', [], _(b'specify reviewers')),
       
   459           (b'', b'confirm', None, _(b'ask for confirmation before sending'))],
       
   460          _(b'REV [OPTIONS]'))
       
   461 def phabsend(ui, repo, *revs, **opts):
       
   462     """upload changesets to Phabricator
       
   463 
       
   464     If there are multiple revisions specified, they will be send as a stack
       
   465     with a linear dependencies relationship using the order specified by the
       
   466     revset.
       
   467 
       
   468     For the first time uploading changesets, local tags will be created to
       
   469     maintain the association. After the first time, phabsend will check
       
   470     obsstore and tags information so it can figure out whether to update an
       
   471     existing Differential Revision, or create a new one.
       
   472 
       
   473     If --amend is set, update commit messages so they have the
       
   474     ``Differential Revision`` URL, remove related tags. This is similar to what
       
   475     arcanist will do, and is more desired in author-push workflows. Otherwise,
       
   476     use local tags to record the ``Differential Revision`` association.
       
   477 
       
   478     The --confirm option lets you confirm changesets before sending them. You
       
   479     can also add following to your configuration file to make it default
       
   480     behaviour::
       
   481 
       
   482         [phabsend]
       
   483         confirm = true
       
   484 
       
   485     phabsend will check obsstore and the above association to decide whether to
       
   486     update an existing Differential Revision, or create a new one.
       
   487     """
       
   488     revs = list(revs) + opts.get(b'rev', [])
       
   489     revs = scmutil.revrange(repo, revs)
       
   490 
       
   491     if not revs:
       
   492         raise error.Abort(_(b'phabsend requires at least one changeset'))
       
   493     if opts.get(b'amend'):
       
   494         cmdutil.checkunfinished(repo)
       
   495 
       
   496     # {newnode: (oldnode, olddiff, olddrev}
       
   497     oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
       
   498 
       
   499     confirm = ui.configbool(b'phabsend', b'confirm')
       
   500     confirm |= bool(opts.get(b'confirm'))
       
   501     if confirm:
       
   502         confirmed = _confirmbeforesend(repo, revs, oldmap)
       
   503         if not confirmed:
       
   504             raise error.Abort(_(b'phabsend cancelled'))
       
   505 
       
   506     actions = []
       
   507     reviewers = opts.get(b'reviewer', [])
       
   508     if reviewers:
       
   509         phids = userphids(repo, reviewers)
       
   510         actions.append({b'type': b'reviewers.add', b'value': phids})
       
   511 
       
   512     drevids = [] # [int]
       
   513     diffmap = {} # {newnode: diff}
       
   514 
       
   515     # Send patches one by one so we know their Differential Revision IDs and
       
   516     # can provide dependency relationship
       
   517     lastrevid = None
       
   518     for rev in revs:
       
   519         ui.debug(b'sending rev %d\n' % rev)
       
   520         ctx = repo[rev]
       
   521 
       
   522         # Get Differential Revision ID
       
   523         oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
       
   524         if oldnode != ctx.node() or opts.get(b'amend'):
       
   525             # Create or update Differential Revision
       
   526             revision, diff = createdifferentialrevision(
       
   527                 ctx, revid, lastrevid, oldnode, olddiff, actions)
       
   528             diffmap[ctx.node()] = diff
       
   529             newrevid = int(revision[r'object'][r'id'])
       
   530             if revid:
       
   531                 action = b'updated'
       
   532             else:
       
   533                 action = b'created'
       
   534 
       
   535             # Create a local tag to note the association, if commit message
       
   536             # does not have it already
       
   537             m = _differentialrevisiondescre.search(ctx.description())
       
   538             if not m or int(m.group(b'id')) != newrevid:
       
   539                 tagname = b'D%d' % newrevid
       
   540                 tags.tag(repo, tagname, ctx.node(), message=None, user=None,
       
   541                          date=None, local=True)
       
   542         else:
       
   543             # Nothing changed. But still set "newrevid" so the next revision
       
   544             # could depend on this one.
       
   545             newrevid = revid
       
   546             action = b'skipped'
       
   547 
       
   548         actiondesc = ui.label(
       
   549             {b'created': _(b'created'),
       
   550              b'skipped': _(b'skipped'),
       
   551              b'updated': _(b'updated')}[action],
       
   552             b'phabricator.action.%s' % action)
       
   553         drevdesc = ui.label(b'D%s' % newrevid, b'phabricator.drev')
       
   554         nodedesc = ui.label(bytes(ctx), b'phabricator.node')
       
   555         desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
       
   556         ui.write(_(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc,
       
   557                                              desc))
       
   558         drevids.append(newrevid)
       
   559         lastrevid = newrevid
       
   560 
       
   561     # Update commit messages and remove tags
       
   562     if opts.get(b'amend'):
       
   563         unfi = repo.unfiltered()
       
   564         drevs = callconduit(repo, b'differential.query', {b'ids': drevids})
       
   565         with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
       
   566             wnode = unfi[b'.'].node()
       
   567             mapping = {} # {oldnode: [newnode]}
       
   568             for i, rev in enumerate(revs):
       
   569                 old = unfi[rev]
       
   570                 drevid = drevids[i]
       
   571                 drev = [d for d in drevs if int(d[r'id']) == drevid][0]
       
   572                 newdesc = getdescfromdrev(drev)
       
   573                 # Make sure commit message contain "Differential Revision"
       
   574                 if old.description() != newdesc:
       
   575                     parents = [
       
   576                         mapping.get(old.p1().node(), (old.p1(),))[0],
       
   577                         mapping.get(old.p2().node(), (old.p2(),))[0],
       
   578                     ]
       
   579                     new = context.metadataonlyctx(
       
   580                         repo, old, parents=parents, text=newdesc,
       
   581                         user=old.user(), date=old.date(), extra=old.extra())
       
   582 
       
   583                     newnode = new.commit()
       
   584 
       
   585                     mapping[old.node()] = [newnode]
       
   586                     # Update diff property
       
   587                     writediffproperties(unfi[newnode], diffmap[old.node()])
       
   588                 # Remove local tags since it's no longer necessary
       
   589                 tagname = b'D%d' % drevid
       
   590                 if tagname in repo.tags():
       
   591                     tags.tag(repo, tagname, nullid, message=None, user=None,
       
   592                              date=None, local=True)
       
   593             scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
       
   594             if wnode in mapping:
       
   595                 unfi.setparents(mapping[wnode][0])
       
   596 
       
   597 # Map from "hg:meta" keys to header understood by "hg import". The order is
       
   598 # consistent with "hg export" output.
       
   599 _metanamemap = util.sortdict([(r'user', b'User'), (r'date', b'Date'),
       
   600                               (r'node', b'Node ID'), (r'parent', b'Parent ')])
       
   601 
       
   602 def _confirmbeforesend(repo, revs, oldmap):
       
   603     url, token = readurltoken(repo)
       
   604     ui = repo.ui
       
   605     for rev in revs:
       
   606         ctx = repo[rev]
       
   607         desc = ctx.description().splitlines()[0]
       
   608         oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
       
   609         if drevid:
       
   610             drevdesc = ui.label(b'D%s' % drevid, b'phabricator.drev')
       
   611         else:
       
   612             drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
       
   613 
       
   614         ui.write(_(b'%s - %s: %s\n')
       
   615                  % (drevdesc,
       
   616                     ui.label(bytes(ctx), b'phabricator.node'),
       
   617                     ui.label(desc, b'phabricator.desc')))
       
   618 
       
   619     if ui.promptchoice(_(b'Send the above changes to %s (yn)?'
       
   620                          b'$$ &Yes $$ &No') % url):
       
   621         return False
       
   622 
       
   623     return True
       
   624 
       
   625 _knownstatusnames = {b'accepted', b'needsreview', b'needsrevision', b'closed',
       
   626                      b'abandoned'}
       
   627 
       
   628 def _getstatusname(drev):
       
   629     """get normalized status name from a Differential Revision"""
       
   630     return drev[r'statusName'].replace(b' ', b'').lower()
       
   631 
       
   632 # Small language to specify differential revisions. Support symbols: (), :X,
       
   633 # +, and -.
       
   634 
       
   635 _elements = {
       
   636     # token-type: binding-strength, primary, prefix, infix, suffix
       
   637     b'(':      (12, None, (b'group', 1, b')'), None, None),
       
   638     b':':      (8, None, (b'ancestors', 8), None, None),
       
   639     b'&':      (5,  None, None, (b'and_', 5), None),
       
   640     b'+':      (4,  None, None, (b'add', 4), None),
       
   641     b'-':      (4,  None, None, (b'sub', 4), None),
       
   642     b')':      (0,  None, None, None, None),
       
   643     b'symbol': (0, b'symbol', None, None, None),
       
   644     b'end':    (0, None, None, None, None),
       
   645 }
       
   646 
       
   647 def _tokenize(text):
       
   648     view = memoryview(text) # zero-copy slice
       
   649     special = b'():+-& '
       
   650     pos = 0
       
   651     length = len(text)
       
   652     while pos < length:
       
   653         symbol = b''.join(itertools.takewhile(lambda ch: ch not in special,
       
   654                                               view[pos:]))
       
   655         if symbol:
       
   656             yield (b'symbol', symbol, pos)
       
   657             pos += len(symbol)
       
   658         else: # special char, ignore space
       
   659             if text[pos] != b' ':
       
   660                 yield (text[pos], None, pos)
       
   661             pos += 1
       
   662     yield (b'end', None, pos)
       
   663 
       
   664 def _parse(text):
       
   665     tree, pos = parser.parser(_elements).parse(_tokenize(text))
       
   666     if pos != len(text):
       
   667         raise error.ParseError(b'invalid token', pos)
       
   668     return tree
       
   669 
       
   670 def _parsedrev(symbol):
       
   671     """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
       
   672     if symbol.startswith(b'D') and symbol[1:].isdigit():
       
   673         return int(symbol[1:])
       
   674     if symbol.isdigit():
       
   675         return int(symbol)
       
   676 
       
   677 def _prefetchdrevs(tree):
       
   678     """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
       
   679     drevs = set()
       
   680     ancestordrevs = set()
       
   681     op = tree[0]
       
   682     if op == b'symbol':
       
   683         r = _parsedrev(tree[1])
       
   684         if r:
       
   685             drevs.add(r)
       
   686     elif op == b'ancestors':
       
   687         r, a = _prefetchdrevs(tree[1])
       
   688         drevs.update(r)
       
   689         ancestordrevs.update(r)
       
   690         ancestordrevs.update(a)
       
   691     else:
       
   692         for t in tree[1:]:
       
   693             r, a = _prefetchdrevs(t)
       
   694             drevs.update(r)
       
   695             ancestordrevs.update(a)
       
   696     return drevs, ancestordrevs
       
   697 
       
   698 def querydrev(repo, spec):
       
   699     """return a list of "Differential Revision" dicts
       
   700 
       
   701     spec is a string using a simple query language, see docstring in phabread
       
   702     for details.
       
   703 
       
   704     A "Differential Revision dict" looks like:
       
   705 
       
   706         {
       
   707             "id": "2",
       
   708             "phid": "PHID-DREV-672qvysjcczopag46qty",
       
   709             "title": "example",
       
   710             "uri": "https://phab.example.com/D2",
       
   711             "dateCreated": "1499181406",
       
   712             "dateModified": "1499182103",
       
   713             "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
       
   714             "status": "0",
       
   715             "statusName": "Needs Review",
       
   716             "properties": [],
       
   717             "branch": null,
       
   718             "summary": "",
       
   719             "testPlan": "",
       
   720             "lineCount": "2",
       
   721             "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
       
   722             "diffs": [
       
   723               "3",
       
   724               "4",
       
   725             ],
       
   726             "commits": [],
       
   727             "reviewers": [],
       
   728             "ccs": [],
       
   729             "hashes": [],
       
   730             "auxiliary": {
       
   731               "phabricator:projects": [],
       
   732               "phabricator:depends-on": [
       
   733                 "PHID-DREV-gbapp366kutjebt7agcd"
       
   734               ]
       
   735             },
       
   736             "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
       
   737             "sourcePath": null
       
   738         }
       
   739     """
       
   740     def fetch(params):
       
   741         """params -> single drev or None"""
       
   742         key = (params.get(r'ids') or params.get(r'phids') or [None])[0]
       
   743         if key in prefetched:
       
   744             return prefetched[key]
       
   745         drevs = callconduit(repo, b'differential.query', params)
       
   746         # Fill prefetched with the result
       
   747         for drev in drevs:
       
   748             prefetched[drev[r'phid']] = drev
       
   749             prefetched[int(drev[r'id'])] = drev
       
   750         if key not in prefetched:
       
   751             raise error.Abort(_(b'cannot get Differential Revision %r')
       
   752                               % params)
       
   753         return prefetched[key]
       
   754 
       
   755     def getstack(topdrevids):
       
   756         """given a top, get a stack from the bottom, [id] -> [id]"""
       
   757         visited = set()
       
   758         result = []
       
   759         queue = [{r'ids': [i]} for i in topdrevids]
       
   760         while queue:
       
   761             params = queue.pop()
       
   762             drev = fetch(params)
       
   763             if drev[r'id'] in visited:
       
   764                 continue
       
   765             visited.add(drev[r'id'])
       
   766             result.append(int(drev[r'id']))
       
   767             auxiliary = drev.get(r'auxiliary', {})
       
   768             depends = auxiliary.get(r'phabricator:depends-on', [])
       
   769             for phid in depends:
       
   770                 queue.append({b'phids': [phid]})
       
   771         result.reverse()
       
   772         return smartset.baseset(result)
       
   773 
       
   774     # Initialize prefetch cache
       
   775     prefetched = {} # {id or phid: drev}
       
   776 
       
   777     tree = _parse(spec)
       
   778     drevs, ancestordrevs = _prefetchdrevs(tree)
       
   779 
       
   780     # developer config: phabricator.batchsize
       
   781     batchsize = repo.ui.configint(b'phabricator', b'batchsize')
       
   782 
       
   783     # Prefetch Differential Revisions in batch
       
   784     tofetch = set(drevs)
       
   785     for r in ancestordrevs:
       
   786         tofetch.update(range(max(1, r - batchsize), r + 1))
       
   787     if drevs:
       
   788         fetch({r'ids': list(tofetch)})
       
   789     validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
       
   790 
       
   791     # Walk through the tree, return smartsets
       
   792     def walk(tree):
       
   793         op = tree[0]
       
   794         if op == b'symbol':
       
   795             drev = _parsedrev(tree[1])
       
   796             if drev:
       
   797                 return smartset.baseset([drev])
       
   798             elif tree[1] in _knownstatusnames:
       
   799                 drevs = [r for r in validids
       
   800                          if _getstatusname(prefetched[r]) == tree[1]]
       
   801                 return smartset.baseset(drevs)
       
   802             else:
       
   803                 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
       
   804         elif op in {b'and_', b'add', b'sub'}:
       
   805             assert len(tree) == 3
       
   806             return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
       
   807         elif op == b'group':
       
   808             return walk(tree[1])
       
   809         elif op == b'ancestors':
       
   810             return getstack(walk(tree[1]))
       
   811         else:
       
   812             raise error.ProgrammingError(b'illegal tree: %r' % tree)
       
   813 
       
   814     return [prefetched[r] for r in walk(tree)]
       
   815 
       
   816 def getdescfromdrev(drev):
       
   817     """get description (commit message) from "Differential Revision"
       
   818 
       
   819     This is similar to differential.getcommitmessage API. But we only care
       
   820     about limited fields: title, summary, test plan, and URL.
       
   821     """
       
   822     title = drev[r'title']
       
   823     summary = drev[r'summary'].rstrip()
       
   824     testplan = drev[r'testPlan'].rstrip()
       
   825     if testplan:
       
   826         testplan = b'Test Plan:\n%s' % testplan
       
   827     uri = b'Differential Revision: %s' % drev[r'uri']
       
   828     return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
       
   829 
       
   830 def getdiffmeta(diff):
       
   831     """get commit metadata (date, node, user, p1) from a diff object
       
   832 
       
   833     The metadata could be "hg:meta", sent by phabsend, like:
       
   834 
       
   835         "properties": {
       
   836           "hg:meta": {
       
   837             "date": "1499571514 25200",
       
   838             "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
       
   839             "user": "Foo Bar <foo@example.com>",
       
   840             "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
       
   841           }
       
   842         }
       
   843 
       
   844     Or converted from "local:commits", sent by "arc", like:
       
   845 
       
   846         "properties": {
       
   847           "local:commits": {
       
   848             "98c08acae292b2faf60a279b4189beb6cff1414d": {
       
   849               "author": "Foo Bar",
       
   850               "time": 1499546314,
       
   851               "branch": "default",
       
   852               "tag": "",
       
   853               "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
       
   854               "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
       
   855               "local": "1000",
       
   856               "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
       
   857               "summary": "...",
       
   858               "message": "...",
       
   859               "authorEmail": "foo@example.com"
       
   860             }
       
   861           }
       
   862         }
       
   863 
       
   864     Note: metadata extracted from "local:commits" will lose time zone
       
   865     information.
       
   866     """
       
   867     props = diff.get(r'properties') or {}
       
   868     meta = props.get(r'hg:meta')
       
   869     if not meta and props.get(r'local:commits'):
       
   870         commit = sorted(props[r'local:commits'].values())[0]
       
   871         meta = {
       
   872             r'date': r'%d 0' % commit[r'time'],
       
   873             r'node': commit[r'rev'],
       
   874             r'user': r'%s <%s>' % (commit[r'author'], commit[r'authorEmail']),
       
   875         }
       
   876         if len(commit.get(r'parents', ())) >= 1:
       
   877             meta[r'parent'] = commit[r'parents'][0]
       
   878     return meta or {}
       
   879 
       
   880 def readpatch(repo, drevs, write):
       
   881     """generate plain-text patch readable by 'hg import'
       
   882 
       
   883     write is usually ui.write. drevs is what "querydrev" returns, results of
       
   884     "differential.query".
       
   885     """
       
   886     # Prefetch hg:meta property for all diffs
       
   887     diffids = sorted(set(max(int(v) for v in drev[r'diffs']) for drev in drevs))
       
   888     diffs = callconduit(repo, b'differential.querydiffs', {b'ids': diffids})
       
   889 
       
   890     # Generate patch for each drev
       
   891     for drev in drevs:
       
   892         repo.ui.note(_(b'reading D%s\n') % drev[r'id'])
       
   893 
       
   894         diffid = max(int(v) for v in drev[r'diffs'])
       
   895         body = callconduit(repo, b'differential.getrawdiff',
       
   896                            {b'diffID': diffid})
       
   897         desc = getdescfromdrev(drev)
       
   898         header = b'# HG changeset patch\n'
       
   899 
       
   900         # Try to preserve metadata from hg:meta property. Write hg patch
       
   901         # headers that can be read by the "import" command. See patchheadermap
       
   902         # and extract in mercurial/patch.py for supported headers.
       
   903         meta = getdiffmeta(diffs[str(diffid)])
       
   904         for k in _metanamemap.keys():
       
   905             if k in meta:
       
   906                 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
       
   907 
       
   908         content = b'%s%s\n%s' % (header, desc, body)
       
   909         write(encoding.unitolocal(content))
       
   910 
       
   911 @command(b'phabread',
       
   912          [(b'', b'stack', False, _(b'read dependencies'))],
       
   913          _(b'DREVSPEC [OPTIONS]'))
       
   914 def phabread(ui, repo, spec, **opts):
       
   915     """print patches from Phabricator suitable for importing
       
   916 
       
   917     DREVSPEC could be a Differential Revision identity, like ``D123``, or just
       
   918     the number ``123``. It could also have common operators like ``+``, ``-``,
       
   919     ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
       
   920     select a stack.
       
   921 
       
   922     ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
       
   923     could be used to filter patches by status. For performance reason, they
       
   924     only represent a subset of non-status selections and cannot be used alone.
       
   925 
       
   926     For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
       
   927     D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
       
   928     stack up to D9.
       
   929 
       
   930     If --stack is given, follow dependencies information and read all patches.
       
   931     It is equivalent to the ``:`` operator.
       
   932     """
       
   933     if opts.get(b'stack'):
       
   934         spec = b':(%s)' % spec
       
   935     drevs = querydrev(repo, spec)
       
   936     readpatch(repo, drevs, ui.write)
       
   937 
       
   938 @command(b'phabupdate',
       
   939          [(b'', b'accept', False, _(b'accept revisions')),
       
   940           (b'', b'reject', False, _(b'reject revisions')),
       
   941           (b'', b'abandon', False, _(b'abandon revisions')),
       
   942           (b'', b'reclaim', False, _(b'reclaim revisions')),
       
   943           (b'm', b'comment', b'', _(b'comment on the last revision')),
       
   944           ], _(b'DREVSPEC [OPTIONS]'))
       
   945 def phabupdate(ui, repo, spec, **opts):
       
   946     """update Differential Revision in batch
       
   947 
       
   948     DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
       
   949     """
       
   950     flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
       
   951     if len(flags) > 1:
       
   952         raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
       
   953 
       
   954     actions = []
       
   955     for f in flags:
       
   956         actions.append({b'type': f, b'value': b'true'})
       
   957 
       
   958     drevs = querydrev(repo, spec)
       
   959     for i, drev in enumerate(drevs):
       
   960         if i + 1 == len(drevs) and opts.get(b'comment'):
       
   961             actions.append({b'type': b'comment', b'value': opts[b'comment']})
       
   962         if actions:
       
   963             params = {b'objectIdentifier': drev[r'phid'],
       
   964                       b'transactions': actions}
       
   965             callconduit(repo, b'differential.revision.edit', params)
       
   966 
       
   967 templatekeyword = registrar.templatekeyword()
       
   968 
       
   969 @templatekeyword(b'phabreview', requires={b'ctx'})
       
   970 def template_review(context, mapping):
       
   971     """:phabreview: Object describing the review for this changeset.
       
   972     Has attributes `url` and `id`.
       
   973     """
       
   974     ctx = context.resource(mapping, b'ctx')
       
   975     m = _differentialrevisiondescre.search(ctx.description())
       
   976     if m:
       
   977         return {
       
   978             b'url': m.group(b'url'),
       
   979             b'id': b"D{}".format(m.group(b'id')),
       
   980         }