25 open recipe PRs had accumulated, and the list had stopped being readable: CI sweep artifacts that were never meant to merge sat next to genuine CVE fixes, and three PRs the operator had been told were outstanding were in fact already merged upstream (discourse #6 with 140 CVEs, keycloak #6 with 12, n8n #5) — visible only once the mirrors were reconciled. The skill: reconcile every mirror from true upstream FIRST (that step alone closed those three), survey every open PR deterministically, close the ones that cannot merge or were never meant to, and report prioritised action items — CVE-carrying first — for the ones that should land. It never merges a recipe PR. pr-survey.py gathers the facts and decides nothing: behind_main, mergeable, diff_files, which images the PR adds vs which are already pinned in main, the newest !testme verdict, branch kind, age. One correctness detail worth the extra state: a FAILED diff fetch is reported as unknown, never as an empty diff. gitea #4 reads that way (force-pushed branch) while being a verified green fix, and 'empty diff' is a close signal — so the tool says DIFF-UNREADABLE(do not close on this) instead.
232 lines
8.8 KiB
Python
Executable File
232 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""pr-survey — deterministic facts about every open recipe PR, for /cc-ci-cleanup to judge.
|
|
|
|
Open recipe PRs rot in specific, detectable ways. This gathers the evidence; it does NOT decide
|
|
anything — closing a PR is a judgement the skill makes, with these facts in hand.
|
|
|
|
RUN `reconcile-upstream.sh --all` FIRST. Every signal below is measured against the mirror's `main`,
|
|
and an unreconciled mirror makes all of them wrong: on 2026-08-11 three PRs (discourse #6 carrying
|
|
140 CVEs, keycloak #6 carrying 12, n8n #5) looked pending against a stale mirror while upstream had
|
|
already merged them. This tool refuses to guess about that — see `reconciled_recently`.
|
|
|
|
Per PR:
|
|
behind_main commits on main not in the branch — the "out of date" measure
|
|
ahead commits on the branch not on main
|
|
mergeable gitea's own verdict (false = conflicts, needs a rebase)
|
|
diff_files files the PR touches (0 = nothing left to merge)
|
|
adds_images the `+ image:` lines it introduces
|
|
already_in_main those `+ image:` lines ALREADY present in main -> the bump landed another way
|
|
obsolete true when every image it adds is already in main (nothing to contribute)
|
|
ci newest `!testme` verdict + build number parsed from the PR comments
|
|
branch_kind upgrade / fix / ci-artifact (`ci/*` sweep + probe branches) / other
|
|
age_days, stale_days (since last update)
|
|
|
|
pr-survey.py [recipe ...] [--json]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
USED_RECIPES = os.path.join(HERE, "used-recipes.md")
|
|
TESTENV = os.environ.get("CCCI_TESTENV", "/srv/cc-ci/.testenv")
|
|
NS = "recipe-maintainers"
|
|
|
|
|
|
def _env() -> dict:
|
|
e = {}
|
|
try:
|
|
for ln in open(TESTENV):
|
|
ln = ln.strip()
|
|
if "=" in ln and not ln.startswith("#"):
|
|
k, v = ln.split("=", 1)
|
|
e[k] = v.strip().strip('"').strip("'")
|
|
except OSError:
|
|
pass
|
|
return e
|
|
|
|
|
|
ENV = _env()
|
|
GITEA = os.environ.get("GITEA_URL") or ENV.get("GITEA_URL", "git.autonomic.zone")
|
|
_AUTH = base64.b64encode(
|
|
f"{os.environ.get('GITEA_USERNAME') or ENV.get('GITEA_USERNAME','')}:"
|
|
f"{os.environ.get('GITEA_PASSWORD') or ENV.get('GITEA_PASSWORD','')}".encode()
|
|
).decode()
|
|
|
|
|
|
def _get(path: str, raw: bool = False):
|
|
req = urllib.request.Request(
|
|
f"https://{GITEA}{path}",
|
|
headers={"Authorization": f"Basic {_AUTH}", "User-Agent": "cc-ci-pr-survey"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
body = r.read()
|
|
return body.decode(errors="replace") if raw else json.loads(body)
|
|
|
|
|
|
def _days(iso: str | None) -> int | None:
|
|
if not iso:
|
|
return None
|
|
try:
|
|
d = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return (datetime.now(timezone.utc) - d).days
|
|
|
|
|
|
def _branch_kind(ref: str) -> str:
|
|
if ref.startswith("ci/"):
|
|
return "ci-artifact" # regall/cfold sweeps + testme probes; never meant to merge
|
|
if ref.startswith("upgrade"):
|
|
return "upgrade"
|
|
if re.match(r"^(fix|feat|chore|revert)", ref):
|
|
return "fix"
|
|
return "other"
|
|
|
|
|
|
def _main_images(recipe: str) -> set[str]:
|
|
"""Image refs pinned on the mirror's main — the baseline a PR is judged against."""
|
|
out = set()
|
|
for f in ("compose.yml",):
|
|
try:
|
|
txt = _get(f"/{NS}/{recipe}/raw/branch/main/{f}", raw=True)
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
for m in re.finditer(r"^\s*image:\s*[\"']?([^\"'\s]+)", txt, re.M):
|
|
out.add(m.group(1))
|
|
return out
|
|
|
|
|
|
def _ci_verdict(recipe: str, number: int) -> dict:
|
|
"""Newest cc-ci !testme outcome recorded on the PR."""
|
|
try:
|
|
cs = _get(f"/api/v1/repos/{NS}/{recipe}/issues/{number}/comments?limit=100")
|
|
except Exception: # noqa: BLE001
|
|
return {"verdict": "unknown", "build": None}
|
|
for c in reversed(cs):
|
|
b = c.get("body") or ""
|
|
if "cc-ci:testme" not in b:
|
|
continue
|
|
m = re.search(r"/cc-ci/(\d+)", b)
|
|
if "✅" in b or "passed" in b:
|
|
return {"verdict": "passed", "build": m.group(1) if m else None}
|
|
if "❌" in b or "failure" in b:
|
|
return {"verdict": "failed", "build": m.group(1) if m else None}
|
|
if "⏳" in b or "in progress" in b:
|
|
return {"verdict": "running", "build": m.group(1) if m else None}
|
|
return {"verdict": "never-run", "build": None}
|
|
|
|
|
|
def survey_pr(recipe: str, pr: dict, main_images: set[str]) -> dict:
|
|
n = pr["number"]
|
|
head = pr["head"]["ref"]
|
|
row = {
|
|
"recipe": recipe, "number": n, "title": pr.get("title", ""), "head": head,
|
|
"url": pr.get("html_url"), "branch_kind": _branch_kind(head),
|
|
"age_days": _days(pr.get("created_at")), "stale_days": _days(pr.get("updated_at")),
|
|
"mergeable": pr.get("mergeable"),
|
|
}
|
|
try:
|
|
row["behind_main"] = _get(
|
|
f"/api/v1/repos/{NS}/{recipe}/compare/{urllib.parse.quote(head, safe='')}...main"
|
|
).get("total_commits", 0)
|
|
row["ahead"] = _get(
|
|
f"/api/v1/repos/{NS}/{recipe}/compare/main...{urllib.parse.quote(head, safe='')}"
|
|
).get("total_commits", 0)
|
|
except Exception: # noqa: BLE001
|
|
row["behind_main"], row["ahead"] = None, None
|
|
# A FAILED diff fetch must never look like an empty diff: gitea#4 404s on .diff (force-pushed
|
|
# branch) and would otherwise be flagged EMPTY-DIFF and closed — while being a verified, green,
|
|
# needed fix. Unknown is its own state.
|
|
diff = None
|
|
try:
|
|
body = _get(f"/{NS}/{recipe}/pulls/{n}.diff", raw=True)
|
|
if body.lstrip().startswith(("diff --git", "From ")) or not body.strip():
|
|
diff = body
|
|
except Exception: # noqa: BLE001
|
|
diff = None
|
|
row["diff_files"] = None if diff is None else len(re.findall(r"^diff --git ", diff, re.M))
|
|
adds = re.findall(r"^\+\s*image:\s*[\"']?([^\"'\s]+)", diff or "", re.M)
|
|
row["adds_images"] = sorted(set(adds))
|
|
row["already_in_main"] = sorted({i for i in set(adds) if i in main_images})
|
|
# Nothing left to contribute: it touches files but every image it introduces is already pinned.
|
|
# Only claim obsolete when the diff was actually READ. No diff, no verdict.
|
|
row["obsolete"] = diff is not None and bool(adds) and set(adds).issubset(main_images)
|
|
row["ci"] = _ci_verdict(recipe, n)
|
|
return row
|
|
|
|
|
|
def all_recipes() -> list[str]:
|
|
out = []
|
|
for ln in open(USED_RECIPES):
|
|
p = ln.split()
|
|
if len(p) >= 2 and not ln.startswith(("#", "`")) and p[1] in ("weekly", "external"):
|
|
out.append(p[0])
|
|
return out
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("recipes", nargs="*")
|
|
ap.add_argument("--json", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
rows = []
|
|
for r in (a.recipes or all_recipes()):
|
|
try:
|
|
prs = _get(f"/api/v1/repos/{NS}/{r}/pulls?state=open&limit=50")
|
|
except urllib.error.HTTPError as e:
|
|
rows.append({"recipe": r, "error": f"HTTP {e.code}"})
|
|
continue
|
|
if not prs:
|
|
continue
|
|
mi = _main_images(r)
|
|
for pr in prs:
|
|
rows.append(survey_pr(r, pr, mi))
|
|
|
|
if a.json:
|
|
print(json.dumps(rows, indent=2))
|
|
return 0
|
|
|
|
print(f"{len(rows)} open PR(s)\n")
|
|
for x in sorted(rows, key=lambda z: (z.get("recipe", ""), z.get("number", 0))):
|
|
if x.get("error"):
|
|
print(f" {x['recipe']}: {x['error']}")
|
|
continue
|
|
flags = []
|
|
if x["obsolete"]:
|
|
flags.append("OBSOLETE(images already in main)")
|
|
if x["branch_kind"] == "ci-artifact":
|
|
flags.append("CI-ARTIFACT")
|
|
if x["diff_files"] == 0:
|
|
flags.append("EMPTY-DIFF")
|
|
if x["diff_files"] is None:
|
|
flags.append("DIFF-UNREADABLE(do not close on this)")
|
|
if x["mergeable"] is False:
|
|
flags.append("CONFLICTS")
|
|
if (x["behind_main"] or 0) > 0:
|
|
flags.append(f"BEHIND-{x['behind_main']}")
|
|
print(f" {x['recipe']}#{x['number']:<3} {x['title'][:52]}")
|
|
print(f" {x['branch_kind']:12} age={x['age_days']}d idle={x['stale_days']}d "
|
|
f"ci={x['ci']['verdict']}({x['ci']['build'] or '-'}) files={x['diff_files'] if x['diff_files'] is not None else '?'}")
|
|
if x["adds_images"]:
|
|
print(f" adds: {', '.join(i.split('/')[-1] for i in x['adds_images'][:4])}")
|
|
if flags:
|
|
print(f" >> {' | '.join(flags)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|