ci-guard-check 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ci_guard/__init__.py +3 -0
- ci_guard/__main__.py +6 -0
- ci_guard/_providers/__init__.py +0 -0
- ci_guard/_providers/github.py +105 -0
- ci_guard/actions.py +219 -0
- ci_guard/classify.py +156 -0
- ci_guard/cli.py +200 -0
- ci_guard/config.py +91 -0
- ci_guard/gate.py +128 -0
- ci_guard/init.py +145 -0
- ci_guard/ledger.py +320 -0
- ci_guard/watch.py +551 -0
- ci_guard_check-0.5.0.dist-info/METADATA +644 -0
- ci_guard_check-0.5.0.dist-info/RECORD +17 -0
- ci_guard_check-0.5.0.dist-info/WHEEL +4 -0
- ci_guard_check-0.5.0.dist-info/entry_points.txt +2 -0
- ci_guard_check-0.5.0.dist-info/licenses/LICENSE +21 -0
ci_guard/__init__.py
ADDED
ci_guard/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""GitHub provider — thin wrapper over the gh CLI.
|
|
2
|
+
|
|
3
|
+
All GitHub-specific I/O is isolated here to ease future provider swaps (v2.0).
|
|
4
|
+
Callers should import from ci_guard.watch, not directly from this module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def gh(*args: str, check: bool = True) -> str:
|
|
17
|
+
"""Run a gh command and return stdout, or exit on failure."""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["gh", *args],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=check,
|
|
24
|
+
)
|
|
25
|
+
return result.stdout
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
sys.stderr.write(
|
|
28
|
+
"ci-guard: 'gh' CLI not found. Install from https://cli.github.com.\n"
|
|
29
|
+
)
|
|
30
|
+
sys.exit(2)
|
|
31
|
+
except subprocess.CalledProcessError as e:
|
|
32
|
+
sys.stderr.write(f"ci-guard: gh failed: {e.stderr}\n")
|
|
33
|
+
if check:
|
|
34
|
+
sys.exit(2)
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def gh_returncode(*args: str) -> int:
|
|
39
|
+
try:
|
|
40
|
+
return subprocess.run(["gh", *args], capture_output=True, text=True).returncode
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
sys.stderr.write("ci-guard: 'gh' CLI not found. Install from https://cli.github.com.\n")
|
|
43
|
+
sys.exit(2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_pr(arg: str) -> int:
|
|
47
|
+
if arg == "auto":
|
|
48
|
+
out = gh("pr", "view", "--json", "number")
|
|
49
|
+
return int(json.loads(out)["number"])
|
|
50
|
+
if arg.isdigit():
|
|
51
|
+
return int(arg)
|
|
52
|
+
parts = [p for p in arg.split("/") if p]
|
|
53
|
+
if "pull" in parts:
|
|
54
|
+
idx = parts.index("pull")
|
|
55
|
+
if idx + 1 < len(parts):
|
|
56
|
+
return int(parts[idx + 1])
|
|
57
|
+
raise ValueError(f"Could not resolve PR from: {arg}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fetch_pr_state(pr: int) -> dict:
|
|
61
|
+
fields = ",".join([
|
|
62
|
+
"number", "state", "headRefOid", "mergeable", "mergeStateStatus",
|
|
63
|
+
"headRefName", "baseRefName",
|
|
64
|
+
])
|
|
65
|
+
return json.loads(gh("pr", "view", str(pr), "--json", fields))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fetch_checks(pr: int) -> list[dict]:
|
|
69
|
+
out = gh("pr", "checks", str(pr), "--json",
|
|
70
|
+
"name,workflow,state,bucket,startedAt,completedAt,link")
|
|
71
|
+
return json.loads(out) if out.strip() else []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fetch_run_jobs(run_id: str) -> dict:
|
|
75
|
+
return json.loads(gh(
|
|
76
|
+
"run", "view", run_id,
|
|
77
|
+
"--json", "jobs,conclusion,status,name,workflowName,headSha,createdAt,updatedAt",
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fetch_failed_log(run_id: str, max_chars: int = 20000) -> str:
|
|
82
|
+
raw = gh("run", "view", run_id, "--log-failed", check=False)
|
|
83
|
+
if len(raw) > max_chars:
|
|
84
|
+
return raw[:max_chars // 2] + "\n...[truncated]...\n" + raw[-max_chars // 2:]
|
|
85
|
+
return raw
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def normalize_check(c: dict) -> tuple[str, Optional[str], Optional[int]]:
|
|
89
|
+
"""Map gh pr checks fields to (status, conclusion, duration_seconds)."""
|
|
90
|
+
bucket = (c.get("bucket") or "").lower()
|
|
91
|
+
status = (
|
|
92
|
+
"completed" if bucket in {"pass", "fail", "cancel", "skipping"}
|
|
93
|
+
else (c.get("state") or "").lower() or "in_progress"
|
|
94
|
+
)
|
|
95
|
+
conclusion = {"pass": "success", "fail": "failure",
|
|
96
|
+
"cancel": "cancelled", "skipping": "skipped"}.get(bucket)
|
|
97
|
+
duration_seconds = None
|
|
98
|
+
if c.get("startedAt") and c.get("completedAt"):
|
|
99
|
+
try:
|
|
100
|
+
s = datetime.fromisoformat(c["startedAt"].replace("Z", "+00:00"))
|
|
101
|
+
e = datetime.fromisoformat(c["completedAt"].replace("Z", "+00:00"))
|
|
102
|
+
duration_seconds = int((e - s).total_seconds())
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
return status, conclusion, duration_seconds
|
ci_guard/actions.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Consume ci-guard watch JSONL and execute action directives.
|
|
2
|
+
|
|
3
|
+
Pipe ci-guard watch output into this module:
|
|
4
|
+
ci-guard watch --pr auto | ci-guard run-actions
|
|
5
|
+
|
|
6
|
+
Exit codes:
|
|
7
|
+
0 — pr_merged or pr_closed
|
|
8
|
+
2 — needs_help
|
|
9
|
+
3 — budget_exhausted
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
_EXIT_CODES: dict[str, int] = {
|
|
20
|
+
"pr_merged": 0,
|
|
21
|
+
"pr_closed": 0,
|
|
22
|
+
"needs_help": 2,
|
|
23
|
+
"budget_exhausted": 3,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_TERMINAL_EMOJI: dict[str, str] = {
|
|
27
|
+
"pr_merged": "✅",
|
|
28
|
+
"pr_closed": "🔒",
|
|
29
|
+
"needs_help": "🚨",
|
|
30
|
+
"budget_exhausted": "⚠️",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _annotation(level: str, message: str) -> None:
|
|
35
|
+
print(f"::{level}::{message}", flush=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _post_pr_comment(pr_number: int, body: str) -> None:
|
|
39
|
+
if not os.environ.get("GH_TOKEN"):
|
|
40
|
+
return
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["gh", "pr", "comment", str(pr_number), "--body", body],
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_step_summary(text: str) -> None:
|
|
48
|
+
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
49
|
+
if path:
|
|
50
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
51
|
+
fh.write(text + "\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_report(snap: dict) -> str:
|
|
55
|
+
pr = snap.get("pr_number", "?")
|
|
56
|
+
sha = snap.get("head_sha_short", "?")
|
|
57
|
+
terminal = snap.get("terminal") or "—"
|
|
58
|
+
cost = snap.get("cost_summary", {})
|
|
59
|
+
checks = snap.get("checks", [])
|
|
60
|
+
qc = snap.get("quarantine_candidates", [])
|
|
61
|
+
|
|
62
|
+
failed = [
|
|
63
|
+
c for c in checks
|
|
64
|
+
if c.get("conclusion") in {"failure", "cancelled", "timed_out"}
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
retries_used = cost.get("retries_used_pr", 0)
|
|
68
|
+
retries_max = cost.get("retries_max_pr", "?")
|
|
69
|
+
minutes = cost.get("minutes_spent", 0)
|
|
70
|
+
minutes_max = cost.get("minutes_max", "?")
|
|
71
|
+
|
|
72
|
+
blocked_failures = [
|
|
73
|
+
c for c in failed
|
|
74
|
+
if c.get("classification", {}).get("category") in {"branch_failure", "unknown"}
|
|
75
|
+
or c.get("classification", {}).get("category") not in {"infra_flake", "test_flake", "dependency_failure"}
|
|
76
|
+
]
|
|
77
|
+
retries_avoided = cost.get("retries_avoided_pr")
|
|
78
|
+
if retries_avoided is None or retries_avoided == 0:
|
|
79
|
+
retries_avoided = len(blocked_failures)
|
|
80
|
+
|
|
81
|
+
minutes_saved = cost.get("minutes_saved")
|
|
82
|
+
if minutes_saved is None or minutes_saved == 0:
|
|
83
|
+
minutes_saved = sum(max(1, round((c.get("duration_seconds") or 300) / 60)) for c in blocked_failures)
|
|
84
|
+
|
|
85
|
+
emoji = _TERMINAL_EMOJI.get(terminal, "")
|
|
86
|
+
|
|
87
|
+
lines: list[str] = [
|
|
88
|
+
"<!-- ci-guard-report -->",
|
|
89
|
+
f"## 🛡️ **ci-guard** — PR #{pr} | `{sha}`\n",
|
|
90
|
+
"### 💰 Savings & Efficiency Summary",
|
|
91
|
+
"| Metric | Result | Context |",
|
|
92
|
+
"|:---|:---:|:---|",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
if minutes_saved > 0 or retries_avoided > 0:
|
|
96
|
+
lines.append(f"| ⏱️ **CI Minutes Saved** | **~{minutes_saved} min** | Avoided running doomed reruns |")
|
|
97
|
+
lines.append(f"| 🚫 **Blind Retries Avoided** | **{retries_avoided}** | Blocked retry on code/branch errors |")
|
|
98
|
+
else:
|
|
99
|
+
lines.append("| ⏱️ **CI Minutes Saved** | **Clean Run** | 0 wasted retries |")
|
|
100
|
+
lines.append("| 🚫 **Blind Retries Avoided** | **0** | No failure interventions needed |")
|
|
101
|
+
|
|
102
|
+
lines.append(f"| 🔄 **Flaky Retries Executed** | **{retries_used}/{retries_max}** | Safe, isolated reruns |")
|
|
103
|
+
lines.append(f"| ⏳ **CI Minutes Consumed** | **{minutes}/{minutes_max} min** | Run compute budget |")
|
|
104
|
+
lines.append(f"| 🎯 **PR Terminal Status** | {emoji} `{terminal}` | Gate conclusion |")
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
if failed:
|
|
108
|
+
lines += [
|
|
109
|
+
"### 🔍 Failure Classifications",
|
|
110
|
+
"| Check | Classification | Confidence |",
|
|
111
|
+
"|---|---|---|",
|
|
112
|
+
]
|
|
113
|
+
for c in failed:
|
|
114
|
+
clf = c.get("classification") or {}
|
|
115
|
+
lines.append(
|
|
116
|
+
f"| `{c.get('name', '?')}` "
|
|
117
|
+
f"| `{clf.get('category', '—')}` "
|
|
118
|
+
f"| {clf.get('confidence', '—')} |"
|
|
119
|
+
)
|
|
120
|
+
lines.append("")
|
|
121
|
+
|
|
122
|
+
lines.append(f"Budget: {retries_used}/{retries_max} retries · {minutes}/{minutes_max} min")
|
|
123
|
+
|
|
124
|
+
if qc:
|
|
125
|
+
lines.append(
|
|
126
|
+
f"\n⚠️ {len(qc)} quarantine candidate(s) — "
|
|
127
|
+
"run `ci-guard ledger quarantine-candidates`"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
lines.append(f"\nTerminal: {emoji} `{terminal}`")
|
|
131
|
+
lines.append("\n---\n<sub>🛡️ Guarded by [ci-guard](https://github.com/Viniciuscarvalho/ci-guard) — stop burning CI minutes on blind retries.</sub>")
|
|
132
|
+
|
|
133
|
+
return "\n".join(lines)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _invoke_ci_guard(pr_number: int, *args: str) -> None:
|
|
137
|
+
subprocess.run(
|
|
138
|
+
[sys.executable, "-m", "ci_guard.cli", "--pr", str(pr_number), *args],
|
|
139
|
+
check=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run(stream=None) -> int:
|
|
144
|
+
if stream is None:
|
|
145
|
+
stream = sys.stdin
|
|
146
|
+
|
|
147
|
+
pr_number: int | None = None
|
|
148
|
+
last_snap: dict = {}
|
|
149
|
+
|
|
150
|
+
for raw in stream:
|
|
151
|
+
raw = raw.strip()
|
|
152
|
+
if not raw:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
snap = json.loads(raw)
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
print(f"[ci-guard run-actions] skipping non-JSON line: {raw}",
|
|
159
|
+
file=sys.stderr, flush=True)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if pr_number is None:
|
|
163
|
+
pr_number = snap.get("pr_number")
|
|
164
|
+
|
|
165
|
+
last_snap = snap
|
|
166
|
+
print(raw, flush=True)
|
|
167
|
+
|
|
168
|
+
for act in snap.get("actions", []):
|
|
169
|
+
action = act.get("action")
|
|
170
|
+
|
|
171
|
+
if action == "idle":
|
|
172
|
+
continue
|
|
173
|
+
elif action == "retry_failed_now":
|
|
174
|
+
if pr_number is not None:
|
|
175
|
+
_invoke_ci_guard(pr_number, "--retry-failed-now")
|
|
176
|
+
elif action == "verify_flaky_green":
|
|
177
|
+
if pr_number is not None:
|
|
178
|
+
_invoke_ci_guard(pr_number, "--verify-flaky-green")
|
|
179
|
+
elif action == "diagnose_branch_failure":
|
|
180
|
+
checks = ", ".join(act.get("checks", []))
|
|
181
|
+
_annotation("error", f"ci-guard: branch_failure — patch code before retrying: {checks}")
|
|
182
|
+
if pr_number is not None:
|
|
183
|
+
_post_pr_comment(
|
|
184
|
+
pr_number,
|
|
185
|
+
f"**ci-guard** ⛔ `branch_failure` detected\n\n"
|
|
186
|
+
f"The following checks failed due to changes on this branch "
|
|
187
|
+
f"— **do not retry**, patch the code first:\n\n"
|
|
188
|
+
+ "\n".join(f"- `{c}`" for c in act.get("checks", [])),
|
|
189
|
+
)
|
|
190
|
+
elif action == "diagnose_unknown":
|
|
191
|
+
checks = ", ".join(act.get("checks", []))
|
|
192
|
+
_annotation("warning", f"ci-guard: unknown failure — read logs before retrying: {checks}")
|
|
193
|
+
if pr_number is not None:
|
|
194
|
+
_post_pr_comment(
|
|
195
|
+
pr_number,
|
|
196
|
+
f"**ci-guard** ❓ `unknown` failure — manual triage needed\n\n"
|
|
197
|
+
f"Could not classify the following checks automatically. "
|
|
198
|
+
f"Read the logs with `gh run view <run-id> --log-failed` "
|
|
199
|
+
f"before any retry:\n\n"
|
|
200
|
+
+ "\n".join(f"- `{c}`" for c in act.get("checks", [])),
|
|
201
|
+
)
|
|
202
|
+
elif action == "stop":
|
|
203
|
+
reason = act.get("reason", "")
|
|
204
|
+
code = _EXIT_CODES.get(reason, 0)
|
|
205
|
+
_annotation(
|
|
206
|
+
"notice" if code == 0 else "warning",
|
|
207
|
+
f"ci-guard: terminal={reason} exit={code}",
|
|
208
|
+
)
|
|
209
|
+
report = _format_report(last_snap)
|
|
210
|
+
if pr_number is not None:
|
|
211
|
+
_post_pr_comment(pr_number, report)
|
|
212
|
+
_write_step_summary(report)
|
|
213
|
+
return code
|
|
214
|
+
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main() -> int:
|
|
219
|
+
return run()
|
ci_guard/classify.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Heuristic CI failure classifier.
|
|
2
|
+
|
|
3
|
+
Reads a failed-job log and emits a classification:
|
|
4
|
+
branch_failure, infra_flake, test_flake, dependency_failure, or unknown.
|
|
5
|
+
|
|
6
|
+
Patterns are intentionally conservative — when unsure, return 'unknown'
|
|
7
|
+
so the caller diagnoses manually instead of acting on a false signal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
# Each rule is (category, confidence, description, regex).
|
|
17
|
+
# Order matters: branch_failure rules run first so a real bug doesn't get
|
|
18
|
+
# masked by a coincidental network signal in the same log.
|
|
19
|
+
RULES: list[tuple[str, str, str, re.Pattern]] = [
|
|
20
|
+
# ---------- branch_failure ----------
|
|
21
|
+
("branch_failure", "high", "TAP/BATS test failure (not ok N)",
|
|
22
|
+
re.compile(r"^not ok \d+", re.M)),
|
|
23
|
+
("branch_failure", "high", "TAP bail-out (suite aborted)",
|
|
24
|
+
re.compile(r"^Bail out!", re.M)),
|
|
25
|
+
("branch_failure", "high", "pytest FAILED line",
|
|
26
|
+
re.compile(r"^FAILED ", re.M)),
|
|
27
|
+
("branch_failure", "high", "pytest FAILURES section",
|
|
28
|
+
re.compile(r"^={3,} FAILURES ={3,}", re.M)),
|
|
29
|
+
("branch_failure", "high", "Jest / Vitest FAIL file line",
|
|
30
|
+
re.compile(r"^\s*FAIL\s+\S+", re.M)),
|
|
31
|
+
("branch_failure", "high", "Jest / Vitest test count: N failed",
|
|
32
|
+
re.compile(r"Tests?:\s+\d+ failed", re.I)),
|
|
33
|
+
("branch_failure", "high", "Mocha / tap N failing",
|
|
34
|
+
re.compile(r"\b\d+ (failing|failed)\b", re.I)),
|
|
35
|
+
("branch_failure", "medium", "AssertionError (test assertion failed)",
|
|
36
|
+
re.compile(r"\bAssertionError\b")),
|
|
37
|
+
("branch_failure", "medium", "RSpec / Ruby failure summary",
|
|
38
|
+
re.compile(r"\d+ example[s]?,? \d+ failure", re.I)),
|
|
39
|
+
("branch_failure", "medium", "JUnit / Go test FAIL line",
|
|
40
|
+
re.compile(r"^(FAIL\t|--- FAIL:)", re.M)),
|
|
41
|
+
("branch_failure", "high", "Python SyntaxError",
|
|
42
|
+
re.compile(r"\bSyntaxError:", re.I)),
|
|
43
|
+
("branch_failure", "high", "TypeScript/Flow type error",
|
|
44
|
+
re.compile(r"^.*?: error TS\d+:", re.M)),
|
|
45
|
+
("branch_failure", "high", "Go compile error",
|
|
46
|
+
re.compile(r"^# .+\n.+\.go:\d+:\d+: ", re.M)),
|
|
47
|
+
("branch_failure", "high", "Rust compile error (rustc)",
|
|
48
|
+
re.compile(r"error\[E\d{3,4}\]:", re.I)),
|
|
49
|
+
("branch_failure", "high", "Java/Kotlin compilation failure",
|
|
50
|
+
re.compile(r"(error: )?compilation failed", re.I)),
|
|
51
|
+
("branch_failure", "high", "Snapshot mismatch",
|
|
52
|
+
re.compile(r"snapshot (does not match|mismatch|doesn'?t match)", re.I)),
|
|
53
|
+
("branch_failure", "high", "Lint error in changed code",
|
|
54
|
+
re.compile(r"\b(\d+) errors?, (\d+) warnings?", re.I)),
|
|
55
|
+
("branch_failure", "medium", "Module not found",
|
|
56
|
+
re.compile(r"(Cannot find module|ModuleNotFoundError|No module named)", re.I)),
|
|
57
|
+
("branch_failure", "medium", "Missing import / undefined symbol",
|
|
58
|
+
re.compile(r"(NameError|undefined reference to|cannot find symbol)", re.I)),
|
|
59
|
+
|
|
60
|
+
# ---------- dependency_failure ----------
|
|
61
|
+
("dependency_failure", "high", "npm registry 5xx/429",
|
|
62
|
+
re.compile(r"npm (ERR! )?(429|5\d\d|registry returned)", re.I)),
|
|
63
|
+
("dependency_failure", "high", "yarn lockfile mismatch",
|
|
64
|
+
re.compile(r"lockfile .* (out of date|mismatch)", re.I)),
|
|
65
|
+
("dependency_failure", "medium", "PyPI fetch error",
|
|
66
|
+
re.compile(r"(could not fetch URL|HTTPSConnectionPool.*pypi)", re.I)),
|
|
67
|
+
("dependency_failure", "medium", "Cargo registry error",
|
|
68
|
+
re.compile(r"error: failed to (fetch|download)", re.I)),
|
|
69
|
+
("dependency_failure", "medium", "Docker pull failure",
|
|
70
|
+
re.compile(r"(error pulling image|failed to (pull|copy)|registry-1\.docker\.io)", re.I)),
|
|
71
|
+
("dependency_failure", "medium", "Go module proxy error",
|
|
72
|
+
re.compile(r"proxy\.golang\.org.*\b5\d\d\b", re.I)),
|
|
73
|
+
|
|
74
|
+
# ---------- infra_flake ----------
|
|
75
|
+
("infra_flake", "high", "Runner shutdown",
|
|
76
|
+
re.compile(r"the runner has received a shutdown signal", re.I)),
|
|
77
|
+
("infra_flake", "high", "Lost communication with runner",
|
|
78
|
+
re.compile(r"lost communication with the server", re.I)),
|
|
79
|
+
("infra_flake", "high", "GitHub Actions infra error",
|
|
80
|
+
re.compile(r"(GitHub Actions infrastructure|workflow run was canceled)", re.I)),
|
|
81
|
+
("infra_flake", "medium", "DNS failure",
|
|
82
|
+
re.compile(r"(could not resolve host|getaddrinfo (failed|ENOTFOUND))", re.I)),
|
|
83
|
+
("infra_flake", "medium", "Network reset/timeout",
|
|
84
|
+
re.compile(r"(connection reset by peer|i/o timeout|read: connection refused)", re.I)),
|
|
85
|
+
("infra_flake", "low", "Generic 503/502",
|
|
86
|
+
re.compile(r"\b(502 Bad Gateway|503 Service Unavailable|504 Gateway Timeout)\b", re.I)),
|
|
87
|
+
("infra_flake", "low", "Out of disk on runner",
|
|
88
|
+
re.compile(r"(no space left on device|disk quota exceeded)", re.I)),
|
|
89
|
+
|
|
90
|
+
# ---------- test_flake ----------
|
|
91
|
+
("test_flake", "medium", "Race condition / timing pattern",
|
|
92
|
+
re.compile(r"(timed? out waiting for|expected .* but got .* \(retried\)|"
|
|
93
|
+
r"flaky test|known.{0,15}flaky)", re.I)),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def classify(log: str) -> dict:
|
|
98
|
+
"""Apply rules in order; return first match. If none, return unknown."""
|
|
99
|
+
if not log.strip():
|
|
100
|
+
return {"category": "unknown", "confidence": "low",
|
|
101
|
+
"reason": "Empty log; cannot classify."}
|
|
102
|
+
for category, confidence, description, pattern in RULES:
|
|
103
|
+
m = pattern.search(log)
|
|
104
|
+
if m:
|
|
105
|
+
snippet = log[max(0, m.start() - 80): m.end() + 80].strip()
|
|
106
|
+
snippet = " ".join(snippet.split())
|
|
107
|
+
if len(snippet) > 240:
|
|
108
|
+
snippet = snippet[:240] + "…"
|
|
109
|
+
return {
|
|
110
|
+
"category": category,
|
|
111
|
+
"confidence": confidence,
|
|
112
|
+
"reason": description,
|
|
113
|
+
"matched_pattern": pattern.pattern,
|
|
114
|
+
"matched_snippet": snippet,
|
|
115
|
+
}
|
|
116
|
+
return {"category": "unknown", "confidence": "low",
|
|
117
|
+
"reason": "No heuristic matched; manual diagnosis recommended."}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fetch_log(run_id: str) -> str:
|
|
121
|
+
try:
|
|
122
|
+
r = subprocess.run(
|
|
123
|
+
["gh", "run", "view", run_id, "--log-failed"],
|
|
124
|
+
capture_output=True, text=True, check=False,
|
|
125
|
+
)
|
|
126
|
+
return r.stdout
|
|
127
|
+
except FileNotFoundError:
|
|
128
|
+
sys.stderr.write("ci-guard: 'gh' CLI not found.\n")
|
|
129
|
+
sys.exit(2)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main() -> int:
|
|
133
|
+
import argparse
|
|
134
|
+
import json
|
|
135
|
+
|
|
136
|
+
p = argparse.ArgumentParser(
|
|
137
|
+
description="Classify a CI failure log as branch_failure, infra_flake, etc.")
|
|
138
|
+
src = p.add_mutually_exclusive_group(required=True)
|
|
139
|
+
src.add_argument("--run-id", help="Fetch failed log for this run via gh CLI.")
|
|
140
|
+
src.add_argument("--log-file", help="Path to a log file on disk.")
|
|
141
|
+
src.add_argument("--stdin", action="store_true", help="Read log from stdin.")
|
|
142
|
+
p.add_argument("--check-name", default=None)
|
|
143
|
+
args = p.parse_args()
|
|
144
|
+
|
|
145
|
+
if args.run_id:
|
|
146
|
+
log = fetch_log(args.run_id)
|
|
147
|
+
elif args.log_file:
|
|
148
|
+
log = open(args.log_file, encoding="utf-8", errors="replace").read()
|
|
149
|
+
else:
|
|
150
|
+
log = sys.stdin.read()
|
|
151
|
+
|
|
152
|
+
result = classify(log)
|
|
153
|
+
if args.check_name:
|
|
154
|
+
result["check_name"] = args.check_name
|
|
155
|
+
print(json.dumps(result, indent=2))
|
|
156
|
+
return 0
|