#!/usr/bin/env python3
"""Lintel's pre-merge check for one pull request on PublicAgents/public-agents.

Read-only. Uses the public GitHub API (no token; about 7 calls per PR,
60 per hour unauthenticated). It mirrors the registry's own rules
(src/scripts/pr-class.ts, docs/GOVERNANCE.md, CODEOWNERS as of d65e615)
and adds the checks CI cannot make: whose authorship, whose approval on
which head, what the title claims versus what the files do.

    python3 tools/premerge.py <pr-number> [--repo owner/name] [--json] [--deep]

--deep additionally reproduces CI in a local clone (about two minutes the
first time, seconds afterwards) and, when a check is red, says whether the
refusal is the branch's stale base rather than anything in the diff. It
clones, fetches and merges only in a scratch directory; it never pushes.

The verdict is advice to me, never a decision: the merge door decides
whether a PR qualifies, and I decide whether to knock on it.
"""
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.error
import urllib.request

REPO = "PublicAgents/public-agents"
API = "https://api.github.com"
UA = "lintel-premerge (cto-public-agents-bot; autonomous agent)"

ME = "cto-public-agents-bot"
REVIEWER = "reviewer-public-agents-bot"
RESEARCHER = "researcher-public-agents-bot"
OPERATOR = "michi88"

# pr-class.ts: data when EVERY path is under these and NOTHING is deleted.
DATA_PREFIXES = ("registry/agents/", "registry/tools/", "registry/jobs/", "registry/evidence/")
# The paths that decide what can merge or what an agent may do. A change
# under one of these gets my own read of the diff (CHARTER, 2026-09-17).
GATE_PREFIXES = (
    ".github/",
    "src/scripts/pr-class.ts",
    "src/scripts/validate.ts",
    "src/scripts/verify-ownership.ts",
    "src/scripts/check-links.ts",
    "src/lib/links.ts",
    "registry/editors.json",
    "registry/reserved-handles.json",
    "docs/GOVERNANCE.md",
    "package.json",
)
# validate.yml on a pull request; "build" only runs on push to main.
REQUIRED_CHECKS = ("validate", "verify-ownership", "links")


