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 ADDED
@@ -0,0 +1,3 @@
1
+ """ci-guard — CI failure classifier and flaky-test ledger."""
2
+
3
+ __version__ = "0.5.0"
ci_guard/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """ci-guard package entry point."""
2
+
3
+ from ci_guard.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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