feat(ci-artifacts,merge-fix): cluster failed CI jobs and search refs through scripts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-10-30 16:54:37 +01:00
1 parent 93b316999c
commit 6544f01eef
10 files changed
+340 -31

No files matched your search

+9 -14
View File
@@ -12,23 +12,18 @@ failure without downloading its artifacts is guessing with extra steps.
## Getting the log ## Getting the log
`gh run view --log` refuses to serve anything while a run is unfinished: For a GitHub Actions run, one call does it:
run <id> is still in progress; logs will be available when it is complete ${CLAUDE_SKILL_DIR}/scripts/ci_failures.py <RUN_ID> [--repo <owner>/<repo>] [--artifacts]
That is not a dead end — individual *jobs* that have already finished are It prints how far HEAD moved since the run's commit, saves every finished
served by the API: failed job's log to `/tmp/ci-logs-<run>/` (colour stripped, even while the run
is still in progress), and clusters the jobs by the normalised error line that
ended them. Jobs still running are listed, not fetched: their API log can stop hours
early. The clustering is a heuristic; check each boundary against the logs.
gh api /repos/<owner>/<repo>/actions/jobs/<JOB_ID>/logs Grep the saved files repeatedly. Re-fetching a log to find the line you just
lost costs minutes; grepping the saved copy costs milliseconds.
Enumerate the failing jobs and their ids first:
gh run view <RUN_ID> --repo <owner>/<repo> --json jobs \
--jq '.jobs[] | select(.conclusion=="failure") | "\(.databaseId)\t\(.name)"'
Save every log to a file (`/tmp/ci-logs-<run>/<job>.log`) and grep the file
repeatedly. Re-fetching a log to find the line you just lost costs minutes;
grepping the saved copy costs milliseconds.
## Getting the artifacts ## Getting the artifacts
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Fetch the failed jobs of a GitHub Actions run and cluster them by error line.
Usage:
ci_failures.py RUN_ID [--repo OWNER/REPO] [--out DIR] [--artifacts]
Prints how far HEAD moved since the run's commit, saves each finished failed
job's log (colour stripped) under DIR (default /tmp/ci-logs-RUN_ID), and
groups the jobs by the normalised error line that ended them (the last one). Jobs still running are
listed but not fetched: their API log can stop hours early. The clustering is
a heuristic; confirm a cluster's boundary against the logs.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
FAILED = {"failure", "cancelled", "timed_out"}
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?")
GENERIC = re.compile(r"Process completed with exit code|failed=0\b|FAILED - RETRYING")
ANNOTATION = re.compile(r"##\[error\]")
FATAL = re.compile(r"fatal: \[|FAILED!|Traceback|AssertionError|panic:")
SIGNAL = re.compile(r"\b(ERROR|Error|FAILED|FAIL|fatal|Exception|failed)\b")
TASK = re.compile(r"^TASK \[")
REFUSAL = "terminal escape sequences"
def run(*cmd: str) -> subprocess.CompletedProcess:
return subprocess.run(list(cmd), capture_output=True, text=True, check=False)
def clean(line: str) -> str:
return STAMP.sub("", ANSI.sub("", line)).strip()
def normalise(line: str) -> str:
line = re.sub(r"\b[0-9a-f]{7,}\b", "<hex>", line)
line = re.sub(r"\d+", "<n>", line)
return line[:200]
def output_lines(text: str) -> list[tuple[str, str]]:
kept, echo, task = [], False, ""
for raw in text.splitlines():
line = clean(raw)
if line.startswith("##[group]Run "):
echo = True
elif line.startswith("##[endgroup]"):
echo = False
elif TASK.match(line):
task = line
elif line and not echo and not GENERIC.search(line):
kept.append((task, line))
return kept
def signature(text: str) -> str:
lines = output_lines(text)
for pattern in (ANNOTATION, FATAL, SIGNAL):
for task, line in reversed(lines):
if pattern.search(line):
found = ANNOTATION.sub("", line).strip()
return normalise(f"{task} {found}" if pattern is FATAL and task else found)
return normalise(lines[-1][1]) if lines else "<empty log>"
def cluster(jobs: list[tuple[str, str]]) -> dict[str, list[str]]:
groups: dict[str, list[str]] = {}
for job, text in jobs:
groups.setdefault(signature(text), []).append(job)
return dict(sorted(groups.items(), key=lambda item: -len(item[1])))
def behind(sha: str) -> str:
if run("git", "cat-file", "-e", f"{sha}^{{commit}}").returncode:
return "behind=unknown (run commit not in this clone)"
count = run("git", "rev-list", "--count", f"{sha}..HEAD").stdout.strip()
moved = {p.split("/")[0] for p in run("git", "diff", "--name-only", f"{sha}..HEAD").stdout.split()}
return f"behind={count} moved={','.join(sorted(moved)) or '-'}"
def main() -> int:
parser = argparse.ArgumentParser(prog="ci_failures")
parser.add_argument("run_id")
parser.add_argument("--repo")
parser.add_argument("--out")
parser.add_argument("--artifacts", action="store_true")
args = parser.parse_args()
repo = args.repo or run("gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner").stdout.strip()
if not repo:
parser.error("no --repo and gh cannot resolve the current repository")
meta = run("gh", "api", f"/repos/{repo}/actions/runs/{args.run_id}")
# per_page=100 on a large matrix answers HTTP 502; 30 per page goes through.
listing = run("gh", "api", "--paginate", f"/repos/{repo}/actions/runs/{args.run_id}/jobs?per_page=30",
"--jq", ".jobs[] | {id, name, status, conclusion}")
if meta.returncode or listing.returncode:
print((meta.stderr or listing.stderr).strip(), file=sys.stderr)
return 1
data = json.loads(meta.stdout)
jobs = [json.loads(line) for line in listing.stdout.splitlines() if line]
out = Path(args.out or f"/tmp/ci-logs-{args.run_id}")
out.mkdir(parents=True, exist_ok=True)
print(f"run={args.run_id} repo={repo} head={data['head_sha'][:10]} status={data['status']} "
f"jobs={len(jobs)} {behind(data['head_sha'])}")
running = [j["name"] for j in jobs if j["status"] != "completed"]
if running:
print(f"running ({len(running)}, not fetched): {', '.join(running)}")
fetched, missing = [], []
for job in jobs:
if job["status"] != "completed" or job["conclusion"] not in FAILED:
continue
log = run("gh", "api", "--allow-escape-sequences", f"/repos/{repo}/actions/jobs/{job['id']}/logs")
if log.returncode or REFUSAL in log.stdout[:200]:
missing.append(f"{job['conclusion']}\t{job['name']}\t{log.stderr.strip()[:80]}")
continue
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", job["name"])[:80]
path = out / f"{job['id']}-{slug}.log"
path.write_text("\n".join(ANSI.sub("", line) for line in log.stdout.splitlines()), encoding="utf-8")
text = "##[error]cancelled" if job["conclusion"] == "cancelled" else log.stdout
fetched.append((f"{job['conclusion']}\t{job['name']}\t{path}", text))
if missing:
(out / "no-log.txt").write_text("\n".join(missing) + "\n", encoding="utf-8")
counts = {c: sum(m.startswith(c) for m in missing) for c in FAILED}
summary = ", ".join(f"{n} {c}" for c, n in sorted(counts.items()) if n)
print(f"no log for {len(missing)} jobs ({summary}; never started or expired): {out / 'no-log.txt'}")
for number, (sig, jobs) in enumerate(cluster(fetched).items(), 1):
print(f"cluster {number} ({len(jobs)} jobs): {sig}")
for job in jobs[:3]:
print(f" {job}")
if len(jobs) > 3:
members = out / f"cluster-{number}.txt"
members.write_text("\n".join(jobs) + "\n", encoding="utf-8")
print(f" ... +{len(jobs) - 3} more: {members}")
if args.artifacts:
listing = run("gh", "api", f"/repos/{repo}/actions/runs/{args.run_id}/artifacts", "--paginate",
"--jq", '.artifacts[] | "\\(.name)\\t\\(.size_in_bytes)"')
print("artifacts:")
print(listing.stdout.rstrip() or " none")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -9,8 +9,8 @@ description: >
--- ---
1. `git fetch --all --prune --tags`, then hunt the staged fix across every 1. `git fetch --all --prune --tags`, then hunt the staged fix across every
ref with the merge-fix search steps (`git log --all` by `--grep`, by ref with the merge-fix skill's `find_fix.py` (by `--grep`, by `-S`, by
`-S<symbol>`, by path). Describe the staged bundle in one line first so a `--path`). Describe the staged bundle in one line first so a
wrong search intent is visible. Only the search runs here — never wrong search intent is visible. Only the search runs here — never
cherry-pick or merge, and never touch the staging index or the tree. cherry-pick or merge, and never touch the staging index or the tree.
2. A similar fix on another branch is a stop: run the dialectic skill on the 2. A similar fix on another branch is a stop: run the dialectic skill on the
+2 -2
View File
@@ -111,8 +111,8 @@ records the result.
The one protocol for every exchange between agents; `coordinate`, `triage`, The one protocol for every exchange between agents; `coordinate`, `triage`,
`tune`, `funnel` and `cleanup` use it. `tune`, `funnel` and `cleanup` use it.
1. **Look**: `council.py list` and the refs (`git log --all` by message, by 1. **Look**: `council.py list` and the refs (the merge-fix skill's
`-S`, by path). A claim or a committed fix answers the question without a `find_fix.py`). A claim or a committed fix answers the question without a
message. message.
2. **Claim** the office (Election). 2. **Claim** the office (Election).
3. **One message per peer** (in Claude Code: `ListAgents`, then 3. **One message per peer** (in Claude Code: `ListAgents`, then
+8 -11
View File
@@ -35,21 +35,18 @@ one line so a wrong reading is visible before the search.
## 4. Search every ref ## 4. Search every ref
Run all three; they find different things: Search by keyword, by literal and by path in one call; they find different
things:
```bash ```bash
git log --all --oneline --regexp-ignore-case --grep='<keyword>' ${CLAUDE_SKILL_DIR}/scripts/find_fix.py --grep '<keyword>' -S '<symbol or literal>' --path '<path or glob>'
git log --all --oneline -S'<symbol or literal>'
git log --all --oneline -- '<path or glob>'
``` ```
Then reduce the hits: It drops every hit already in HEAD (`in_head` counts them; all in HEAD means
the fix is already in, say so and stop) and prints the rest with their
- Drop anything already merged: `git merge-base --is-ancestor <sha> HEAD` branches and files. Then read the actual change: `git show --stat <sha>` and
exits 0 → the fix is already in, say so and stop. the diff. A matching commit subject is a lead, not evidence, and `-S` also
- Locate the rest: `git branch -a --contains <sha>`. matches the commit that removed the literal; the diff decides.
- Read the actual change: `git show --stat <sha>` and the diff. A matching
commit subject is a lead, not evidence; the diff decides.
## 5. Present the candidates ## 5. Present the candidates
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Find commits on any ref that match a fix, skipping those already in HEAD.
Usage:
find_fix.py [--grep KEYWORD]... [-S LITERAL]... [--path PATH]... [--fetch]
Prints one line per candidate: sha, up to three branches, subject, files
touched. -S also matches commits that removed the literal; read the diff.
Run it inside the repository; it never checks anything out.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
LIMIT = 30
def git(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], capture_output=True, text=True, check=False)
def search(args: argparse.Namespace) -> list[str]:
queries = [["--regexp-ignore-case", f"--grep={k}"] for k in args.grep]
queries += [[f"-S{s}"] for s in args.S]
queries += [["--", p] for p in args.path]
found: list[str] = []
for query in queries:
for sha in git("log", "--all", "--format=%H", *query).stdout.split():
if sha not in found:
found.append(sha)
return found
def describe(sha: str) -> str:
subject = git("log", "-1", "--format=%h %s", sha).stdout.strip()
branches = git("branch", "-a", "--contains", sha, "--format=%(refname:short)").stdout.split()
files = [f for f in git("show", "--name-only", "--format=", sha).stdout.split("\n") if f]
shown = ",".join(branches[:3]) + (f",+{len(branches) - 3}" if len(branches) > 3 else "")
return f"{subject}\t{shown or '-'}\t{len(files)} files: {','.join(files[:5])}"
def main() -> int:
parser = argparse.ArgumentParser(prog="find_fix")
parser.add_argument("--grep", action="append", default=[])
parser.add_argument("-S", action="append", default=[])
parser.add_argument("--path", action="append", default=[])
parser.add_argument("--fetch", action="store_true")
args = parser.parse_args()
if not (args.grep or args.S or args.path):
parser.error("give at least one --grep, -S or --path")
if args.fetch:
git("fetch", "--all", "--prune", "--tags")
hits = search(args)
outside = [sha for sha in hits if git("merge-base", "--is-ancestor", sha, "HEAD").returncode]
print(f"hits={len(hits)} in_head={len(hits) - len(outside)} candidates={len(outside)}")
for sha in outside[:LIMIT]:
print(describe(sha))
if len(outside) > LIMIT:
print(f"... {len(outside) - LIMIT} more, narrow the search")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -1
View File
@@ -27,7 +27,8 @@ gave one): after each observation, schedule the next wake-up yourself and pass
the same `/observe` prompt back so the next firing repeats the watch. the same `/observe` prompt back so the next firing repeats the watch.
1. **Poll the target.** Pull its current state (job list, check conclusions, the 1. **Poll the target.** Pull its current state (job list, check conclusions, the
log's tail). Enumerate the failures exactly as the `triage` skill's step 1 log's tail); for a GitHub Actions run, the ci-artifacts skill's
`ci_failures.py <run>`. Enumerate the failures exactly as the `triage` skill's step 1
does: failure, cancelled, timed-out; a downstream aggregate that fails only does: failure, cancelled, timed-out; a downstream aggregate that fails only
because an upstream did is not its own failure. because an upstream did is not its own failure.
2. **Diff against the baseline.** A failure is *new* only if it is not in the 2. **Diff against the baseline.** A failure is *new* only if it is not in the
+4
View File
@@ -15,6 +15,10 @@ root cause is proven.
## Procedure ## Procedure
For a GitHub Actions run, the ci-artifacts skill's `ci_failures.py <run>`
does the mechanics of steps 0 to 2 in one call: distance to HEAD, the failed
jobs' logs, and a first clustering. The judgement in those steps stays yours.
0. **Check the run against HEAD before anything else.** A CI run is a snapshot 0. **Check the run against HEAD before anything else.** A CI run is a snapshot
of one commit, and the branch has moved since. Run of one commit, and the branch has moved since. Run
`git log --oneline <run-sha>..HEAD | wc -l` as the first command of the `git log --oneline <run-sha>..HEAD | wc -l` as the first command of the
+1 -1
View File
@@ -93,7 +93,7 @@ Discard a before/after pair, not just its noise, when:
## Before you optimize ## Before you optimize
- **Search every branch** (`git log --all -S<symbol>`, by path) for a change - **Search every branch** (the merge-fix skill's `find_fix.py -S <symbol> --path <path>`) for a change
that already removes or reshapes the step. A fix on a sibling branch can make that already removes or reshapes the step. A fix on a sibling branch can make
the whole candidate worth zero. the whole candidate worth zero.
- A step that is long by design (a backup and restore drill, an end-to-end - A step that is long by design (a backup and restore drill, an end-to-end
+96
View File
@@ -0,0 +1,96 @@
"""Exercise ci_failures clustering and find_fix's ref search."""
from __future__ import annotations
import importlib.util
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CI_FAILURES = REPO_ROOT / "skills" / "ci-artifacts" / "scripts" / "ci_failures.py"
FIND_FIX = REPO_ROOT / "skills" / "merge-fix" / "scripts" / "find_fix.py"
def _load(path: Path):
spec = importlib.util.spec_from_file_location(path.stem, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestCiFailures(unittest.TestCase):
def setUp(self):
self.ci = _load(CI_FAILURES)
def test_jobs_differing_only_in_identifiers_share_a_cluster(self):
one = "2026-10-30T10:00:00.1Z setup ok\n##[error]Timeout on host 10.0.0.12 after 300s\n"
two = "\x1b[31m2026-10-30T11:00:00.1Z ##[error]Timeout on host 10.0.0.47 after 299s\x1b[0m\n"
other = "PLAY RECAP failed=0\nfatal: unable to reach registry\n"
groups = self.ci.cluster([("a", one), ("b", two), ("c", other)])
self.assertEqual(list(groups.values()), [["a", "b"], ["c"]])
def test_command_echo_and_retries_are_skipped_and_task_named(self):
text = (
'##[group]Run make deploy || echo "failed"\n'
"make deploy\n"
"##[endgroup]\n"
"FAILED - RETRYING: wait (3 retries left)\n"
"TASK [web-app-y : Probe] ****\n"
"Traceback (most recent call last): recovered on retry\n"
"TASK [web-app-x : Wait for health] ****\n"
'fatal: [localhost]: FAILED! => {"msg": "timeout 120"}\n'
"##[error]Process completed with exit code 2.\n"
)
self.assertEqual(
self.ci.signature(text),
'TASK [web-app-x : Wait for health] **** fatal: [localhost]: FAILED! => {"msg": "timeout <n>"}',
)
def test_generic_exit_code_line_is_not_the_signature(self):
text = "Error: disk full\n##[error]Process completed with exit code 1.\n"
self.assertEqual(self.ci.signature(text), "Error: disk full")
@unittest.skipUnless(shutil.which("git"), "git not installed")
class TestFindFix(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.repo = Path(self.tmp.name)
self.env = {
"PATH": "/usr/bin:/bin",
"HOME": str(self.repo),
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
self._git("init", "-q", "-b", "main")
self._git("commit", "-q", "--allow-empty", "-m", "fix: probe in head")
self._git("switch", "-q", "-c", "side")
self._git("commit", "-q", "--allow-empty", "-m", "fix: probe on side")
self._git("switch", "-q", "main")
def _git(self, *args: str) -> None:
subprocess.run(["git", *args], check=True, capture_output=True, env=self.env, cwd=self.repo)
def test_only_commits_outside_head_are_candidates(self):
result = subprocess.run(
[sys.executable, str(FIND_FIX), "--grep", "probe"],
capture_output=True,
text=True,
check=True,
env=self.env,
cwd=self.repo,
)
self.assertIn("hits=2 in_head=1 candidates=1", result.stdout)
self.assertIn("fix: probe on side", result.stdout)
self.assertNotIn("probe in head", result.stdout)
if __name__ == "__main__":
unittest.main()