#!/usr/bin/env python3
"""Reject workflow YAML that GitHub Actions would refuse to run.

A duplicate mapping key is invalid YAML, but most loaders silently keep the
last value, so a workflow can look correct locally and behave correctly when
read by anything forgiving. GitHub does not forgive it: it rejects the file
and the run ends in 0s with no jobs, no annotations, and only "This run
likely failed because of a workflow file issue" to go on. CI cannot warn you
either, because the workflow that would have told you is the broken one.

Checks the staged content of .github/workflows/*.yml, not the working tree,
so a fixed-but-unstaged file still fails.

Run directly with paths to check files outside a commit:

    .githooks/pre-commit .github/workflows/on_push.yml
"""
import subprocess
import sys

WORKFLOW_DIR = ".github/workflows"
SUFFIXES = (".yml", ".yaml")


def staged_workflows():
    """Paths of staged, still-present workflow files."""
    out = subprocess.run(
        ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
        capture_output=True, text=True, check=True,
    ).stdout
    return [
        p for p in out.splitlines()
        if p.startswith(WORKFLOW_DIR + "/") and p.endswith(SUFFIXES)
    ]


def staged_content(path):
    """The staged bytes for `path`, which may differ from the working tree."""
    return subprocess.run(
        ["git", "show", f":{path}"],
        capture_output=True, check=True,
    ).stdout


def check(path, content):
    """Return an error string, or None when the file is fine."""
    import yaml

    class Strict(yaml.SafeLoader):
        pass

    def no_duplicate_keys(loader, node, deep=False):
        seen = set()
        for key_node, _ in node.value:
            key = loader.construct_object(key_node, deep=deep)
            if key in seen:
                raise yaml.constructor.ConstructorError(
                    None, None, f"duplicate key {key!r}", key_node.start_mark
                )
            seen.add(key)
        return yaml.constructor.SafeConstructor.construct_mapping(loader, node, deep)

    Strict.add_constructor(
        yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, no_duplicate_keys
    )
    try:
        yaml.load(content, Strict)
    except yaml.YAMLError as e:
        return str(e)
    return None


def main(argv):
    try:
        import yaml  # noqa: F401
    except ImportError:
        # Skipping beats blocking a commit over a missing dev dependency, but
        # say so rather than passing silently
        print(
            "pre-commit: PyYAML not installed, skipping the workflow YAML check.\n"
            "            pip install pyyaml  to enable it.",
            file=sys.stderr,
        )
        return 0

    if argv:
        targets = [(p, open(p, "rb").read()) for p in argv]
    else:
        targets = [(p, staged_content(p)) for p in staged_workflows()]

    failed = False
    for path, content in targets:
        error = check(path, content)
        if error:
            if not failed:
                print("pre-commit: invalid workflow YAML\n", file=sys.stderr)
            print(f"  {path}:\n{error}\n", file=sys.stderr)
            failed = True

    if failed:
        print(
            "GitHub Actions rejects these outright: the run ends in 0s with no\n"
            "jobs and no useful error. Fix them, or commit with --no-verify if\n"
            "you are certain.",
            file=sys.stderr,
        )
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
