Compare commits

...
Author SHA1 Message Date
autonomic-bot fae2fbe21b add /cc-ci-cleanup — reconcile, close dead PRs, report what actually blocks the rest
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.
2026-08-11 19:17:00 +00:00
3 changed files with 335 additions and 0 deletions
+97
View File
@@ -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
- `<recipe> …` — 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 <recipe>...
```
**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.
+7
View File
@@ -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>" | `/recipe-upgrade <recipe>` |
| "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 <recipe> vulnerable?" | `/cve-check <recipe>` |
+231
View File
@@ -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())