#!/usr/bin/env python3

# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import argparse
import json
import logging
import pathlib
import subprocess
import tempfile
from util import jj_log
from util import run_command
from util import run_jj
from util import join_revsets
from util import percent_encode_for_git_ref
from util import split_description
from util import IMMUTABLE_PARENTS
from util import MUTABLE_PARENTS


def fatal(*args, **kwargs):
  logging.critical(*args, **kwargs)
  exit(1)


def get_refspec_opts(args) -> list[str]:
  # Extra options that can be specified at push time. Doc:
  # https://gerrit-review.googlesource.com/Documentation/user-upload.html
  refspec_opts = []

  # Code mostly stolen from `git_cl.py`
  if args.send_mail:
    refspec_opts.append('ready')
    refspec_opts.append('notify=ALL')
  if args.enable_auto_submit:
    refspec_opts.append('l=Auto-Submit+1')
  if args.enable_owners_override:
    refspec_opts.append('l=Owners-Override+1')
  if args.use_commit_queue:
    refspec_opts.append('l=Commit-Queue+2')
  elif args.cq_dry_run:
    refspec_opts.append('l=Commit-Queue+1')
  if args.title:
    refspec_opts.append(f'm={percent_encode_for_git_ref(args.title)}')
  for reviewer in args.reviewers:
    refspec_opts.append(f'r={reviewer}')
  return refspec_opts


def main(args, unknown_args):
  logging.basicConfig(level=logging.getLevelNamesMapping()[args.verbosity])

  revs = args.revisions + args.revision
  implicit_revs = False
  # If no revisions are provided, we will upload `@` unless it is empty and
  # descriptionless, in which case we upload 'parents(@)'.
  if len(revs) == 0:
    rev = '@'
    implicit_revs = True
  else:
    rev = join_revsets(revs)

  snapshot_taken = False

  if args.fix:
    run_jj(['fix', '-s', f'mutable()::({rev})'])
    # After running fix, jj creates another snapshot.
    snapshot_taken = True

  to_upload = jj_log(
    revisions=f'mutable()::({rev})',
    templates={
      'commit_id': 'commit_id',
      'empty': 'empty',
      'desc': 'description',
      'mutable_parents': MUTABLE_PARENTS,
    },
    ignore_working_copy=snapshot_taken,
  )
  snapshot_taken = True
  if implicit_revs:
    # It's in reverse topological order, so to_upload[0] is the working copy '@'
    wc = to_upload[0]
    if not split_description(wc['desc'])[0] and wc['empty'] == 'true':
      revs = ['parents(@)']
      logging.info(
        'No revisions provided and working copy is empty and '
        + 'descriptionless, uploading parents(@)'
      )
      to_upload.remove(wc)
    else:
      revs = '@'
      logging.info('No revisions provided, uploading working copy')

  for change in to_upload:
    name = change['name']
    desc, trailers = split_description(change['desc'])
    # Don't trust `git cl presubmit` to pick up on these for stacked changes,
    # since it assumes all the commits will be squashed.
    if change['empty'] == 'true':
      fatal('Attempting to upload an empty change %s', name)
    if not desc:
      fatal('Attempting to upload change with an empty description %s', name)
    if 'Bug' not in trailers and 'Fixed' not in trailers:
      logging.warning(
        'Change %s has no associated Bug. If this change has an associated '
        + 'bug, run `jj bug add [--inherit]`',
        name,
      )

  if not args.bypass_hooks:
    jj_root = run_jj(
      ['root'], ignore_working_copy=True, stdout=subprocess.PIPE, text=True
    ).stdout.strip()
    if not (pathlib.Path(jj_root) / '.git').exists():
      logging.warning(
        '`git cl presubmit` will be skipped because this is a '
        'standalone jj workspace that is not a git working tree'
      )
      args.bypass_hooks = True

  if not args.bypass_hooks:
    # Find the commits that `git cl presubmit` will actually run on
    got_presubmits = jj_log(
      revisions=f'mutable()::@',
      templates={'empty': 'empty', 'immutable_parents': IMMUTABLE_PARENTS},
      ignore_working_copy=snapshot_taken,
    )

    # We could simplify this with another call to jj_log, but each call to
    # jj_log can take a nontrivial amount of time.
    immutable_parents = {
      commit_id for c in got_presubmits for commit_id in c['immutable_parents']
    }
    if len(immutable_parents) != 1:
      fatal(
        '%s has multiple different immutable parents of mutable ancestors. '
        + 'Fix with a rebase or jj simplify-parents.',
        rev,
      )

    want_presubmits = {x['name'] for x in to_upload if x['empty'] == 'false'}
    got_presubmits = {
      x['name'] for x in got_presubmits if x['empty'] == 'false'
    }

    if want_presubmits.intersection(got_presubmits):
      for change in got_presubmits - want_presubmits:
        logging.warning(
          "Running presubmit on additional non-empty revision %s", change
        )
      for change in want_presubmits - got_presubmits:
        logging.warning("Presubmits will be skipped for %s", change)

      # Git CL presubmit is very annoying with stacked commits. It:
      # * Always runs with files from @
      #   * This means that if you have an error in a parent commit fixed in a
      #     child commit, it won't pick up on it.
      # * Simply concatenates the CL descriptions from mutable()::@-
      #   * It ignores the CL description for @
      #   * If, for example the first commit in the chain has an associated bug,
      #     it won't warn you other commits in the chain don't have one.
      # This isn't any worse with jj than with git, but it is very annoying.
      # In particular, if you're uploading any commit except @-, expect some
      # weirdness.
      with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
        out = pathlib.Path(f.name)
      try:
        presubmit_cmd = [
          'git',
          'cl',
          'presubmit',
          # Allows it to run with a dirty tree and on no branch
          '--force',
        ]
        if args.parallel:
          presubmit_cmd.append('--parallel')
        presubmit_cmd.extend(
          [
            # Unfortunately, upload skips certain checks which would be
            # useful. However, it also skips certain checks we really don't
            # want to run. CheckTreeIsOpen(), for example.
            '--upload',
            f'--json={out}',
            next(iter(immutable_parents)),
          ]
        )
        run_command(presubmit_cmd, cwd=jj_root)
        results = json.loads(out.read_text())
        if results.get('errors', []) or results.get('warnings', []):
          if not args.allow_warnings:
            fatal(
              'git cl presubmit had warnings.\n'
              + 'Hint: maybe you want --allow-warnings?'
            )
      finally:
        # On Windows, NamedTemporaryFile cannot be opened by another process
        # while it is still open by the creating process. We use delete=False
        # so we can close the file before passing it to `git cl presubmit`,
        # then manually clean it up in the finally block.
        out.unlink(missing_ok=True)
    else:
      # For consistency's sake, we warn if the intersection of commits is small,
      # so we should also warn if the intersection is emmpty.
      logging.warning(
        'git cl presubmit only supports running on the '
        + 'revision @. `git cl presubmit` will be skipped'
      )

  refspec = get_refspec_opts(args)
  refspec_suffix = '%' + ','.join(refspec) if refspec else ''

  cmd = [
    'gerrit',
    'upload',
    '--remote',
    'origin',
    '--remote-branch',
    args.target_branch + refspec_suffix,
  ] + unknown_args
  for rev in revs:
    cmd.extend(['-r', rev])
  if args.upload:
    run_jj(cmd)
  else:
    logging.info('no-upload: Would otherwise run `%s`', ' '.join(cmd))


