235 lines
8.9 KiB
Python
Executable File
235 lines
8.9 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")
|
|
PUBLIC_ENV = "/srv/cc-ci/cc-ci/.env.public"
|
|
NS = "recipe-maintainers"
|
|
|
|
|
|
def _env() -> dict:
|
|
e = {}
|
|
for path in (PUBLIC_ENV, TESTENV):
|
|
try:
|
|
lines = open(path)
|
|
except OSError:
|
|
continue
|
|
for ln in lines:
|
|
ln = ln.strip()
|
|
if "=" in ln and not ln.startswith("#"):
|
|
k, v = ln.split("=", 1)
|
|
e[k] = v.strip().strip('"').strip("'")
|
|
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())
|