diff --git a/.claude/skills/cc-ci-cleanup/SKILL.md b/.claude/skills/cc-ci-cleanup/SKILL.md new file mode 100644 index 0000000..1049683 --- /dev/null +++ b/.claude/skills/cc-ci-cleanup/SKILL.md @@ -0,0 +1,97 @@ +--- +name: cc-ci-cleanup +description: Tidy the fleet's open recipe PRs. Reconciles every mirror from TRUE upstream first (which alone closes PRs upstream already merged), then surveys every open PR deterministically, CLOSES the ones that can no longer be merged or were never meant to be (CI sweep artifacts, obsolete bumps, superseded duplicates) with a reason, and reports prioritised action items for the ones that SHOULD merge — what specifically is blocking each. NEVER merges a recipe PR. Invoke as /cc-ci-cleanup [recipe ...] [--dry-run]. +--- + +# cc-ci-cleanup + +Open recipe PRs accumulate and rot. Some were never meant to merge (CI sweep artifacts), some were +overtaken (upstream merged the same change, or a newer PR supersedes them), and some genuinely should +land but are quietly blocked. Left alone the list becomes noise, and a real CVE fix hides in it. + +This skill separates those three, acts on the first two, and hands you a short list for the third. + +**Boundaries.** It **CLOSES** irrelevant PRs and **NEVER MERGES** any recipe PR — those change what +deploys on other people's infrastructure, so a human merges them (see AGENTS.md). Closing is the only +write it performs, always with a comment saying why. + +## Arguments +- ` …` — limit to these recipes (else every recipe in `cc-ci-plan/used-recipes.md`). +- `--dry-run` — classify and report, close nothing. + +## Procedure + +### 1. Reconcile every mirror from TRUE upstream — MANDATORY, FIRST +``` +cc-ci-plan/reconcile-upstream.sh --all # or: reconcile-upstream.sh ... +``` +**Do not skip this and do not reorder it.** Every signal in step 2 is measured against the mirror's +`main`; against a stale mirror they are all wrong. This step also does a chunk of the cleanup by +itself — it closes any PR whose changes upstream has already merged. + +> On the first real run (2026-08-11) this alone closed **three** PRs that looked pending and were +> already merged upstream: discourse #6 (carrying **140 CVEs**), keycloak #6 (**12 CVEs**), n8n #5. +> All three had been reported to the operator as outstanding work. mailu #6 went the same way earlier +> the same day. Reconciling is not hygiene, it is how you avoid recommending work that is already done. + +### 2. Survey every open PR (deterministic — no judgement yet) +``` +python3 cc-ci-plan/pr-survey.py [recipe ...] # add --json for the raw facts +``` +Per PR it measures: `behind_main`, `ahead`, `mergeable`, `diff_files`, the images it **adds**, which +of those are **already in main**, `obsolete`, the newest `!testme` verdict + build, `branch_kind`, +and age/idle days. It decides nothing — that is this skill's job. + +### 3. Classify + +**CLOSE — cannot merge, or was never meant to.** Each needs a *positive* reason, not an absence: + +| signal | why it is closeable | +|---|---| +| `branch_kind: ci-artifact` (`ci/*`) | regall/cfold sweeps and `!testme` probes — harness artifacts, never intended to merge | +| `obsolete: true` | every image it adds is **already pinned in main** — it has nothing left to contribute | +| superseded | a newer PR on the same recipe makes the same bump (name both numbers in the comment) | +| `diff_files: 0` | genuinely empty diff — nothing to merge | + +**NEVER close on:** +- `DIFF-UNREADABLE` — the diff could not be fetched, which is NOT an empty diff. gitea #4 reads that + way (force-pushed branch) while being a verified, green, needed fix. +- any field that came back `null`/unknown. +- a PR that carries a **CVE fix** and is the only thing carrying it, even if it looks stale — report it + instead. Losing a security fix to tidiness is far worse than a long PR list. +- `--dry-run`. + +**NEEDS WORK — should merge, something blocks it.** Give the *specific* next action: +| signal | action item | +|---|---| +| `mergeable: false` | conflicts — rebase the branch on `main` and re-run `!testme` | +| `behind_main > 0` | out of date — rebase, then re-verify (a green from before main moved proves nothing) | +| `ci: failed` | diagnose via `/ci-test-review`; classify recipe-bug vs stale test | +| `ci: never-run` | run `!testme` | +| blocked on the operator | say exactly what is needed (a secret, an upstream release, a decision) | + +**READY — green, current, no conflicts.** Action item is simply: review and merge. + +### 4. Close the CLOSE set (skip entirely under `--dry-run`) +Comment first, then close. The comment must say **which signal** made it closeable and **what to do +if that is wrong** ("reopen if …"), so a wrong call is cheap to undo. Never close silently. + +### 5. Report +Order by what deserves attention, not by recipe name: + +1. **CVE-carrying PRs that should merge** — most severe first, with the CVE ids. +2. Other **READY** PRs (green + current). +3. **NEEDS WORK**, each with its one specific action. +4. **CLOSED this run**, with the reason for each. +5. Anything **deliberately left alone** despite looking stale, and why. + +End with a one-line summary: `N open → C closed, R ready to merge, W need work`. + +## Guardrails +- **Never merge a recipe PR.** Create/verify/close only; the operator merges. +- **Reconcile first, always.** Judging a PR against a stale mirror is how you close good work or + recommend work that is already done. +- **Close only on a positive signal**, never on "looks old". Age alone is not a reason — several + 60-day-old PRs here are green and mergeable. +- **Never close a lone CVE fix.** Report it, however stale. +- Every close gets a comment with its reason and a reopen hint. diff --git a/.opencode/skills/help/SKILL.md b/.opencode/skills/help/SKILL.md index aef266b..6469e8e 100644 --- a/.opencode/skills/help/SKILL.md +++ b/.opencode/skills/help/SKILL.md @@ -31,6 +31,12 @@ Then present the roster grouped as follows, and close with the situation guide. PR). `--with-tests` also fixes that recipe's stale test. - **/recipe-report** — (re)generate the weekly report page for report.ci.commoninternet.net. +**Keeping the PR list honest** +- **/cc-ci-cleanup** — reconciles every mirror from true upstream (which alone closes PRs upstream + already merged), then closes the open recipe PRs that can no longer merge or were never meant to + (CI sweep artifacts, obsolete bumps, superseded duplicates) and reports what is actually blocking + the ones that should land. Never merges. + **Security (CVEs)** - **/cve-check** — fleet-wide CVE sweep with **no upgrading**: for every recipe, work out what upgrade is available (per image, sidecars included), scan it for CVEs, and publish a CVE report. @@ -81,6 +87,7 @@ ARM skills never touch cc-ci infra. After a submodule bump run `scripts/gen-ccte | "Run the weekly upgrades now" | `/upgrade-all` (or `systemctl start cc-ci-upgrade-all.service`) | | "Upgrade just " | `/recipe-upgrade ` | | "The report site is stale/missing a week" | `/recipe-report` | +| "The open PR list is a mess / what should I merge?" | `/cc-ci-cleanup` | | "What CVEs are we exposed to right now?" | `/cve-check` (read-only, no PRs) | | "A CVE just dropped — check and patch it" | `/cve-check-and-upgrade` (add `--min-severity high` to skip the noise) | | "Is vulnerable?" | `/cve-check ` | diff --git a/cc-ci-plan/pr-survey.py b/cc-ci-plan/pr-survey.py new file mode 100755 index 0000000..ed8c850 --- /dev/null +++ b/cc-ci-plan/pr-survey.py @@ -0,0 +1,231 @@ +#!/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())