if __name__ == '__main__':
  parser = argparse.ArgumentParser()
  parser.add_argument(
    '--verbosity',
    help='Verbosity of logging',
    default='INFO',
    choices=logging.getLevelNamesMapping().keys(),
    type=lambda x: x.upper(),
  )

  # Alternative form so users can write `upload -r foo` as well as `upload foo``
  parser.add_argument(
    '-r', '--revision', help=None, action='append', default=[]
  )
  parser.add_argument('revisions', help='Revisions to upload', nargs='*')
  parser.add_argument(
    '--no-fix',
    help='Skips running `jj fix` before uploading',
    action='store_false',
    dest='fix',
  )
  parser.add_argument(
    '--no-upload',
    help='Doesn\'t actually upload the change to gerrit',
    action='store_false',
    dest='upload',
  )
  parser.add_argument(
    '--allow-warnings',
    help='Prevents presubmit warnings from blocking upload',
    action='store_true',
  )
  parser.add_argument(
    '--parallel',
    help='Runs git cl presubmit checks in parallel',
    action='store_true',
  )

  # These args are directly copied from git_cl.py
  parser.add_argument(
    '--bypass-hooks', action='store_true', help='bypass upload presubmit hook'
  )
  # We use -R instead of the -r that git cl upload uses because -r in jj means
  # revision.
  parser.add_argument(
    '-R',
    '--reviewers',
    action='append',
    default=[],
    help='reviewer email addresses',
  )
  parser.add_argument(
    '-s',
    '--send-mail',
    '--send-email',
    dest='send_mail',
    action='store_true',
    help='send email to reviewer(s) and cc(s) immediately',
  )
  parser.add_argument(
    '--target_branch',
    '--target-branch',
    metavar='TARGET',
    help='Apply CL to remote branch TARGET.',
    default='main',
  )

  parser.add_argument(
    '-c',
    '--use-commit-queue',
    action='store_true',
    default=False,
    help='tell the CQ to commit this patchset; implies --send-mail',
  )
  parser.add_argument(
    '-d',
    '--dry-run',
    '--cq-dry-run',
    action='store_true',
    dest='cq_dry_run',
    default=False,
    help='Send the patchset to do a CQ dry run right after upload.',
  )
  parser.add_argument(
    '-a',
    '--auto-submit',
    '--enable-auto-submit',
    action='store_true',
    dest='enable_auto_submit',
    help='Sends your change to the CQ after an approval. Only '
    'works on repos that have the Auto-Submit label '
    'enabled',
  )
  parser.add_argument(
    '-t',
    '--title',
    dest='title',
    default="",
    help='Adds a title for the patchset.',
  )
  parser.add_argument(
    '--enable-owners-override',
    action='store_true',
    help='Adds the Owners-Override label to your change.',
  )

  args, unknown_args = parser.parse_known_args()
  main(args, unknown_args)
