mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
feat(ci): open or append a per-role issue when a role deploy fails on main
On a main run, report-main-failures parses the failed deploy jobs, downloads each role's rescue-diagnostics artifact, and opens a 'CI failure: <role>' issue (label ci-failure) carrying the run link, the artifact names, and the decisive rescue excerpt inline. An existing open issue for the role gets a comment instead. Mode emojis are resolved through the symbol glossary, not hardcoded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4181d2194d
commit
8b58b67121
5 files changed
+246
No files matched your search
@@ -57,6 +57,7 @@ permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
wait-fork-prereq-run:
|
||||
@@ -257,6 +258,26 @@ jobs:
|
||||
- build-ci-images
|
||||
secrets: inherit
|
||||
|
||||
report-main-failures:
|
||||
name: 🐛 Report main role failures
|
||||
needs:
|
||||
- test-deploy-single-node-priority
|
||||
- test-deploy-swarm-priority
|
||||
- test-deploy-single-node
|
||||
- test-deploy-swarm
|
||||
if: ${{ always() && github.ref == 'refs/heads/main' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- name: 🐛 Open or append issues for failed roles
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 -m cli.meta.ci.report_failures --run-id "${{ github.run_id }}" --repo "${{ github.repository }}"
|
||||
|
||||
done:
|
||||
name: 🏁 Finish pipeline
|
||||
needs:
|
||||
|
||||
@@ -39,6 +39,7 @@ permissions:
|
||||
packages: write
|
||||
security-events: write
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
ci-orchestrator:
|
||||
|
||||
@@ -20,6 +20,7 @@ permissions:
|
||||
packages: write
|
||||
actions: write
|
||||
security-events: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-main:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Open (or comment on) a 'CI failure: <role>' issue per role whose deploy failed, linking the run artifacts and inlining the decisive rescue excerpt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from utils.symbol_glossary import to_emoji, to_word
|
||||
|
||||
_MODES = ("swarm", "compose", "host")
|
||||
_ROLE_RE = re.compile(
|
||||
r"(" + "|".join(re.escape(to_emoji(m)) for m in _MODES) + r")️?\s+"
|
||||
r"((?:web-app|web-svc|svc|sys)-[a-z0-9-]+?)(?:\s+([0-9,]+))?\s*$"
|
||||
)
|
||||
_DECISIVE_FILES = ("error-context.md", "meta.txt", "containers.txt")
|
||||
_TITLE = "CI failure: {role}"
|
||||
_LABEL = "ci-failure"
|
||||
|
||||
|
||||
def failed_roles(jobs: list[dict]) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Map role -> [(mode, variant)] for every failed deploy job in *jobs*."""
|
||||
out: dict[str, list[tuple[str, str]]] = {}
|
||||
for job in jobs:
|
||||
if job.get("conclusion") not in ("failure", "timed_out"):
|
||||
continue
|
||||
match = _ROLE_RE.search(job.get("name", ""))
|
||||
if match is None:
|
||||
continue
|
||||
mode = to_word(match.group(1))
|
||||
role = match.group(2)
|
||||
variant = (match.group(3) or "").replace(",", "-")
|
||||
out.setdefault(role, []).append((mode, variant))
|
||||
return out
|
||||
|
||||
|
||||
def artifact_name(mode: str, role: str, variant: str) -> str:
|
||||
suffix = f"-{variant}" if variant else ""
|
||||
return f"rescue-diagnostics-{mode}-{role}{suffix}"
|
||||
|
||||
|
||||
def decisive_excerpt(rescue_dir: Path, *, max_lines: int = 40) -> str:
|
||||
"""First error-context / meta / containers file in *rescue_dir*, truncated."""
|
||||
for wanted in _DECISIVE_FILES:
|
||||
for path in sorted(rescue_dir.rglob(wanted)):
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
body = "\n".join(lines[:max_lines])
|
||||
if body.strip():
|
||||
more = "\n... (truncated)" if len(lines) > max_lines else ""
|
||||
return f"`{path.name}`:\n```\n{body}{more}\n```"
|
||||
return "_No decisive rescue file captured (container torn down before capture)._"
|
||||
|
||||
|
||||
def issue_body(
|
||||
role: str,
|
||||
failures: list[tuple[str, str]],
|
||||
*,
|
||||
run_url: str,
|
||||
excerpt: str,
|
||||
) -> str:
|
||||
rows = "\n".join(
|
||||
f"- `{mode}`"
|
||||
+ (f" variant `{variant}`" if variant else "")
|
||||
+ f" — artifact `{artifact_name(mode, role, variant)}`"
|
||||
for mode, variant in failures
|
||||
)
|
||||
return (
|
||||
f"Role **{role}** failed on `main`.\n\n"
|
||||
f"Run: {run_url}\n\n"
|
||||
f"Failed deploys:\n{rows}\n\n"
|
||||
f"Download the artifacts from the run page above.\n\n"
|
||||
f"{excerpt}\n"
|
||||
)
|
||||
|
||||
|
||||
def _gh(args: list[str]) -> str:
|
||||
return subprocess.run(
|
||||
["gh", *args], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
|
||||
|
||||
def _existing_issue(repo: str, role: str) -> int | None:
|
||||
out = _gh(
|
||||
["issue", "list", "--repo", repo, "--state", "open", "--label", _LABEL,
|
||||
"--search", _TITLE.format(role=role), "--json", "number,title"]
|
||||
)
|
||||
for issue in json.loads(out or "[]"):
|
||||
if issue.get("title") == _TITLE.format(role=role):
|
||||
return int(issue["number"])
|
||||
return None
|
||||
|
||||
|
||||
def report(run_id: str, repo: str) -> int:
|
||||
jobs = json.loads(
|
||||
_gh(["api", "--paginate",
|
||||
f"repos/{repo}/actions/runs/{run_id}/jobs", "--jq",
|
||||
"[.jobs[] | {name, conclusion}]"])
|
||||
or "[]"
|
||||
)
|
||||
roles = failed_roles(jobs)
|
||||
if not roles:
|
||||
print("No failed deploy roles.")
|
||||
return 0
|
||||
_gh(["label", "create", _LABEL, "--repo", repo, "--force",
|
||||
"--color", "d73a4a", "--description", "A role deploy failed on main"])
|
||||
run_url = f"https://github.com/{repo}/actions/runs/{run_id}"
|
||||
for role, failures in sorted(roles.items()):
|
||||
dest = Path(f"rescue-{role}")
|
||||
excerpt = _download_excerpt(repo, run_id, role, failures, dest)
|
||||
body = issue_body(role, failures, run_url=run_url, excerpt=excerpt)
|
||||
number = _existing_issue(repo, role)
|
||||
if number is None:
|
||||
_gh(["issue", "create", "--repo", repo, "--label", _LABEL,
|
||||
"--title", _TITLE.format(role=role), "--body", body])
|
||||
print(f"opened issue for {role}")
|
||||
else:
|
||||
_gh(["issue", "comment", str(number), "--repo", repo, "--body", body])
|
||||
print(f"commented on #{number} for {role}")
|
||||
return 0
|
||||
|
||||
|
||||
def _download_excerpt(
|
||||
repo: str, run_id: str, role: str, failures: list[tuple[str, str]], dest: Path
|
||||
) -> str:
|
||||
for mode, variant in failures:
|
||||
name = artifact_name(mode, role, variant)
|
||||
try:
|
||||
_gh(["run", "download", run_id, "--repo", repo, "-n", name, "-D",
|
||||
str(dest)])
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
return decisive_excerpt(dest) if dest.is_dir() else _NO_ARTIFACT
|
||||
|
||||
|
||||
_NO_ARTIFACT = "_No rescue-diagnostics artifact was uploaded for this role._"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Report main role failures.")
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--repo", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
return report(args.run_id, args.repo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from cli.meta.ci.report_failures import (
|
||||
artifact_name,
|
||||
decisive_excerpt,
|
||||
failed_roles,
|
||||
issue_body,
|
||||
)
|
||||
|
||||
|
||||
class TestReportFailures(unittest.TestCase):
|
||||
def test_failed_roles_parses_mode_role_variant(self) -> None:
|
||||
jobs = [
|
||||
{"name": "x / ⭐ Priority · swarm / 🐝 web-app-xwiki 0", "conclusion": "failure"},
|
||||
{"name": "y / 🐳 web-app-openproject 0,1,2", "conclusion": "failure"},
|
||||
{"name": "🐝 web-svc-logout 1", "conclusion": "timed_out"},
|
||||
{"name": "z / 💻 Host / 💻 sys-front-proxy", "conclusion": "failure"},
|
||||
{"name": "🐝 web-app-nextcloud 0", "conclusion": "success"},
|
||||
{"name": "🧹 Lint", "conclusion": "failure"},
|
||||
]
|
||||
self.assertEqual(
|
||||
failed_roles(jobs),
|
||||
{
|
||||
"web-app-xwiki": [("swarm", "0")],
|
||||
"web-app-openproject": [("compose", "0-1-2")],
|
||||
"web-svc-logout": [("swarm", "1")],
|
||||
"sys-front-proxy": [("host", "")],
|
||||
},
|
||||
)
|
||||
|
||||
def test_artifact_name(self) -> None:
|
||||
self.assertEqual(
|
||||
artifact_name("swarm", "web-app-xwiki", "0"),
|
||||
"rescue-diagnostics-swarm-web-app-xwiki-0",
|
||||
)
|
||||
self.assertEqual(
|
||||
artifact_name("compose", "web-app-x", ""),
|
||||
"rescue-diagnostics-compose-web-app-x",
|
||||
)
|
||||
|
||||
def test_issue_body_lists_failures_and_run(self) -> None:
|
||||
body = issue_body(
|
||||
"web-app-xwiki",
|
||||
[("swarm", "0"), ("compose", "")],
|
||||
run_url="https://gh/run/1",
|
||||
excerpt="EXCERPT",
|
||||
)
|
||||
self.assertIn("web-app-xwiki", body)
|
||||
self.assertIn("https://gh/run/1", body)
|
||||
self.assertIn("rescue-diagnostics-swarm-web-app-xwiki-0", body)
|
||||
self.assertIn("EXCERPT", body)
|
||||
|
||||
def test_decisive_excerpt_prefers_error_context(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
(root / "sub").mkdir()
|
||||
(root / "sub" / "error-context.md").write_text("502 backend down\n")
|
||||
self.assertIn("502 backend down", decisive_excerpt(root))
|
||||
|
||||
def test_decisive_excerpt_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
self.assertIn("No decisive", decisive_excerpt(Path(td)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user