#!/usr/bin/env python3
"""subjects.py - does a merge commit's subject describe the diff under it?

Why this exists (wake 22). `operon github merge` squashes, and a squash
commit's SUBJECT IS THE PULL REQUEST TITLE. A title written when the pull
request was opened, and then overtaken by a change made during review, becomes
permanent history at the moment I merge. Nothing in the registry's CI reads the
title, and nothing re-reads it at merge time except me.

The checkable form of the defect: a subject that names a job id, on a diff that
neither claims that job nor touches that job's file. That is the exact shape of
04d1d77 (#44, Linear, merged by me at wake 15): "claims eng.triage-issues with
its boundary stated" over a diff whose jobs array is [], because the claim came
out during review in the second commit of the same pull request.

It reports on history and, with --pr, on an open head before the door is
knocked on.

CONTROL, printed first and always. The instrument is fed two subjects it must
disagree about: one taken from a merge that really does add the job it names,
and one invented over the same diff naming a job that is certainly absent. If
it does not print OK for the first and MISS for the second, every line below it
is void and it stops.

    python3 tools/subjects.py [--clone DIR]
    python3 tools/subjects.py --pr 69 --title "..." [--clone DIR]
"""
import argparse
import json
import re
import subprocess
import sys

JOBID = re.compile(r"\b(?:eng|fin|hr|it|mkt|proc|sales|scm|cs|legal)\.[a-z0-9-]+\b")


def git(clone, *args):
    return subprocess.run(
        ["git", "-C", clone] + list(args), capture_output=True, text=True
    ).stdout


def verdict(clone, sha, subject, base=None):
    """Return (job id -> True/False) for every job id the subject names.

    True means the diff backs the subject up in one of three ways: it adds a
    structured claim on that job, it creates that job's file, or it edits that
    job's file. False means the subject names a job the diff does not touch at
    all, which is the defect.
    """
    ids = sorted({m.group(0) for m in JOBID.finditer(subject)})
    if not ids:
        return {}
    if base:
        diff = git(clone, "diff", "--unified=0", f"{base}...{sha}")
        files = git(clone, "diff", "--name-only", f"{base}...{sha}").split()
    else:
        diff = git(clone, "show", sha, "--format=", "--unified=0")
        files = git(clone, "show", sha, "--format=", "--name-only").split()
    added = "\n".join(l for l in diff.split("\n") if l.startswith("+"))
    edited = edited_claims(clone, sha, files, base)
    out = {}
    for jid in ids:
        claimed = re.search(r'"job"\s*:\s*"%s"' % re.escape(jid), added) is not None
        touched = any(f.endswith("/" + jid + ".json") for f in files)
        out[jid] = claimed or touched or jid in edited
    return out


def edited_claims(clone, sha, files, base=None):
    """Job ids whose claim OBJECT differs between base and head in a changed
    entry file. Wake 97c00daa: #284 filled `sources` on an existing claim and
    the only added lines were the URLs, so the job id sat on a context line
    and the subject read MISS although the diff was exactly what it said.
    An edit to any field of a claim touches that job."""
    parent = base if base else sha + "^"
    out = set()
    for f in files:
        if not (f.startswith("registry/tools/") or f.startswith("registry/agents/")):
            continue
        if not f.endswith(".json"):
            continue
        before = jobs_of(git(clone, "show", f"{parent}:{f}"))
        after = jobs_of(git(clone, "show", f"{sha}:{f}"))
        for jid in set(before) | set(after):
            if before.get(jid) != after.get(jid):
                out.add(jid)
    return out


def jobs_of(text):
    try:
        doc = json.loads(text)
    except (ValueError, TypeError):
        return {}
    jobs = doc.get("jobs") if isinstance(doc, dict) else None
    if not isinstance(jobs, list):
        return {}
    return {j.get("job"): json.dumps(j, sort_keys=True) for j in jobs if isinstance(j, dict) and j.get("job")}


def control(clone):
    """Falsify the instrument before believing a word of it."""
    ok = True
    # A merge whose subject names a job it really does add.
    truthful = "baad98d"
    if not git(clone, "cat-file", "-t", truthful).strip():
        print("CONTROL SKIPPED: %s not in this clone" % truthful)
        return True
    v = verdict(clone, truthful, git(clone, "log", "-1", "--format=%s", truthful))
    if not v or not all(v.values()):
        print("CONTROL FAILED: a subject known to be true read as a miss: %r" % v)
        ok = False
    # The same diff, with an invented subject naming a job it cannot contain.
    v2 = verdict(clone, truthful, "feat(registry): claims fin.file-and-remit-tax-returns")
    if v2.get("fin.file-and-remit-tax-returns") is not False:
        print("CONTROL FAILED: an invented subject read as backed: %r" % v2)
        ok = False
    if ok:
        print("CONTROL: a true subject reads OK and an invented one reads MISS")
    else:
        print("EVERY LINE BELOW IS VOID")
    return ok


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--clone", default="/tmp/lintel-queue-clone")
    ap.add_argument("--pr", type=int, help="check an open head instead of history")
    ap.add_argument("--title", help="the title that would become the subject")
    ap.add_argument("--base", default="origin/main")
    args = ap.parse_args()

    if not control(args.clone):
        sys.exit(2)
    print()

    if args.pr:
        if not args.title:
            sys.exit("--pr needs --title (the subject the squash would carry)")
        ref = "pr%d" % args.pr
        subprocess.run(
            ["git", "-C", args.clone, "fetch", "-q", "origin",
             "pull/%d/head:%s" % (args.pr, ref)],
            capture_output=True,
        )
        v = verdict(args.clone, ref, args.title, base=args.base)
        if not v:
            print("PR #%d: the title names no job id; nothing to check here" % args.pr)
            return
        for jid, good in sorted(v.items()):
            print("%s #%d %s" % ("OK  " if good else "MISS", args.pr, jid))
        if not all(v.values()):
            print("\nThe subject names a job the diff does not touch. A squash merge")
            print("makes that permanent and no door can edit it afterwards.")
        return

    rows = []
    log = git(args.clone, "log", "--format=%H\x01%s", args.base).strip().split("\n")
    for line in log:
        if "\x01" not in line:
            continue
        sha, subject = line.split("\x01", 1)
        for jid, good in sorted(verdict(args.clone, sha, subject).items()):
            rows.append((sha[:7], jid, good, subject))
    for sha, jid, good, subject in rows:
        print("%s %s %-42s | %s" % ("OK  " if good else "MISS", sha, jid, subject[:76]))
    misses = [r for r in rows if not r[2]]
    print("\n%d merged subjects name a job id; %d name one the diff never touches."
          % (len(rows), len(misses)))


if __name__ == "__main__":
    main()
