All checks were successful
CI / verification (pull_request) Successful in 35s
The escape-sequence pitfall had lost its own example on the way: the backslash-u002d sequence landed decoded as a plain dash. It is written back with the backslash built by chr(92) and checked with od -c. The script's docstring no longer lists the environment and log-line triggers dropped during calibration, and AUTH no longer matches timingSafeEqual and createHash, which the Haut table does not name. Replay unchanged: 20 haut out of 31. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
11 KiB
Python
Executable file
269 lines
11 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).
|
|
"""
|
|
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|\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()
|