#!/usr/bin/env python3
# Copyright 2015 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Rolls DEPS controlled dependency.

Works only with git checkout and git dependencies. Currently this script will
always roll to the tip of to origin/main.
"""

import argparse
import itertools
import os
import re
import subprocess2
import sys
import tempfile

import gclient_utils

NEED_SHELL = sys.platform.startswith("win")
GCLIENT_PATH = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "gclient.py"
)
_DEPENDENCY_DIVIDER_PATTERN = re.compile(
    r"^-{20} DEPENDENCY DIVIDER -{20}$", re.MULTILINE
)
_REVISION_LINE_PATTERN = re.compile(
    r"^Revision: ([a-f0-9]+|DEPS)$", re.MULTILINE
)

# Commit subject that will be considered a roll. In the format generated by the
# git log used, so it's "<year>-<month>-<day> <author> <subject>"
_ROLL_SUBJECT = re.compile(
    # Date
    r"^\d\d\d\d-\d\d-\d\d "
    # Author
    r"[^ ]+ "
    # Subject
    r"("
    # Generated by
    # https://skia.googlesource.com/buildbot/+/HEAdA/autoroll/go/repo_manager/deps_repo_manager.go
    r"Roll [^ ]+ [a-f0-9]+\.\.[a-f0-9]+ \(\d+ commits\)"
    r"|"
    # Generated by
    # https://chromium.googlesource.com/infra/infra/+/HEAD/recipes/recipe_modules/recipe_autoroller/api.py
    r"Roll recipe dependencies \(trivial\)\."
    r")$"
)

_PUBLIC_GERRIT_HOSTS = {
    "android",
    "aomedia",
    "boringssl",
    "chromium",
    "dart",
    "dawn",
    "fuchsia",
    "gn",
    "go",
    "llvm",
    "pdfium",
    "quiche",
    "skia",
    "swiftshader",
    "webrtc",
}


class Error(Exception):
    pass


class AlreadyRolledError(Error):
    pass


def check_output(*args, **kwargs):
    """subprocess2.check_output() passing shell=True on Windows for git."""
    kwargs.setdefault("shell", NEED_SHELL)
    return subprocess2.check_output(*args, **kwargs).decode("utf-8")


def check_call(*args, **kwargs):
    """subprocess2.check_call() passing shell=True on Windows for git."""
    kwargs.setdefault("shell", NEED_SHELL)
    subprocess2.check_call(*args, **kwargs)


def return_code(*args, **kwargs):
    """subprocess2.call() passing shell=True on Windows for git and
    subprocess2.DEVNULL for stdout and stderr."""
    kwargs.setdefault("shell", NEED_SHELL)
    kwargs.setdefault("stdout", subprocess2.DEVNULL)
    kwargs.setdefault("stderr", subprocess2.DEVNULL)
    return subprocess2.call(*args, **kwargs)


def is_pristine(root):
    """Returns True if a git checkout is pristine."""
    # `git rev-parse --verify` has a non-zero return code if the revision
    # doesn't exist.
    diff_cmd = ["git", "diff", "--ignore-submodules", "origin/main"]
    return (
        not check_output(diff_cmd, cwd=root).strip()
        and not check_output(diff_cmd + ["--cached"], cwd=root).strip()
    )


def get_gerrit_host(url):
    """Returns the host for a given Gitiles URL."""
    m = re.match(r"https://([^/]*)\.googlesource\.com/", url)
    return m and m.group(1)


def get_log_url(upstream_url, head, tot):
    """Returns an URL to read logs via a Web UI if applicable."""
    if get_gerrit_host(upstream_url):
        return f"{upstream_url}/+log/{head[:12]}..{tot[:12]}"
    if upstream_url.startswith("https://github.com/"):
        upstream_url = upstream_url.rstrip("/")
        if upstream_url.endswith(".git"):
            upstream_url = upstream_url[: -len(".git")]
        return f"{upstream_url}/compare/{head[:12]}...{tot[:12]}"
    return None


def should_show_log(upstream_url):
    """Returns True if a short log should be included in the tree."""
    # Skip logs for very active projects.
    if upstream_url.endswith("/v8/v8.git"):
        return False
    if "webrtc" in upstream_url:
        return False
    return get_gerrit_host(upstream_url) in _PUBLIC_GERRIT_HOSTS


def gclient(args):
    """Executes gclient with the given args and returns the stdout."""
    return check_output([sys.executable, GCLIENT_PATH] + args).strip()


def generate_commit_message(
    full_dir, dependency, head, roll_to, upstream_url, show_log, log_limit
):
    """Creates the commit message for this specific roll."""
    commit_range = f"{head}..{roll_to}"
    commit_range_for_header = f"{head[:9]}..{roll_to[:9]}"
    cmd = ["git", "log", commit_range, "--date=short", "--no-merges"]
    logs = check_output(
        # Args with '=' are automatically quoted.
        cmd + ["--format=%ad %ae %s", "--"],
        cwd=full_dir,
    ).rstrip()
    logs = re.sub(r"(?m)^(\d\d\d\d-\d\d-\d\d [^@]+)@[^ ]+( .*)$", r"\1\2", logs)
    lines = logs.splitlines()
    cleaned_lines = [l for l in lines if not _ROLL_SUBJECT.match(l)]  # noqa: E741
    logs = "\n".join(cleaned_lines) + "\n"

    nb_commits = len(lines)
    rolls = nb_commits - len(cleaned_lines)
    header = (
        f"Roll {dependency}/ {commit_range_for_header} "
        f"({nb_commits} commit{'s' if nb_commits > 1 else ''}"
        f"{f'; {rolls} trivial rolls' if rolls else ''})\n\n"
    )
    log_section = ""
    if log_url := get_log_url(upstream_url, head, roll_to):
        log_section = log_url + "\n\n"
    # It is important that --no-log continues to work, as it is used by
    # internal -> external rollers. Please do not remove or break it.
    if show_log:
        log_section += f"$ {' '.join(cmd)} "
        log_section += "--format='%ad %ae %s'\n"
        log_section = log_section.replace(commit_range, commit_range_for_header)
        if len(cleaned_lines) > log_limit:
            # Keep the first N/2 log entries and last N/2 entries.
            lines = logs.splitlines(True)
            lines = (
                lines[: log_limit // 2] + ["(...)\n"] + lines[-log_limit // 2 :]
            )
            logs = "".join(lines)
        log_section += logs + "\n"
    return header + log_section


def is_submoduled():
    """Returns true if gclient root has submodules"""
    return os.path.isfile(os.path.join(gclient(["root"]), ".gitmodules"))


def get_submodule_rev(submodule):
    """Returns revision of the given submodule path"""
    rev_output = check_output(
        ["git", "submodule", "status", submodule], cwd=gclient(["root"])
    ).strip()

    # git submodule status <path> returns all submodules with its rev in the
    # pattern: `(+|-| )(<revision>) (submodule.path)`
    revision = rev_output.split(" ")[0]
    return revision[1:] if revision[0] in ("+", "-") else revision


def calculate_roll(full_dir, dependency, roll_to):
    """Calculates the roll for a dependency by processing gclient_dict, and
    fetching the dependency via git.
    """
    # if the super-project uses submodules, get rev directly using git.
    if is_submoduled():
        head = get_submodule_rev(dependency)
    else:
        head = gclient(["getdep", "-r", dependency])
    if not head:
        raise Error(f"{dependency} is unpinned.")
    check_call(["git", "fetch", "origin", "--quiet"], cwd=full_dir)
    if roll_to == "origin/HEAD":
        check_output(
            ["git", "remote", "set-head", "origin", "-a"], cwd=full_dir
        )

    roll_to = check_output(["git", "rev-parse", roll_to], cwd=full_dir).strip()
    return head, roll_to


def gen_commit_msg(logs, cmdline, reviewers, bug):
    """Returns the final commit message."""
    commit_msg = ""
    if len(logs) > 1:
        commit_msg = f"Rolling {len(logs)} dependencies\n\n"
    commit_msg += "\n\n".join(logs)
    commit_msg += f"Created with:\n  {cmdline}\n"
    if reviewers:
        commit_msg += f"R={','.join(reviewers)}\n"
    if bug:
        commit_msg += f"\nBug: {bug}\n"
    return commit_msg


def finalize(args, commit_msg, current_dir, rolls):
    """Commits changes to the DEPS file, then uploads a CL."""
    print("Commit message:")
    print("\n".join("    " + i for i in commit_msg.splitlines()))

    # Pull the dependency to the right revision. This is surprising to users
    # otherwise. The revision update is done before committing to update
    # submodule revision if present.
    for dependency, (_head, roll_to, full_dir) in sorted(rolls.items()):
        check_call(["git", "checkout", "--quiet", roll_to], cwd=full_dir)

        # Attempt to update README.chromium.
        if not args.no_update_readme:
            update_readme_chromium(dependency, roll_to, current_dir)

        # This adds the submodule revision update to the commit.
        if is_submoduled():
            check_call(
                [
                    "git",
                    "update-index",
                    "--add",
                    "--cacheinfo",
                    "160000,{},{}".format(roll_to, dependency),
                ],
                cwd=current_dir,
            )

    check_call(["git", "add", "DEPS"], cwd=current_dir)
    # We have to set delete=False and then let the object go out of scope so
    # that the file can be opened by name on Windows.
    with tempfile.NamedTemporaryFile("w+", newline="", delete=False) as f:
        commit_filename = f.name
        f.write(commit_msg)
    check_call(
        ["git", "commit", "--quiet", "--file", commit_filename], cwd=current_dir
    )
    os.remove(commit_filename)


def update_readme_chromium(dependency, roll_to, current_dir):
    """Attempts to update the README.chromium file with the new revision.

    TODO(b/390067679): Handle README.chromium files with multiple dependencies.

    TODO(b/390067679): Add flag to provide custom location for README.chromium.

    Args:
        dependency: Path to the dependency being rolled.
        roll_to: New revision hash to roll to.
        current_dir: Current working directory.
    """

    # README.chromium is typically one directory up from the dependency.
    gclient_root = gclient(["root"])
    readme_path = os.path.normpath(
        os.path.join(
            gclient_root, dependency, os.path.pardir, "README.chromium"
        )
    )

    if not os.path.isfile(readme_path):
        print(f"No README.chromium found at {readme_path}")
        return

    with open(readme_path, "r") as f:
        content = f.read()

    # TODO(b/390067679): Handle README.chromium files with multiple dependencies.
    if _DEPENDENCY_DIVIDER_PATTERN.match(content):
        print(
            'README.chromium contains "- DEPENDENCY DIVIDER -"\n'
            "Files with multiple dependencies are not supported"
        )
        return

    # Only update when there is exactly one `Revision: line`.
    revision_count = len(_REVISION_LINE_PATTERN.findall(content))
    if revision_count != 1:
        print(
            f"README.chromium contains {revision_count} Revision: lines, skipping update.\n"
            "Files with multiple dependencies are not supported"
        )
        return

    # Update the revision line.
    new_content = _REVISION_LINE_PATTERN.sub(f"Revision: {roll_to}", content)

    if new_content == content:
        print(
            f"README.chromium already has revision {roll_to}, \ncontent:{new_content}"
        )
        return

    with open(readme_path, "w") as f:
        f.write(new_content)
    check_call(["git", "add", readme_path], cwd=current_dir)
    print(f"Updated revision in README.chromium for {dependency} to {roll_to}")


def main():
    if gclient_utils.IsEnvCog():
        print(
            '"roll-dep" is not supported in non-git environment',
            file=sys.stderr,
        )
        return 1
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--ignore-dirty-tree",
        action="store_true",
        help="Roll anyways, even if there is a diff.",
    )
    parser.add_argument(
        "-r",
        "--reviewer",
        action="append",
        help="To specify multiple reviewers, either use a comma separated "
        "list, e.g. -r joe,jane,john or provide the flag multiple times, e.g. "
        "-r joe -r jane. Defaults to @chromium.org",
    )
    parser.add_argument(
        "-b", "--bug", help="Associate a bug number to the roll"
    )
    # It is important that --no-log continues to work, as it is used by
    # internal -> external rollers. Please do not remove or break it.
    parser.add_argument(
        "--no-log",
        action="store_true",
        help="Do not include the short log in the commit message",
    )
    parser.add_argument(
        "--always-log",
        action="store_true",
        help="Always include the short log in the commit message",
    )
    parser.add_argument(
        "--log-limit",
        type=int,
        default=100,
        help="Trim log after N commits (default: %(default)s)",
    )
    parser.add_argument(
        "--roll-to",
        default="origin/HEAD",
        help="Specify the new commit to roll to (default: %(default)s)",
    )
    parser.add_argument(
        "--key",
        action="append",
        default=[],
        help="Regex(es) for dependency in DEPS file",
    )
    parser.add_argument("dep_path", nargs="+", help="Path(s) to dependency")
    parser.add_argument(
        "--no-update-readme",
        action="store_true",
        help="Do not try to update Revision in README.chromium",
    )
    args = parser.parse_args()

    if len(args.dep_path) > 1:
        if args.roll_to != "origin/HEAD":
            parser.error(
                "Can't use multiple paths to roll simultaneously and --roll-to"
            )
        if args.key:
            parser.error(
                "Can't use multiple paths to roll simultaneously and --key"
            )

    if args.no_log and args.always_log:
        parser.error("Can't use both --no-log and --always-log")

    reviewers = None
    if args.reviewer:
        reviewers = list(
            itertools.chain(*[r.split(",") for r in args.reviewer])
        )
        for i, r in enumerate(reviewers):
            if "@" not in r:
                reviewers[i] = r + "@chromium.org"

    gclient_root = gclient(["root"])
    current_dir = os.getcwd()
    dependencies = sorted(
        d.replace("\\", "/").rstrip("/") for d in args.dep_path
    )
    cmdline = (
        "roll-dep "
        + " ".join(dependencies)
        + "".join(" --key " + k for k in args.key)
    )
    if args.no_update_readme:
        cmdline += " --no-update-readme"
    try:
        if not args.ignore_dirty_tree and not is_pristine(current_dir):
            raise Error(
                f"Ensure {current_dir} is clean first (no non-merged commits)."
            )
        # First gather all the information without modifying anything, except
        # for a git fetch.
        rolls = {}
        for dependency in dependencies:
            full_dir = os.path.normpath(os.path.join(gclient_root, dependency))
            if not os.path.isdir(full_dir):
                print(f"Dependency {dependency} not found at {full_dir}")
                full_dir = os.path.normpath(
                    os.path.join(current_dir, dependency)
                )
                print(f"Will look for relative dependency at {full_dir}")
                if not os.path.isdir(full_dir):
                    raise Error(
                        f"Directory not found: {dependency} ({full_dir})"
                    )

            head, roll_to = calculate_roll(full_dir, dependency, args.roll_to)
            if roll_to == head:
                if len(dependencies) == 1:
                    raise AlreadyRolledError("No revision to roll!")
                print(f"{dependency}: Already at latest commit {roll_to}")
            else:
                print(
                    f"{dependency}: Rolling from {head[:10]} to {roll_to[:10]}"
                )
                rolls[dependency] = (head, roll_to, full_dir)

        logs = []
        setdep_args = []
        for dependency, (head, roll_to, full_dir) in sorted(rolls.items()):
            upstream_url = check_output(
                ["git", "config", "remote.origin.url"], cwd=full_dir
            ).strip()
            show_log = args.always_log or (
                not args.no_log and should_show_log(upstream_url)
            )
            if not show_log:
                print(
                    f"{dependency}: Omitting git log from the commit message. "
                    "Use the `--always-log` flag to include it."
                )
            log = generate_commit_message(
                full_dir,
                dependency,
                head,
                roll_to,
                upstream_url,
                show_log,
                args.log_limit,
            )
            logs.append(log)
            setdep_args.extend(["-r", "{}@{}".format(dependency, roll_to)])

        # DEPS is updated even if the repository uses submodules.
        gclient(["setdep"] + setdep_args)

        commit_msg = gen_commit_msg(logs, cmdline, reviewers, args.bug)
        finalize(args, commit_msg, current_dir, rolls)
    except Error as e:
        sys.stderr.write(f"error: {e}\n")
        return 2 if isinstance(e, AlreadyRolledError) else 1
    except subprocess2.CalledProcessError:
        return 1

    print("")
    if not reviewers:
        print(
            "You forgot to pass -r, make sure to insert a R=foo@example.com "
            "line"
        )
        print("to the commit description before emailing.")
        print("")
    print("Run:")
    print("  git cl upload --send-mail")
    return 0


if __name__ == "__main__":
    sys.exit(main())
