#!/usr/bin/env python3 """Replay the review levels of REVIEW.md on the pull requests that landed (#63). Usage: python3 scripts/review-levels.py [N ...] (default: every PR of LANDED) python3 scripts/review-levels.py --check (no network) The first form reads the Forgejo API (token in $FORGEJO_TOKEN, else ~/.forgejo-token; read-only calls) and prints each PR with its level and the files that set it, then the distribution. --check compares the patterns of this script with the first column of the Haut table of REVIEW.md and exits 1 if they differ: a PR that changes the Niveaux section updates this script in the same diff. Adapted from simpl-resultat/scripts/review-levels.py. The file list comes from the PR's .diff, never from /pulls/{n}/files: that route answers 500 on a PR whose base branch is gone, which is every link of a stack merged by fast-forward. Three PRs (EMPTY_DIFF) have an empty .diff and are read from the local clone: run the script from the repository. The content conditions of the Haut table are approximated from the diff: index.js routing and auth, side effects of the shipped modules (disk writes, environment, body caps, shell commands, outbound calls, require.main, log lines), guard tests (an existing line changed or removed), package.json (anything but a version), package-lock.json (a package added or removed, an origin changed), .gitignore (a line removed). """ import itertools import json import os import re import subprocess import sys import urllib.request API = "https://git.lacompagniemaximus.com/api/v1/repos/maximus/vps-health-api" REVIEW_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "REVIEW.md") # Every PR whose head reached main, frozen by number. Forgejo flags only part # of them as merged: a stack merged by fast-forward (#18-#23, #31, #34, #37) # shows as closed. #32 (a red-CI demo) never landed. LANDED = [2, 5, 6, 8, 9, 18, 19, 20, 21, 22, 23, 26, 28, 30, 31, 34, 37, 39, 42, 43, 44, 45, 47, 51, 52, 53, 56, 57, 59, 60, 62] # Three PRs whose .diff comes back empty: once fast-forwarded into main, their # merge_base equals their head. Each is one commit, read from the local clone. EMPTY_DIFF = { 18: ("39a55fad93414b8e0049724f351710bfe1eb7a77", "79cb813767cfb53282d5fe6f386af3d740bf7f7e"), 31: ("2b35b78a0beb04dc9ae92c13e42b9ff09fd2d147", "4e7879da6ca5163d37d11b9c5803cf2bed640b59"), 34: ("4e7879da6ca5163d37d11b9c5803cf2bed640b59", "156ca818ca37441a2166d112251529ac32783afc"), } # REVIEW.md, table Haut: rows without a condition. HIGH_ALWAYS = [ "agent/pg-backup-pull/**", "agent/claude-usage/{limits,push-claude-usage}.js", "agent/push-metrics.js", "agent/**/*.sh", "Dockerfile", ".forgejo/workflows/**", "vitest.config.js", "scripts/boot-from-copy.sh", ".claude/settings.json", ".mcp.json", ] SHIPPED = ["{index,metrics,claude-usage,claude-usage-history,backups}.js"] GUARDS = ["__tests__/{auth,writes}.test.js"] LOW = ["**/*.md", "docs/**", "LICENSE", "reports/**"] NEVER_LOW = ["CLAUDE.md", "REVIEW.md", "agent/README.md", ".claude/**"] AUTH = re.compile(r"\b(ROUTES|TOKEN_KINDS|resolveRoute|checkAuth|tokenMatches|logRejection|" r"safeLogValue|handler|timingSafeEqual|createHash|\w*_TOKEN)\b") EFFECTS = re.compile( r"\b(writeFile\w*|writeSync|createWriteStream|appendFile\w*|rename\w*|mkdir\w*|unlink\w*|" r"rmSync|rmdir\w*|chmod\w*|chown\w*|copyFile\w*|symlink\w*|truncate\w*|writeJsonAtomic|" r"writeHostSnapshot|sweepStaleTempFiles|TEMP_FILE_RE|readBody|\w*MAX_BODY_BYTES|" r"child_process|execSync|execFile\w*|spawn\w*|fetch|https?\.(request|get))\b" r"|require\.main") def token(): return os.environ.get("FORGEJO_TOKEN") or open(os.path.expanduser("~/.forgejo-token")).read().strip() def get(path, raw=False): req = urllib.request.Request(API + path, headers={"Authorization": "token " + token()}) data = urllib.request.urlopen(req).read() return data if raw else json.loads(data) def expand_braces(pat): m = re.search(r"\{([^{}]*)\}", pat) if not m: return [pat] return list(itertools.chain.from_iterable( expand_braces(pat[:m.start()] + alt + pat[m.end():]) for alt in m.group(1).split(","))) def glob_re(pat): # '**/' spans zero or more directories, '*' and '?' never cross '/' i, out = 0, "" while i < len(pat): if pat.startswith("**/", i): out += "(?:.*/)?"; i += 3; continue if pat.startswith("**", i): out += ".*"; i += 2; continue c = pat[i] out += "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c) i += 1 return re.compile("^" + out + "$") def match(pats, path): # '{a,b}' alternates, wildcards allowed inside return any(glob_re(p).match(path) for pat in pats for p in expand_braces(pat)) def parse_diff(diff): """[{filename, previous_filename, status, block}] from a git diff.""" files = [] for block in re.split(r"(?m)^diff --git ", diff)[1:]: head = block.split("\n@@", 1)[0] m = re.match(r"a/(\S+) b/(\S+)", block) old, new = m.group(1), m.group(2) status = "changed" if re.search(r"(?m)^new file mode", head): status = "added" elif re.search(r"(?m)^deleted file mode", head): status = "deleted" elif re.search(r"(?m)^rename from", head) or old != new: status = "renamed" files.append({"filename": new, "previous_filename": old if old != new else None, "status": status, "block": block}) return files def meaningful(text): t = text.strip() return t != "" and not t.startswith(("#", "//", "*", "/*")) def changed(block): return [l for l in block.split("\n") if l[:1] in "+-" and not l.startswith(("+++", "---")) and meaningful(l[1:])] def touches(block, pattern): # a hunk whose enclosing function (the @@ context) or a changed line matches for hunk in re.split(r"(?m)^(?=@@)", block)[1:]: if pattern.search(hunk.split("\n", 1)[0]) or any(pattern.search(l) for l in changed(hunk)): return True return False def package_json_changes(block): # any key/value change except "version" and a bump of a package already listed before, after = {}, {} for l in changed(block): m = re.match(r'^([+-])\s*"([^"]+)":\s*(.*?),?\s*$', l) if not m: return True (before if m.group(1) == "-" else after)[m.group(2)] = m.group(3) for key in set(before) | set(after): if key == "version": continue if key in before and key in after and all(re.match(r'^"[\^~]?\d', v) for v in (before[key], after[key])): continue return True return False def origin_of(url): m = re.match(r"^([a-z+]+)://([^/]+)", url) return m.group(1) + "://" + m.group(2) if m else url def lock_adds_or_removes(block): if any(re.match(r'^[+-]\s+"node_modules/[^"]+": \{', l) for l in block.split("\n")): return True for hunk in re.split(r"(?m)^@@", block)[1:]: hl = hunk.split("\n") before, after = set(), set() for l in hl: m = re.match(r'^([+-])\s+"resolved": "([^"]+)"', l) if m: (before if m.group(1) == "-" else after).add(origin_of(m.group(2))) if after - before: return True if any(re.match(r'^\+\s+"integrity": ', l) for l in hl) and not any(re.match(r'^\+\s+"version": ', l) for l in hl): return True return False def review_level(files): triggers = [] for f in files: b = f["block"] for path in filter(None, {f["filename"], f["previous_filename"]}): if match(HIGH_ALWAYS, path): triggers.append(path) if path == "index.js" and touches(b, AUTH): triggers.append(path + " (routing or auth)") if match(SHIPPED, path) and (f["status"] != "changed" or touches(b, EFFECTS)): triggers.append(path + " (side effect)") if match(GUARDS, path) and [l for l in changed(b) if l.startswith("-")]: triggers.append(path + " (existing line changed)") if path == "package.json" and package_json_changes(b): triggers.append(path) if path == "package-lock.json" and lock_adds_or_removes(b): triggers.append(path) if path == ".gitignore" and [l for l in changed(b) if l.startswith("-")]: triggers.append(path + " (line removed)") if path == "REVIEW.md": triggers.append(path + " (check the « Niveaux » section by hand)") parts = path.split("/") new_hidden = f["status"] == "added" and any( x.startswith(".") for x in parts[:-1]) and parts[0] not in (".forgejo", ".claude") if (f["status"] != "changed" and len(parts) == 1 and not path.endswith(".md")) or new_hidden: triggers.append(path + " (" + f["status"] + " at top level)") paths = [f["filename"] for f in files] if triggers: return "haut", sorted(set(triggers)) if all(match(LOW, p) and not match(NEVER_LOW, p) for p in paths): return "bas", [] return "normal", [] CONDITIONAL = { "index.js", "{index,metrics,claude-usage,claude-usage-history,backups}.js", "__tests__/{auth,writes}.test.js", "package.json", "package-lock.json", ".gitignore", "REVIEW.md", } def check(): """Compare this script's patterns with the first column of the Haut table.""" text = open(REVIEW_MD, encoding="utf-8").read() table = text.split("### Haut", 1)[1].split("\n\n", 2)[1] documented = set() for row in table.split("\n"): if not row.startswith("| ") or row.startswith("| Motif") or row.startswith("| fichier à la racine"): continue documented.update(re.findall(r"`([^`]+)`", row.split(" | ")[0])) coded = set(HIGH_ALWAYS) | CONDITIONAL for label, extra in (("REVIEW.md only", documented - coded), ("script only", coded - documented)): for pattern in sorted(extra): print(f"{label}: {pattern}") if documented != coded: return 1 print(f"ok: {len(coded)} patterns, identical to the Haut table of REVIEW.md") return 0 def main(): if sys.argv[1:] == ["--check"]: sys.exit(check()) numbers = [int(a) for a in sys.argv[1:]] or LANDED rows = [] for n in numbers: if n in EMPTY_DIFF: diff = subprocess.run(["git", "diff", *EMPTY_DIFF[n]], capture_output=True, text=True, check=True).stdout else: diff = get(f"/pulls/{n}.diff", raw=True).decode("utf-8", "replace") files = parse_diff(diff) if not files: sys.exit(f"#{n}: empty diff, the level would read as bas") level, triggers = review_level(files) rows.append((n, level)) print(f"#{n}\t{level}\t{'; '.join(triggers)}") counts = {lvl: sum(1 for r in rows if r[1] == lvl) for lvl in ("bas", "normal", "haut")} print(f"{len(rows)} PRs: {counts} -> haut {100 * counts['haut'] / len(rows):.1f} %") if __name__ == "__main__": main()