vps-health-api/scripts/review-levels.py
le king fu 1dff0c805e
All checks were successful
CI / verification (pull_request) Successful in 39s
CI / verification (push) Successful in 47s
docs(review-levels): name the package.json block blind spot (#63)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:24:12 -04:00

290 lines
12 KiB
Python
Executable file

#!/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, the temp-file sweep, body caps, shell commands, outbound calls,
require.main), 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). package.json
knows the block of a package line only when the block's opening line is in
the hunk: a bump deep inside a large "dependencies" block reads as normal,
where the table says high (impossible today, the runtime has none).
"""
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", ".claude/hooks/**", ".mcp.json",
]
SHIPPED = ["{index,metrics,claude-usage,claude-usage-history,backups}.js"]
GUARDS = ["__tests__/{auth,writes}.test.js"]
LOW = ["**/*.md", "LICENSE"]
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|\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):
# anything but "version" and a bump of a package listed in the same block;
# any line of "dependencies" counts, since the runtime has none
before, after = {}, {}
for hunk in re.split(r"(?m)^@@", block)[1:]:
section = None
for l in hunk.split("\n")[1:]:
if l.startswith(("+++", "---")) or l[:1] not in " +-":
continue
m = re.match(r'^[ +-]\s*"([^"]+)":\s*\{', l)
if m:
section = m.group(1)
if l[:1] == " " or not meaningful(l[1:]):
if re.match(r"^[ +-]\s*\}", l):
section = None
continue
if section == "dependencies":
return True
m = re.match(r'^([+-])\s*"([^"]+)":\s*(.*?),?\s*$', l)
if not m:
return True
if m.group(3) == "{":
continue
(before if m.group(1) == "-" else after)[(section, m.group(2))] = m.group(3)
if re.match(r"^[+-]\s*\}", l):
section = None
for key in set(before) | set(after):
if key[1] == "version" and key[0] is None:
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()