#!/usr/bin/env python3
"""Agent-driven PR review + merge gate for ASUCB.

Use this when a Codex or high-capability Claude reviewer (Opus 4.7 Extra
High, Fable 5) has approved a PR. The script enforces the same gate
auto-merge would, without requiring org-admin to enable GitHub auto-merge
server-side.

Gate (all four must pass):
  1. PR is mergeable (no conflicts).
  2. All required CI checks are green (status: success).
  3. Full pytest suite on the PR branch passes locally.
  4. Reviewer has signed off (passed via --reviewer flag with one of
     {codex, opus, opus-extra-high, fable}).

On all-green, merges with `gh pr merge <n> --squash --delete-branch`.
On any failure, prints the exact gate that failed and exits non-zero.

Usage:
    python3 scripts/agent_review_and_merge.py --pr 34 --reviewer opus-extra-high

The --reviewer flag is intentionally required so the merge action records
WHO reviewed (in the audit trail) and so the script cannot be invoked
from a coder agent that happens to also be the reviewer.
"""
import argparse
import json
import re
import subprocess
import sys
import time
from pathlib import Path

REPO = "AuryanASU/asu-canvas-builder"
REPO_ROOT = Path(__file__).resolve().parent.parent
VALID_REVIEWERS = {"codex", "opus", "opus-extra-high", "fable"}

# Branch names are interpolated into argv for git commands in gate_3_local_tests.
# Even in list-argv form (no shell), a name starting with "-" can be parsed by
# git as a flag (e.g. "--upload-pack=evil"). Require a conservative charset.
_SAFE_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")


def _is_safe_branch_name(name):
    """True if `name` is safe to pass as a single git argv element."""
    return bool(name) and bool(_SAFE_BRANCH_RE.match(name))


def _run(cmd, check=True, capture=True):
    """Run a shell command; return CompletedProcess."""
    result = subprocess.run(
        cmd,
        shell=isinstance(cmd, str),
        check=False,
        capture_output=capture,
        text=True,
        cwd=str(REPO_ROOT),
    )
    if check and result.returncode != 0:
        sys.stderr.write(f"Command failed: {cmd}\n{result.stderr}\n")
        sys.exit(2)
    return result


def gh_pr_view(pr_number):
    fields = "number,title,headRefName,baseRefName,mergeable,mergeStateStatus,state,author,statusCheckRollup"
    r = _run(["gh", "pr", "view", str(pr_number), "--repo", REPO,
              "--json", fields])
    return json.loads(r.stdout)


def gate_1_mergeable(pr):
    """PR must be mergeable + no conflicts."""
    if pr["state"] != "OPEN":
        return False, f"PR is {pr['state']}, not OPEN"
    if pr["mergeable"] != "MERGEABLE":
        return False, f"mergeable={pr['mergeable']} (expected MERGEABLE)"
    if pr["mergeStateStatus"] not in ("CLEAN", "UNSTABLE"):
        # UNSTABLE means mergeable but some non-required check failed; we
        # tighten this by requiring CLEAN below.
        return False, f"mergeStateStatus={pr['mergeStateStatus']} (expected CLEAN or UNSTABLE)"
    return True, "mergeable + no conflicts"


def gate_2_ci_green(pr):
    """All status checks listed on the PR must be SUCCESS (or skip neutral/skipped)."""
    rollup = pr.get("statusCheckRollup") or []
    if not rollup:
        return False, "no CI checks found on PR (expected at least one)"
    failing = []
    pending = []
    for chk in rollup:
        status = chk.get("status") or chk.get("state")
        conclusion = chk.get("conclusion")
        name = chk.get("name") or chk.get("context") or "<unnamed>"
        # CheckRun: status=COMPLETED, conclusion in {SUCCESS,FAILURE,...}
        # StatusContext: state in {SUCCESS,FAILURE,PENDING,EXPECTED}
        if status == "COMPLETED":
            if conclusion not in ("SUCCESS", "NEUTRAL", "SKIPPED"):
                failing.append(f"{name}={conclusion}")
        elif status in ("IN_PROGRESS", "QUEUED", "PENDING"):
            pending.append(name)
        elif status == "SUCCESS":
            pass
        elif status in ("FAILURE", "ERROR"):
            failing.append(f"{name}={status}")
        else:
            failing.append(f"{name}=status:{status} conclusion:{conclusion}")
    if pending:
        return False, f"CI still running: {', '.join(pending)}"
    if failing:
        return False, f"CI failing: {', '.join(failing)}"
    return True, f"{len(rollup)} check(s) green"