def get(path, raw=False):
    req = urllib.request.Request(API + path, headers={"User-Agent": UA, "Accept": "application/vnd.github+json"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            body = r.read()
    except urllib.error.HTTPError as e:
        sys.exit(f"github api {e.code} on {path}: {e.read()[:200]!r}")
    return body if raw else json.loads(body)


def paged(path):
    out, page = [], 1
    while True:
        chunk = get(f"{path}{'&' if '?' in path else '?'}per_page=100&page={page}")
        out.extend(chunk)
        if len(chunk) < 100:
            return out
        page += 1


def classify(files):
    """Returns (class, reasons). A rename counts its old side as a deletion."""
    reasons = []
    if not files:
        return "code", ["no files changed"]
    for f in files:
        st, p = f["status"], f["filename"]
        if st == "removed":
            reasons.append(f"deletion: {p}")
        elif st == "renamed":
            reasons.append(f"rename (old side is a deletion): {f.get('previous_filename')} -> {p}")
        if not p.startswith(DATA_PREFIXES):
            reasons.append(f"outside the data directories: {p}")
    return ("code" if reasons else "data"), reasons


def head_json(repo, sha, path):
    try:
        raw = get(f"/repos/{repo}/contents/{path}?ref={sha}")
        import base64
        return json.loads(base64.b64decode(raw["content"]))
    except SystemExit:
        return None
    except (ValueError, KeyError):
        return None


DEEP_DIR = "/tmp/lintel-premerge-clone"


GIT_ENV = dict(os.environ, GIT_AUTHOR_NAME="premerge", GIT_AUTHOR_EMAIL="premerge@localhost",
               GIT_COMMITTER_NAME="premerge", GIT_COMMITTER_EMAIL="premerge@localhost")


def run(cmd, cwd, timeout=600):
    """Runs a command in the scratch clone; returns (ok, combined output)."""
    p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, env=GIT_ENV)
    return p.returncode == 0, (p.stdout + p.stderr).strip()


def deep_check(repo, number, head, author, base_ref, open_pr=True, held_runs=()):
    """Reproduces CI locally on the head and on the head merged with current
    base, and reports the difference. The whole point is one question: is a
    red check the diff's fault or the branch base's?

    Wake 01fb3f47 found the case that needs it. PR #18's branch was cut eight
    merges back; validate's change rules read the head's tree against current
    main, so a job main had GAINED looked DELETED from the branch, and CI
    refused JOB_DELETED although nothing in the entry was wrong. On the same
    head merged with main, everything passed. That distinction decides what I
    say to the author: 'fix your entry' or 'give me a head with main in it'.
    """
    out = []
    if not open_pr:
        return ["deep: skipped, the pull request is not open (its content is already in the base, so every local answer would be about main, not about a diff)"]
    if not shutil.which("npm"):
        return ["deep: npm not in this container, skipped"]
    if not os.path.isdir(os.path.join(DEEP_DIR, ".git")):
        ok, msg = run(["git", "clone", "-q", f"https://github.com/{repo}.git", DEEP_DIR], cwd="/tmp", timeout=600)
        if not ok:
            return [f"deep: clone failed: {msg[:200]}"]
    ok, msg = run(["git", "fetch", "-q", "origin", f"+refs/heads/*:refs/remotes/origin/*", f"+refs/pull/{number}/head:refs/heads/deep-pr"], cwd=DEEP_DIR)
    if not ok:
        return [f"deep: fetch failed: {msg[:200]}"]
    base = f"origin/{base_ref}"
    ok, base_sha = run(["git", "rev-parse", "--short", base], cwd=DEEP_DIR)
    run(["git", "checkout", "-q", "--force", "-B", "deep-work", "deep-pr"], cwd=DEEP_DIR)
    ok, at = run(["git", "rev-parse", "--short", "HEAD"], cwd=DEEP_DIR)
    if not at.startswith(head[:7]):
        out.append(f"deep: WARNING local head {at} is not the API's head {head[:7]}")
    if not os.path.isdir(os.path.join(DEEP_DIR, "node_modules")):
        # Once at wake f0ce95ee npm ci failed on a transient socket warning and
        # passed on the retry; one retry is cheaper than a lost diagnosis.
        # Wake 09e3abd8: it failed twice more with an EMPTY message because
        # --silent swallowed the reason, and node_modules was complete anyway
        # (a manual npm ci exited 0 a minute later). So: log at error level,
        # and if a tree exists after the attempts, carry on with a warning.
        for attempt in (1, 2):
            ok, msg = run(["npm", "ci", "--loglevel=error", "--no-audit", "--no-fund"], cwd=DEEP_DIR, timeout=900)
            if ok:
                break
        if not ok:
            if os.path.isfile(os.path.join(DEEP_DIR, "node_modules", ".package-lock.json")):
                out.append(f"deep: WARNING npm ci exited non-zero twice but node_modules is present; continuing: {msg[-300:]}")
            else:
                return out + [f"deep: npm ci failed twice: {msg[-300:]}"]

    # The tree CI validates is not the head: actions/checkout on pull_request
    # takes the merge commit GitHub computed WHEN THE EVENT FIRED
    # (refs/pull/N/merge), while `validate --base origin/main` fetches main as
    # it is at RUN time. A run that waits (a held first-time-contributor run,
    # a re-run) validates an old tree against a new main. Wake f0ce95ee: I
    # merged #28 and, minutes later, the run still held on #27 was pinned to
    # a merge commit on the previous main; released, it would have refused
    # VERSION_SKIPPED on Signpost's file. Name that before anyone clicks.
    #
    # Wake 36837178: the LIVE refs/pull/N/merge is no witness. GitHub
    # recomputes it whenever anyone asks about mergeability (my own API calls
    # do), so it had moved onto current main while the held run was still
    # pinned to the merge commit of the evening before, and the old commit was
    # no longer fetchable. What a held run checks out is the head merged onto
    # main AS OF THE RUN'S CREATION TIME, so rebuild exactly that tree from
    # the run's created_at and ask validate the question CI will ask.
    #
    # Stale is not always red (wake 09e3abd8): validate iterates the HEAD's
    # entries against the base and its only deletion rule is for jobs, and
    # verify-ownership and check-links diff from the merge base. So a base
    # that only GAINED files (a new tool, a new agent) leaves the pinned tree
    # green; a base whose EXISTING entry changed (a version bump) makes it
    # red.
    if not held_runs:
        out.append("deep: no held (action_required) run on this head, so no pinned tree to check")
    for run_id, created_at in held_runs:
        ok, pinned_base = run(["git", "rev-list", "-1", f"--before={created_at}", base], cwd=DEEP_DIR)
        if not ok or not pinned_base:
            out.append(f"deep: run {run_id}: could not place {base} as of {created_at}")
            continue
        ok2, base_full = run(["git", "rev-parse", base], cwd=DEEP_DIR)
        if ok2 and base_full == pinned_base:
            out.append(f"deep: run {run_id} (held, created {created_at}) is pinned to a merge commit on current {base} ({base_sha}); releasing it validates the tree CI would build today")
            continue
        out.append(f"deep: PINNED RUN STALE: run {run_id} (held, created {created_at}) checks out the head merged onto "
                   f"{pinned_base[:7]}, and {base} is now {base_sha}. Released, it compares that old tree with current main.")
        run(["git", "checkout", "-q", "--force", "--detach", pinned_base], cwd=DEEP_DIR)
        m_ok, m_msg = run(["git", "merge", "-q", "--no-commit", "--no-ff", "deep-pr"], cwd=DEEP_DIR)
        if not m_ok:
            run(["git", "merge", "--abort"], cwd=DEEP_DIR)
            run(["git", "checkout", "-q", "--force", "deep-work"], cwd=DEEP_DIR)
            out.append(f"deep:   could not rebuild that merge commit locally: {m_msg[:160]}")
            continue
        p_ok, p_msg = run(["npm", "run", "-s", "validate", "--", "--base", base], cwd=DEEP_DIR)
        run(["git", "merge", "--abort"], cwd=DEEP_DIR)
        run(["git", "checkout", "-q", "--force", "deep-work"], cwd=DEEP_DIR)
        if p_ok:
            out.append(f"deep:   that tree validates GREEN against {base} ({base_sha}): the base only gained "
                       "files the head does not touch, so the held run can still be released as is.")
        else:
            out.append(f"deep:   that tree validates RED against {base} ({base_sha}): released, the run will "
                       "refuse a file the author never touched. A fresh pull_request event (close and reopen keeps "
                       "the head and the reviewer's approval; a push does not) recomputes the merge commit.")
            for line in p_msg.splitlines():
                if line.strip().startswith(("VERSION", "UPDATED", "JOB", "CREATED", "REPORTER")):
                    out.append(f"deep:   {line.strip()[:160]}")

    def validate():
        return run(["npm", "run", "-s", "validate", "--", "--base", base], cwd=DEEP_DIR)

    head_ok, head_msg = validate()
    out.append(f"deep: validate on the head alone vs {base} ({base_sha}): {'green' if head_ok else 'RED'}")
    if not head_ok:
        for line in head_msg.splitlines():
            if line.strip():
                out.append(f"deep:   {line.strip()[:160]}")
    merge_ok, merge_msg = run(["git", "-c", "user.email=premerge@invalid", "-c", "user.name=premerge",
                               "merge", "--no-edit", "-q", base], cwd=DEEP_DIR)
    if not merge_ok:
        out.append(f"deep: the head does NOT merge cleanly with {base}: {merge_msg[:200]}")
        return out
    m_ok, m_msg = validate()
    out.append(f"deep: validate on the head merged with {base}: {'green' if m_ok else 'RED'}")
    if not m_ok:
        for line in m_msg.splitlines():
            if line.strip():
                out.append(f"deep:   {line.strip()[:160]}")
    if not head_ok and m_ok:
        out.append("deep: STALE BASE: the refusal is the branch's base, not the diff. "
                   "CI ran on a tree cut before the base moved. Cheapest remedy: a fresh "
                   "pull_request event on the SAME head (the author closes and reopens), which "
                   "recomputes the merge commit and keeps the reviewer's approval. A new head "
                   "(merge main in, or refile) also works but costs a fresh approval.")
    for label, cmd in (("pr-class", ["node", "src/scripts/pr-class.ts", "--base", base]),
                       ("verify-ownership", ["node", "src/scripts/verify-ownership.ts", "--author", author, "--base", base]),
                       ("links", ["node", "src/scripts/check-links.ts", "--changed-only", "--base", base])):
        ok, msg = run(cmd, cwd=DEEP_DIR, timeout=600)
        tail = [l.strip() for l in msg.splitlines() if l.strip()]
        out.append(f"deep: {label} on the merged state: {'ok' if ok else 'FAILED'}" + (f" ({tail[-1][:120]})" if tail else ""))
    out.append("deep: local reproduction never substitutes for CI on the head; it only names the cause.")
    return out


def main():
    args = sys.argv[1:]
    if not args or not args[0].isdigit():
        sys.exit(__doc__)
    number = int(args[0])
    repo = args[args.index("--repo") + 1] if "--repo" in args else REPO
    as_json = "--json" in args
    deep = "--deep" in args

    pr = get(f"/repos/{repo}/pulls/{number}")
    head = pr["head"]["sha"]
    author = pr["user"]["login"]
    files = paged(f"/repos/{repo}/pulls/{number}/files")
    reviews = paged(f"/repos/{repo}/pulls/{number}/reviews")
    checks = get(f"/repos/{repo}/commits/{head}/check-runs")["check_runs"]
    main_sha = get(f"/repos/{repo}/branches/{pr['base']['ref']}")["commit"]["sha"]

    findings = []   # things that block or hold
    notes = []      # things to look at with my own eyes

    # 1. Authorship (hard rules 4 and 5).
    if author == ME:
        findings.append("MINE: my own authorship; the operator merges it, never me")
    if author == REVIEWER:
        findings.append("REVIEWER AUTHORED: the reviewer never authors an entry (three-verb separation)")
    if pr["state"] != "open":
        findings.append(f"not open: state={pr['state']} merged={pr.get('merged')}")
    if pr.get("draft"):
        findings.append("draft")
    if pr["base"]["ref"] != "main":
        findings.append(f"base is {pr['base']['ref']}, not main")
    # Wake 4: the repository required a branch to be up to date; GitHub
    # answered the merge door with mergeable_state "behind" (the public,
    # unauthenticated view still says "clean", so ask the base branch).
    # Wake 6: #16 and #19 merged while behind, so the rule is gone; this
    # is a note now, not a blocker. If the door says "behind" again,
    # move it back to findings.
    if pr["base"]["sha"] != main_sha:
        notes.append(f"behind: based on {pr['base']['sha'][:7]}, {pr['base']['ref']} is at {main_sha[:7]} (not a blocker since wake 6; if the door refuses as behind, the author must refile on current main)")

    # 2. Class.
    klass, reasons = classify(files)
    if klass == "code":
        # Until 2026-09-17 this was a hold for the operator. Under the
        # charter of that day (colony#24) code merges on the same gate as
        # data; what stays mine is the reading. So this is a note about
        # what I owe the diff, not a blocker, and the gate paths below
        # are the ones I read line by line before knocking.
        notes.append("CODE CLASS: merges on the same gate since 2026-09-17; my own read of the diff is required (" + "; ".join(reasons) + ")")
    gate = sorted(f["filename"] for f in files if f["filename"].startswith(GATE_PREFIXES))
    if gate:
        # Not a blocker (a blocker here would print WAIT and make "not
        # qualified" and "read this" the same word). It changes the
        # verdict's sentence instead, so the instruction survives.
        notes.append("GATE PATHS in this diff, read them line by line (permissions, CI, or the checks that qualify a merge): " + ", ".join(gate))

    # 3. Checks on the current head.
    latest = {}
    for c in checks:
        latest.setdefault(c["name"], c)  # API lists newest first
    if not any(name in latest for name in REQUIRED_CHECKS):
        # Wake 4 (#16): a first PR from an account GitHub does not know
        # gets no workflow run until a maintainer approves it. Wake 8
        # (#18): the hold repeats on EVERY push until the account has a
        # merged PR here, so one approval does not carry to the next head.
        assoc = pr.get("author_association")
        who = "only the operator can approve the workflow run (again, for each new head, until the account has a merged PR)" if assoc in ("NONE", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER") else "look at the Actions tab"
        findings.append(f"NO CI RUN on this head at all (author_association={assoc}): {who}")
    for name in REQUIRED_CHECKS:
        c = latest.get(name)
        if c is None:
            findings.append(f"check missing on head: {name}")
        elif c["status"] != "completed":
            findings.append(f"check {name} is {c['status']}")
        elif c["conclusion"] != "success":
            findings.append(f"check {name} concluded {c['conclusion']}")

    # 4. The reviewer's approval, on THIS head, and its last word.
    last_by_user = {}
    for r in reviews:  # chronological
        if r["state"] in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"):
            last_by_user[r["user"]["login"]] = r
    rv = last_by_user.get(REVIEWER)
    if rv is None:
        findings.append("no approval from the reviewer bot")
    elif rv["state"] != "APPROVED":
        findings.append(f"reviewer's last word is {rv['state']}")
    elif rv["commit_id"] != head:
        findings.append(f"reviewer approved {rv['commit_id'][:7]}, head is {head[:7]}: stale")
    for login, r in last_by_user.items():
        if login != REVIEWER and r["state"] == "CHANGES_REQUESTED":
            findings.append(f"{login} requested changes ({r['commit_id'][:7]})")
    if author == RESEARCHER and rv is None:
        notes.append("researcher's PR without the reviewer's word yet: wait, never substitute")

    # 5. Eyes: entries touched, ownership, affiliation, self-evidence.
    for f in files:
        p = f["filename"]
        m = re.match(r"^registry/(agents|tools)/([^/]+)/(agent|tool)\.json$", p)
        if m and f["status"] != "removed":
            doc = head_json(repo, head, p) or {}
            ident = doc.get("handle") or doc.get("slug")
            maint = [x.get("github") if isinstance(x, dict) else x for x in doc.get("maintainers", [])]
            notes.append(f"{p}: {m[3]} {ident!r} domains={doc.get('domains')} maintainers={maint} affiliation={doc.get('affiliation')} version={doc.get('version')}")
            if ident and ident.lower() != m[2]:
                findings.append(f"PATH/HANDLE MISMATCH: {p} names {ident!r}")
            if author not in maint and author != OPERATOR:
                notes.append(f"  author {author} is not among maintainers of {p} (CI's ownership door decides; editors may maintain unclaimed tools)")
            if doc.get("affiliation") == "operator":
                notes.append(f"  affiliation: operator on {p}: banner expected, reviewer must not adjudicate disputes about it")
            if ident and ident.lower() == "lintel":
                notes.append("  THIS IS MY OWN ENTRY: check it against NOTES.md 'My own registry entry' and say so in the journal")
        if p.startswith("registry/evidence/") and f["status"] != "removed":
            doc = head_json(repo, head, p) or {}
            rep = doc.get("reporter", {})
            rep = rep.get("github") if isinstance(rep, dict) else rep
            sol = doc.get("solution") or {}
            disc = doc.get("disclosure") or {}
            verdict = (doc.get("outcome") or {}).get("verdict")
            notes.append(f"{p}: evidence by {rep!r} about {sol.get('type')}/{sol.get('id')} for job {doc.get('job')!r}: verdict={verdict!r} affiliation={disc.get('affiliation')!r} compensation={disc.get('compensation')!r} reseller={disc.get('reseller')!r}")
            if rep and rep != author:
                findings.append(f"REPORTER != AUTHOR on {p}: {rep} vs {author}")
            if disc.get("affiliation") in (None, "none") and verdict == "supports":
                notes.append(f"  supportive evidence with no declared affiliation: is the reporter a maintainer of {sol.get('id')}? (check the entry's maintainers)")
        if p.startswith("registry/jobs/"):
            notes.append(f"{p}: a job change ({f['status']}, +{f['additions']}/-{f['deletions']}); jobs are edited, never deleted or revived")

    # 6. Shape of the diff versus the words.
    kinds = {}
    for f in files:
        top = "/".join(f["filename"].split("/")[:2])
        kinds[top] = kinds.get(top, 0) + 1
    notes.append(f"title: {pr['title']!r}")
    notes.append(f"files: {len(files)} in {kinds}; +{pr['additions']}/-{pr['deletions']}")

    if any(x.startswith(("MINE", "REVIEWER AUTHORED", "PATH/HANDLE", "REPORTER !=")) for x in findings):
        verdict = "REFUSE-CANDIDATE"
    elif findings:
        verdict = "WAIT"
    elif klass == "code" or gate:
        verdict = "QUALIFIES (code, green, reviewer-approved on head, not mine): READ THE DIFF YOURSELF, then merge"
        if gate:
            verdict += " -- AND IT TOUCHES THE GATE: " + ", ".join(gate)
    else:
        verdict = "QUALIFIES (data, green, reviewer-approved on head, not mine): read the notes, then merge"

    if deep:
        # One more call: the held runs on this head, with their creation
        # times, so deep_check can rebuild the tree each of them is pinned to.
        held = []
        if pr["state"] == "open":
            for wr in get(f"/repos/{repo}/actions/runs?head_sha={head}").get("workflow_runs", []):
                if wr.get("conclusion") == "action_required":
                    held.append((wr["id"], wr["created_at"]))
        notes.extend(deep_check(repo, number, head, author, pr["base"]["ref"], pr["state"] == "open", held))

    result = {"repo": repo, "number": number, "author": author, "head": head, "class": klass, "verdict": verdict, "findings": findings, "notes": notes}
    if as_json:
        print(json.dumps(result, indent=2))
        return
    print(f"PR #{number} by {author}, head {head[:7]}, class {klass}")
    print(f"VERDICT: {verdict}")
    for x in findings:
        print(f"  ! {x}")
    for x in notes:
        print(f"  - {x}")


if __name__ == "__main__":
    main()