def gate_3_local_tests(pr):
    """Check out the PR branch + run full pytest. Restore prior branch on exit."""
    current = _run("git rev-parse --abbrev-ref HEAD").stdout.strip()
    branch = pr["headRefName"]
    for name in (branch, current):
        if not _is_safe_branch_name(name):
            return False, f"unsafe branch name: {name!r}"
    _run(["git", "fetch", "origin", branch])
    _run(["git", "checkout", branch])
    _run(["git", "pull", "origin", branch])
    try:
        # sys.executable, not bare python3: the suite must run under the plugin
        # venv (heavy deps live there), matching whichever interpreter runs this gate
        r = _run(f'"{sys.executable}" -m pytest tests/ -q', check=False)
        if r.returncode != 0:
            tail = (r.stdout or "")[-1200:] + "\n" + (r.stderr or "")[-500:]
            return False, f"pytest failed:\n{tail}"
        # Parse the final summary line if present
        last = (r.stdout or "").strip().splitlines()[-1] if r.stdout else ""
        return True, f"pytest passed ({last})"
    finally:
        # Return to whatever branch we were on, even if pytest crashed
        _run(["git", "checkout", current], check=False)


def merge_pr(pr_number, reviewer):
    """Squash + delete branch."""
    body = f"Reviewed by: {reviewer}\nGate: mergeable + CI green + local pytest"
    r = _run([
        "gh", "pr", "merge", str(pr_number),
        "--repo", REPO,
        "--squash",
        "--delete-branch",
        "--body", body,
    ], check=False)
    if r.returncode != 0:
        return False, f"merge failed: {r.stderr}"
    return True, "merged + branch deleted"


def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--pr", type=int, required=True, help="PR number")
    p.add_argument("--reviewer", required=True, choices=sorted(VALID_REVIEWERS),
                   help="Identity of the approving reviewer")
    p.add_argument("--skip-tests", action="store_true",
                   help="Skip the local pytest gate (NOT recommended; for CI-only verification)")
    p.add_argument("--dry-run", action="store_true",
                   help="Run all gates; do not merge")
    args = p.parse_args()

    pr = gh_pr_view(args.pr)
    print(f"PR #{pr['number']}: {pr['title']!r}")
    print(f"  branch: {pr['headRefName']} → {pr['baseRefName']}")
    print(f"  author: {pr['author']['login']}")
    print()

    print("Gate 1 — mergeable + no conflicts")
    ok, msg = gate_1_mergeable(pr)
    print(f"  {'✓' if ok else '✗'} {msg}")
    if not ok:
        sys.exit(1)

    print("Gate 2 — CI checks green")
    ok, msg = gate_2_ci_green(pr)
    print(f"  {'✓' if ok else '✗'} {msg}")
    if not ok:
        sys.exit(1)

    if args.skip_tests:
        print("Gate 3 — local pytest SKIPPED (--skip-tests)")
    else:
        print("Gate 3 — local pytest on PR branch")
        ok, msg = gate_3_local_tests(pr)
        print(f"  {'✓' if ok else '✗'} {msg}")
        if not ok:
            sys.exit(1)

    print(f"Gate 4 — reviewer sign-off: {args.reviewer}")
    print()

    if args.dry_run:
        print("DRY RUN: all gates passed, would merge.")
        return

    print("All gates passed. Merging...")
    ok, msg = merge_pr(args.pr, args.reviewer)
    print(f"  {'✓' if ok else '✗'} {msg}")
    if not ok:
        sys.exit(1)


if __name__ == "__main__":
    main()
