ci-whisper 0.1.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mgkvibez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: ci-whisper
3
+ Version: 0.1.1
4
+ Summary: Read a failed CI log and get the first real error, its causal chain, and a fix.
5
+ Author: mgkvibez
6
+ License: MIT
7
+ Keywords: ci,github-actions,logs,debugging,developer-tools,agents
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # ci-whisper
22
+
23
+ **Read a failed CI log. Get the first real error, its causal chain, and a fix.** One paragraph instead of ten thousand lines of noise.
24
+
25
+ [![ci](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml)
26
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
27
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
28
+ [![stars](https://img.shields.io/github/stars/mgkvibez/ci-whisper?style=flat&color=yellow)](https://github.com/mgkvibez/ci-whisper/stargazers)
29
+
30
+ ![ci-whisper demo](docs/demo/ci-whisper.svg)
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pipx install git+https://github.com/mgkvibez/ci-whisper.git
36
+ ```
37
+
38
+ Until it is on PyPI, install from git. For development: `git clone && pip install -e ".[dev]"`.
39
+
40
+ ## Three ways to use it
41
+
42
+ ```bash
43
+ # 1. The one you will use daily: pipe the failing job log
44
+ gh run view --log-failed | ci-whisper
45
+
46
+ # 2. From a downloaded log file
47
+ ci-whisper --file /tmp/build.log
48
+
49
+ # 3. Let it fetch the last failed run of the current repo
50
+ ci-whisper --gh 16977481234
51
+
52
+ # machine-readable, for a bot or an agent skill
53
+ ci-whisper --file build.log --json
54
+ ```
55
+
56
+ Exit code is `1` when no error signal is found, so you can gate on it.
57
+
58
+ ## Why it exists
59
+
60
+ A red build hands you 10k lines. The actual cause is line 4, and everything after it is consequence: retries, cascading test failures, the polite `##[error]Process completed with exit code 1`.
61
+
62
+ Two things go wrong when this hits an agent. It reads the *last* error and fixes a symptom, or it reads the header and summarises nothing. ci-whisper ranks signals so the cause outranks its own consequences, then shows the citations so anyone can check the call.
63
+
64
+ ## How it works
65
+
66
+ 1. **Clean** — strips ANSI and GitHub's `2026-10-29T08:01:02.1234567Z ` timestamp prefix, keeping a line-number map to the original file.
67
+ 2. **Scan** — matches a ranked rule set: `ModuleNotFoundError`/`Exception` (4) beat `Traceback`, test failures and compiler errors (3), which beat `npm ERR!` and `##[error]` (2), which beat a bare `exit code 1` (1).
68
+ 3. **Root cause** — highest severity, then earliest line. Noise (warnings, deprecations, `0 failed`, download progress) is filtered before ranking.
69
+ 4. **Chain** — the chosen line plus the following block: indented continuations, frames, and `caused by` style links.
70
+ 5. **Fix** — a rule per failure shape: peer dependency conflict, missing module, permissions, missing path, assertion, timeout, OOM.
71
+
72
+ No network calls, no LLM, no dependencies.
73
+
74
+ ## Contributing
75
+
76
+ Read [CONTRIBUTING.md](CONTRIBUTING.md). Tests:
77
+
78
+ ```bash
79
+ python -m pytest -q
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,64 @@
1
+ # ci-whisper
2
+
3
+ **Read a failed CI log. Get the first real error, its causal chain, and a fix.** One paragraph instead of ten thousand lines of noise.
4
+
5
+ [![ci](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
8
+ [![stars](https://img.shields.io/github/stars/mgkvibez/ci-whisper?style=flat&color=yellow)](https://github.com/mgkvibez/ci-whisper/stargazers)
9
+
10
+ ![ci-whisper demo](docs/demo/ci-whisper.svg)
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pipx install git+https://github.com/mgkvibez/ci-whisper.git
16
+ ```
17
+
18
+ Until it is on PyPI, install from git. For development: `git clone && pip install -e ".[dev]"`.
19
+
20
+ ## Three ways to use it
21
+
22
+ ```bash
23
+ # 1. The one you will use daily: pipe the failing job log
24
+ gh run view --log-failed | ci-whisper
25
+
26
+ # 2. From a downloaded log file
27
+ ci-whisper --file /tmp/build.log
28
+
29
+ # 3. Let it fetch the last failed run of the current repo
30
+ ci-whisper --gh 16977481234
31
+
32
+ # machine-readable, for a bot or an agent skill
33
+ ci-whisper --file build.log --json
34
+ ```
35
+
36
+ Exit code is `1` when no error signal is found, so you can gate on it.
37
+
38
+ ## Why it exists
39
+
40
+ A red build hands you 10k lines. The actual cause is line 4, and everything after it is consequence: retries, cascading test failures, the polite `##[error]Process completed with exit code 1`.
41
+
42
+ Two things go wrong when this hits an agent. It reads the *last* error and fixes a symptom, or it reads the header and summarises nothing. ci-whisper ranks signals so the cause outranks its own consequences, then shows the citations so anyone can check the call.
43
+
44
+ ## How it works
45
+
46
+ 1. **Clean** — strips ANSI and GitHub's `2026-10-29T08:01:02.1234567Z ` timestamp prefix, keeping a line-number map to the original file.
47
+ 2. **Scan** — matches a ranked rule set: `ModuleNotFoundError`/`Exception` (4) beat `Traceback`, test failures and compiler errors (3), which beat `npm ERR!` and `##[error]` (2), which beat a bare `exit code 1` (1).
48
+ 3. **Root cause** — highest severity, then earliest line. Noise (warnings, deprecations, `0 failed`, download progress) is filtered before ranking.
49
+ 4. **Chain** — the chosen line plus the following block: indented continuations, frames, and `caused by` style links.
50
+ 5. **Fix** — a rule per failure shape: peer dependency conflict, missing module, permissions, missing path, assertion, timeout, OOM.
51
+
52
+ No network calls, no LLM, no dependencies.
53
+
54
+ ## Contributing
55
+
56
+ Read [CONTRIBUTING.md](CONTRIBUTING.md). Tests:
57
+
58
+ ```bash
59
+ python -m pytest -q
60
+ ```
61
+
62
+ ## License
63
+
64
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ """ci-whisper: read a failed CI log, report the first real error and its cause."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,80 @@
1
+ """Command line entry point for ci-whisper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .parse import Diagnosis, diagnose
13
+
14
+
15
+ def read_log(args: argparse.Namespace) -> str:
16
+ """Get the log text from a file, stdin, or `gh run view`."""
17
+ if args.file:
18
+ return Path(args.file).read_text(encoding="utf-8", errors="replace")
19
+ if args.gh:
20
+ result = subprocess.run(
21
+ ["gh", "run", "view", args.gh, "--log-failed"],
22
+ capture_output=True,
23
+ text=True,
24
+ check=False,
25
+ )
26
+ if result.returncode != 0 and not result.stdout:
27
+ print(f"ci-whisper: gh failed: {result.stderr.strip()}", file=sys.stderr)
28
+ raise SystemExit(2)
29
+ return result.stdout
30
+ if sys.stdin.isatty():
31
+ print("ci-whisper: pass a log on stdin, --file, or --gh <run-id>", file=sys.stderr)
32
+ raise SystemExit(2)
33
+ return sys.stdin.read()
34
+
35
+
36
+ def render(diagnosis: Diagnosis) -> str:
37
+ """Human-readable report."""
38
+ out: list[str] = []
39
+ if diagnosis.root_cause is None:
40
+ out.append("No error found in this log.")
41
+ out.append(f"Scanned {diagnosis.scanned} lines, {diagnosis.signals} signals.")
42
+ return "\n".join(out)
43
+
44
+ root = diagnosis.root_cause
45
+ out.append("FIRST REAL ERROR")
46
+ out.append(f" line {root.line_no}: {root.text}")
47
+ out.append("")
48
+ out.append("CAUSAL CHAIN")
49
+ for hit in diagnosis.chain:
50
+ role = "root" if hit is root else hit.kind
51
+ out.append(f" {role:12} line {hit.line_no}: {hit.text}")
52
+ out.append("")
53
+ out.append("SUGGESTED FIX")
54
+ out.append(f" {diagnosis.fix}")
55
+ out.append("")
56
+ out.append(f"Scanned {diagnosis.scanned} lines, {diagnosis.signals} signals.")
57
+ return "\n".join(out)
58
+
59
+
60
+ def main(argv: list[str] | None = None) -> int:
61
+ parser = argparse.ArgumentParser(
62
+ prog="ci-whisper",
63
+ description="Read a failed CI log and report the first real error, its chain, and a fix.",
64
+ )
65
+ parser.add_argument("--file", "-f", help="log file to read")
66
+ parser.add_argument("--gh", metavar="RUN_ID", help="GitHub Actions run id (uses gh run view)")
67
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
68
+ parser.add_argument("--version", action="version", version=f"ci-whisper {__version__}")
69
+ args = parser.parse_args(argv)
70
+
71
+ diagnosis = diagnose(read_log(args))
72
+ if args.json:
73
+ print(json.dumps(diagnosis.as_dict(), indent=2))
74
+ else:
75
+ print(render(diagnosis))
76
+ return 0 if diagnosis.root_cause else 1
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
@@ -0,0 +1,215 @@
1
+ """Turn a raw CI log into a diagnosis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Iterable
8
+
9
+ from .patterns import (
10
+ ANSI_RE,
11
+ CAUSAL_HINTS,
12
+ FIX_RULES,
13
+ GO_COMPILER_ERROR_RE,
14
+ GO_PACKAGE_FAILURE_RE,
15
+ GO_TEST_FAILURE_RE,
16
+ NOISE,
17
+ STRONG_ERRORS,
18
+ TIMESTAMP_RE,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class Hit:
24
+ """One signal found in the log."""
25
+
26
+ line_no: int
27
+ text: str
28
+ kind: str
29
+ strength: int
30
+
31
+
32
+ @dataclass
33
+ class Diagnosis:
34
+ """Result of reading a log."""
35
+
36
+ root_cause: Hit | None
37
+ chain: list[Hit] = field(default_factory=list)
38
+ fix: str = ""
39
+ phase: str = ""
40
+ scanned: int = 0
41
+ signals: int = 0
42
+
43
+ def as_dict(self) -> dict[str, object]:
44
+ return {
45
+ "phase": self.phase,
46
+ "scanned_lines": self.scanned,
47
+ "signals": self.signals,
48
+ "root_cause": (
49
+ {
50
+ "line": self.root_cause.line_no,
51
+ "kind": self.root_cause.kind,
52
+ "text": self.root_cause.text,
53
+ }
54
+ if self.root_cause
55
+ else None
56
+ ),
57
+ "chain": [
58
+ {"line": h.line_no, "kind": h.kind, "text": h.text} for h in self.chain
59
+ ],
60
+ "suggested_fix": self.fix,
61
+ }
62
+
63
+
64
+ def clean_lines(raw: str) -> list[str]:
65
+ """Strip ANSI escapes and CI timestamps, keeping one entry per log line."""
66
+ out: list[str] = []
67
+ for line in raw.splitlines():
68
+ line = ANSI_RE.sub("", line)
69
+ line = TIMESTAMP_RE.sub("", line)
70
+ out.append(line.rstrip("\r"))
71
+ return out
72
+
73
+
74
+ def is_noise(line: str) -> bool:
75
+ """True when a line looks like an error but is not worth reporting."""
76
+ return any(pattern.search(line) for pattern in NOISE)
77
+
78
+
79
+ def classify(line: str) -> tuple[str, int] | None:
80
+ """Return (kind, strength) for a matching error line, else None."""
81
+ text = line.strip()
82
+ if not text or is_noise(text):
83
+ return None
84
+ # Go compiler diagnostics have no "error:" prefix. Keep indentation here:
85
+ # a test's indented source location is context, not a compiler error.
86
+ if GO_COMPILER_ERROR_RE.search(line):
87
+ return "compiler-error", 3
88
+ for kind, pattern in STRONG_ERRORS:
89
+ if pattern.search(text):
90
+ # The exception line is the cause; the Traceback header is only its preface.
91
+ if kind in {"module-not-found", "exception"}:
92
+ strength = 4
93
+ elif kind in {"python-traceback", "test-failure", "go-test-failure", "compiler-error"}:
94
+ strength = 3
95
+ elif kind in {"npm-error", "github-error"}:
96
+ strength = 2
97
+ else:
98
+ strength = 1
99
+ return kind, strength
100
+ return None
101
+
102
+
103
+ def find_signals(lines: Iterable[str]) -> list[Hit]:
104
+ """Collect every non-noise error signal with its original line number."""
105
+ hits: list[Hit] = []
106
+ for index, line in enumerate(lines, start=1):
107
+ found = classify(line)
108
+ if found:
109
+ kind, strength = found
110
+ hits.append(Hit(line_no=index, text=line.strip(), kind=kind, strength=strength))
111
+ return hits
112
+
113
+
114
+ def find_phase(lines: list[str]) -> str:
115
+ """Name the step that was running when the first signal appeared."""
116
+ current = ""
117
+ for line in lines:
118
+ stripped = line.strip()
119
+ if stripped.startswith("##[group]"):
120
+ current = stripped[len("##[group]") :].strip() or current
121
+ elif stripped.startswith("##[endgroup]"):
122
+ continue
123
+ return current
124
+
125
+
126
+ def pick_root_cause(hits: list[Hit]) -> Hit | None:
127
+ """Choose the earliest strong signal; prefer structured errors over exit codes."""
128
+ if not hits:
129
+ return None
130
+ structured = [h for h in hits if h.strength >= 3]
131
+ if structured:
132
+ # Highest severity first, then earliest line: the exception beats its own preface.
133
+ return sorted(structured, key=lambda h: (-h.strength, h.line_no))[0]
134
+ non_exit = [h for h in hits if h.kind != "non-zero-exit"]
135
+ return (non_exit or hits)[0]
136
+
137
+
138
+ def build_chain(root: Hit | None, hits: list[Hit], lines: list[str]) -> list[Hit]:
139
+ """Collect related signals: the root cause, its context, and later consequences."""
140
+ if root is None:
141
+ return []
142
+ chain: list[Hit] = [root]
143
+ downstream = [h for h in hits if h.line_no > root.line_no][:3]
144
+ chain.extend(downstream)
145
+ for offset in range(root.line_no, min(root.line_no + 12, len(lines) + 1)):
146
+ text = lines[offset - 1].strip()
147
+ if offset == root.line_no:
148
+ continue
149
+ if any(hint.search(text) for hint in CAUSAL_HINTS) and not is_noise(text):
150
+ chain.append(Hit(line_no=offset, text=text, kind="causal", strength=1))
151
+ break
152
+ seen: set[int] = set()
153
+ unique: list[Hit] = []
154
+ for hit in chain:
155
+ if hit.line_no not in seen:
156
+ seen.add(hit.line_no)
157
+ unique.append(hit)
158
+ return unique
159
+
160
+
161
+ TEST_ID_RE = re.compile(r"^(?:FAILED\s+)?(?P<test>[\w./-]+\.py::[\w:.\[\]-]+)")
162
+
163
+
164
+ def suggest_fix(root: Hit | None, lines: list[str] | None = None) -> str:
165
+ """Map the root cause to a concrete first action."""
166
+ if root is None:
167
+ return "No error signal found. The log may be truncated or the failure is a cancelled step."
168
+ if root.kind == "go-test-failure":
169
+ match = GO_TEST_FAILURE_RE.search(root.text)
170
+ if match:
171
+ package = ""
172
+ for line in (lines or [])[root.line_no:]:
173
+ text = line.strip()
174
+ if text.startswith(("##[group]", "##[endgroup]", "Run ")):
175
+ break
176
+ result = GO_PACKAGE_FAILURE_RE.search(text)
177
+ if result:
178
+ package = f" in package {result.group(1)}"
179
+ break
180
+ return (
181
+ f"Go test {match.group(1)}{package} failed. Re-run it alone and inspect "
182
+ "its assertion output, then fix the expectation or the code it exercises."
183
+ )
184
+ if root.kind == "test-failure":
185
+ match = TEST_ID_RE.search(root.text)
186
+ if match:
187
+ detail = root.text.split(" - ", 1)[1] if " - " in root.text else root.text
188
+ return (
189
+ f"{match.group('test')} failed with {detail}. Run it alone to reproduce, "
190
+ "then fix the expectation or the code it exercises."
191
+ )
192
+ for pattern, template in FIX_RULES:
193
+ match = pattern.search(root.text)
194
+ if match:
195
+ groups = [g for g in match.groups() if g]
196
+ return template.format(*groups) if groups else template
197
+ return (
198
+ f"Read {root.text!r} at line {root.line_no}. The first real error is usually "
199
+ "the one before the cascade of non-zero exits."
200
+ )
201
+
202
+
203
+ def diagnose(raw: str) -> Diagnosis:
204
+ """Full pipeline: clean, find signals, pick root cause, chain, fix."""
205
+ lines = clean_lines(raw)
206
+ hits = find_signals(lines)
207
+ root = pick_root_cause(hits)
208
+ return Diagnosis(
209
+ root_cause=root,
210
+ chain=build_chain(root, hits, lines),
211
+ fix=suggest_fix(root, lines),
212
+ phase=find_phase(lines),
213
+ scanned=len(lines),
214
+ signals=len(hits),
215
+ )
@@ -0,0 +1,74 @@
1
+ """Signal and noise patterns for CI log classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
8
+ TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s?")
9
+ JOB_PREFIX_RE = re.compile(r"^[^\t]*\t(?=[a-z-]+\t)")
10
+ GO_TEST_FAILURE_RE = re.compile(r"^--- FAIL: (\S+) \(\d+(?:\.\d+)?s\)$")
11
+ GO_PACKAGE_FAILURE_RE = re.compile(r"^FAIL\s+(\S+)\s+(?:\d+(?:\.\d+)?s|\[build failed\])(?:\s|$)")
12
+ GO_COMPILER_ERROR_RE = re.compile(r"^\S+\.go:\d+:\d+:\s+\S")
13
+
14
+ # Errors that are almost always the real cause.
15
+ STRONG_ERRORS: list[tuple[str, re.Pattern[str]]] = [
16
+ ("python-traceback", re.compile(r"^Traceback \(most recent call last\):")),
17
+ ("module-not-found", re.compile(r"^ModuleNotFoundError:|^ImportError:")),
18
+ ("npm-error", re.compile(r"^npm ERR!")),
19
+ ("github-error", re.compile(r"^##\[error\]")),
20
+ ("compiler-error", re.compile(r"\berror(\[E\d+\])?:")),
21
+ ("go-test-failure", GO_TEST_FAILURE_RE),
22
+ ("test-failure", re.compile(r"^(FAILED|FAIL|✕|×)\b")),
23
+ ("fatal", re.compile(r"^fatal:")),
24
+ ("exception", re.compile(r"^[A-Za-z_.]+(Error|Exception):\s")),
25
+ ("non-zero-exit", re.compile(r"process completed with exit code [1-9]")),
26
+ ]
27
+
28
+ # Lines that look like errors but are noise.
29
+ NOISE: list[re.Pattern[str]] = [
30
+ re.compile(r"\b\d+\s+(passed|failed)=?\s*0 failed", re.IGNORECASE),
31
+ re.compile(r"^warning:?|^W\d{4}\b|\bWARN\b", re.IGNORECASE),
32
+ re.compile(r"deprecat", re.IGNORECASE),
33
+ re.compile(r"^\s*$"),
34
+ re.compile(r"^\s*\d+%|\bDownloading\b|\bCollecting\b", re.IGNORECASE),
35
+ re.compile(r"^npm WARN", re.IGNORECASE),
36
+ re.compile(r"\b0 errors?\b", re.IGNORECASE),
37
+ ]
38
+
39
+ # Markers that a previous phase ended; errors after these are the interesting ones.
40
+ PHASE_MARKERS: list[re.Pattern[str]] = [
41
+ re.compile(r"^##\[group\](.*)$"),
42
+ re.compile(r"^Run\s+(.+)$"),
43
+ ]
44
+
45
+ CAUSAL_HINTS: list[re.Pattern[str]] = [
46
+ re.compile(r"caused by:", re.IGNORECASE),
47
+ re.compile(r"\bduring handling of the above exception", re.IGNORECASE),
48
+ re.compile(r"\bdue to\b", re.IGNORECASE),
49
+ re.compile(r"^The above exception was the direct cause", re.IGNORECASE),
50
+ re.compile(r"^\s+npm ERR!.*\n?", re.IGNORECASE),
51
+ ]
52
+
53
+ FIX_RULES: list[tuple[re.Pattern[str], str]] = [
54
+ (re.compile(r"ModuleNotFoundError: No module named '([^']+)'"),
55
+ "Install the missing module (pip install {0}) and add it to your dependencies so CI installs it."),
56
+ (re.compile(r"npm ERR! code ERESOLVE"),
57
+ "Peer dependency conflict: align the conflicting versions or use a compatible lockfile."),
58
+ (re.compile(r"npm ERR!.*ETARGET|No matching version found for (\S+)"),
59
+ "The requested version does not exist in the registry. Pin an existing version."),
60
+ (re.compile(r"Permission denied \(publickey\)|Host key verification failed"),
61
+ "The deploy key or known_hosts entry is missing or rotated. Check repository secrets."),
62
+ (re.compile(r"No such file or directory: '([^']+)'"),
63
+ "A required file is missing at {0}. Verify the checkout path and that the file is committed."),
64
+ (re.compile(r"EADDRINUSE|address already in use", re.IGNORECASE),
65
+ "Port conflict: the service is already bound. Kill the stale process or choose a free port."),
66
+ (re.compile(r"AssertionError"),
67
+ "An assertion failed: the actual value differs from the expected one. Read the assertion line for both values."),
68
+ (re.compile(r"SyntaxError"),
69
+ "The file does not parse. Fix the syntax before investigating anything downstream."),
70
+ (re.compile(r"Timeout|timed out", re.IGNORECASE),
71
+ "The step exceeded its time budget. Look for a hang or an unbounded network call."),
72
+ (re.compile(r"Killed|signal: killed|OutOfMemory", re.IGNORECASE),
73
+ "The process was killed, usually out of memory. Reduce memory use or raise the runner limit."),
74
+ ]
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: ci-whisper
3
+ Version: 0.1.1
4
+ Summary: Read a failed CI log and get the first real error, its causal chain, and a fix.
5
+ Author: mgkvibez
6
+ License: MIT
7
+ Keywords: ci,github-actions,logs,debugging,developer-tools,agents
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # ci-whisper
22
+
23
+ **Read a failed CI log. Get the first real error, its causal chain, and a fix.** One paragraph instead of ten thousand lines of noise.
24
+
25
+ [![ci](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml)
26
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
27
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
28
+ [![stars](https://img.shields.io/github/stars/mgkvibez/ci-whisper?style=flat&color=yellow)](https://github.com/mgkvibez/ci-whisper/stargazers)
29
+
30
+ ![ci-whisper demo](docs/demo/ci-whisper.svg)
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pipx install git+https://github.com/mgkvibez/ci-whisper.git
36
+ ```
37
+
38
+ Until it is on PyPI, install from git. For development: `git clone && pip install -e ".[dev]"`.
39
+
40
+ ## Three ways to use it
41
+
42
+ ```bash
43
+ # 1. The one you will use daily: pipe the failing job log
44
+ gh run view --log-failed | ci-whisper
45
+
46
+ # 2. From a downloaded log file
47
+ ci-whisper --file /tmp/build.log
48
+
49
+ # 3. Let it fetch the last failed run of the current repo
50
+ ci-whisper --gh 16977481234
51
+
52
+ # machine-readable, for a bot or an agent skill
53
+ ci-whisper --file build.log --json
54
+ ```
55
+
56
+ Exit code is `1` when no error signal is found, so you can gate on it.
57
+
58
+ ## Why it exists
59
+
60
+ A red build hands you 10k lines. The actual cause is line 4, and everything after it is consequence: retries, cascading test failures, the polite `##[error]Process completed with exit code 1`.
61
+
62
+ Two things go wrong when this hits an agent. It reads the *last* error and fixes a symptom, or it reads the header and summarises nothing. ci-whisper ranks signals so the cause outranks its own consequences, then shows the citations so anyone can check the call.
63
+
64
+ ## How it works
65
+
66
+ 1. **Clean** — strips ANSI and GitHub's `2026-10-29T08:01:02.1234567Z ` timestamp prefix, keeping a line-number map to the original file.
67
+ 2. **Scan** — matches a ranked rule set: `ModuleNotFoundError`/`Exception` (4) beat `Traceback`, test failures and compiler errors (3), which beat `npm ERR!` and `##[error]` (2), which beat a bare `exit code 1` (1).
68
+ 3. **Root cause** — highest severity, then earliest line. Noise (warnings, deprecations, `0 failed`, download progress) is filtered before ranking.
69
+ 4. **Chain** — the chosen line plus the following block: indented continuations, frames, and `caused by` style links.
70
+ 5. **Fix** — a rule per failure shape: peer dependency conflict, missing module, permissions, missing path, assertion, timeout, OOM.
71
+
72
+ No network calls, no LLM, no dependencies.
73
+
74
+ ## Contributing
75
+
76
+ Read [CONTRIBUTING.md](CONTRIBUTING.md). Tests:
77
+
78
+ ```bash
79
+ python -m pytest -q
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ ci_whisper/__init__.py
5
+ ci_whisper/cli.py
6
+ ci_whisper/parse.py
7
+ ci_whisper/patterns.py
8
+ ci_whisper.egg-info/PKG-INFO
9
+ ci_whisper.egg-info/SOURCES.txt
10
+ ci_whisper.egg-info/dependency_links.txt
11
+ ci_whisper.egg-info/entry_points.txt
12
+ ci_whisper.egg-info/requires.txt
13
+ ci_whisper.egg-info/top_level.txt
14
+ tests/test_parse.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ci-whisper = ci_whisper.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=8
@@ -0,0 +1 @@
1
+ ci_whisper
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ci-whisper"
7
+ version = "0.1.1"
8
+ description = "Read a failed CI log and get the first real error, its causal chain, and a fix."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "mgkvibez" }]
13
+ keywords = ["ci", "github-actions", "logs", "debugging", "developer-tools", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Quality Assurance",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8"]
26
+
27
+ [project.scripts]
28
+ ci-whisper = "ci_whisper.cli:main"
29
+
30
+ [tool.setuptools]
31
+ packages = ["ci_whisper"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,123 @@
1
+ """Tests for ci_whisper.parse."""
2
+
3
+ from pathlib import Path
4
+
5
+ from ci_whisper.parse import classify, clean_lines, diagnose, suggest_fix
6
+
7
+ FIXTURES = Path(__file__).parent / "fixtures"
8
+
9
+
10
+ def load(name: str) -> str:
11
+ return (FIXTURES / name).read_text(encoding="utf-8")
12
+
13
+
14
+ def test_pytest_failure_finds_assertion_not_exit_code():
15
+ diagnosis = diagnose(load("pytest_fail.log"))
16
+ assert diagnosis.root_cause is not None
17
+ assert diagnosis.root_cause.kind == "test-failure"
18
+ assert "test_render_header" in diagnosis.root_cause.text
19
+ assert "test_render_header" in diagnosis.fix
20
+
21
+
22
+ def test_npm_failure_reports_eresolve_and_ignores_deprecation():
23
+ diagnosis = diagnose(load("npm_fail.log"))
24
+ assert diagnosis.root_cause is not None
25
+ assert diagnosis.root_cause.kind == "npm-error"
26
+ assert "ERESOLVE" in diagnosis.root_cause.text
27
+ assert "Peer dependency" in diagnosis.fix
28
+
29
+
30
+ def test_traceback_uses_module_not_found_rule():
31
+ diagnosis = diagnose(load("traceback.log"))
32
+ assert diagnosis.root_cause is not None
33
+ assert "No module named 'yaml'" in diagnosis.root_cause.text
34
+ assert "pip install yaml" in diagnosis.fix
35
+
36
+
37
+ def test_noise_only_log_reports_no_error():
38
+ diagnosis = diagnose(load("noise_only.log"))
39
+ assert diagnosis.root_cause is None
40
+ assert "No error signal found" in diagnosis.fix
41
+
42
+
43
+ def test_line_numbers_point_at_the_source_line():
44
+ raw = load("pytest_fail.log")
45
+ diagnosis = diagnose(raw)
46
+ lines = clean_lines(raw)
47
+ root = diagnosis.root_cause
48
+ assert root is not None
49
+ assert lines[root.line_no - 1].strip() == root.text
50
+
51
+
52
+ def test_chain_is_ordered_and_unique():
53
+ diagnosis = diagnose(load("pytest_fail.log"))
54
+ numbers = [hit.line_no for hit in diagnosis.chain]
55
+ assert numbers == sorted(numbers)
56
+ assert len(numbers) == len(set(numbers))
57
+ assert diagnosis.chain[0].line_no == diagnosis.root_cause.line_no
58
+
59
+
60
+ def test_suggest_fix_handles_none():
61
+ assert suggest_fix(None).startswith("No error signal found")
62
+
63
+
64
+ def test_exit_code_only_log_falls_back_to_exit_signal():
65
+ diagnosis = diagnose("##[error]Process completed with exit code 1.")
66
+ assert diagnosis.root_cause is not None
67
+ assert diagnosis.root_cause.kind == "github-error"
68
+
69
+
70
+ def test_go_failure_names_test_and_package_not_summary():
71
+ diagnosis = diagnose(load("go_fail.log"))
72
+ assert diagnosis.root_cause is not None
73
+ assert diagnosis.root_cause.kind == "go-test-failure"
74
+ assert diagnosis.root_cause.strength == 3
75
+ assert diagnosis.root_cause.line_no == 3
76
+ assert diagnosis.root_cause.text == "--- FAIL: TestTotal (0.00s)"
77
+ assert "TestTotal" in diagnosis.fix
78
+ assert "example.com/shop/cart" in diagnosis.fix
79
+ assert "example.com/shop/cache" not in diagnosis.fix
80
+
81
+
82
+ def test_go_failure_does_not_outrank_earlier_compiler_error():
83
+ raw = (
84
+ "2026-10-29T08:01:01.0000000Z # example.com/shop/broken\n"
85
+ "2026-10-29T08:01:01.0000000Z broken/main.go:3:9: undefined: missing\n"
86
+ "2026-10-29T08:01:01.0000000Z FAIL\texample.com/shop/broken [build failed]\n"
87
+ + load("go_fail.log")
88
+ )
89
+ diagnosis = diagnose(raw)
90
+ assert diagnosis.root_cause is not None
91
+ assert diagnosis.root_cause.kind == "compiler-error"
92
+ assert diagnosis.root_cause.line_no == 2
93
+ assert "undefined: missing" in diagnosis.root_cause.text
94
+ assert "TestTotal" not in diagnosis.fix
95
+
96
+
97
+ def test_go_subtest_and_missing_package_summary():
98
+ diagnosis = diagnose(
99
+ " --- FAIL: TestTotal/discount (0.01s)\n"
100
+ " cart_test.go:12: total = 12, want 10\n"
101
+ )
102
+ assert diagnosis.root_cause is not None
103
+ assert diagnosis.root_cause.kind == "go-test-failure"
104
+ assert "TestTotal/discount" in diagnosis.fix
105
+ assert "package" not in diagnosis.fix
106
+ assert suggest_fix(diagnosis.root_cause) == diagnosis.fix
107
+
108
+
109
+ def test_go_package_lookup_stops_at_next_ci_step():
110
+ diagnosis = diagnose(
111
+ "--- FAIL: TestTotal (0.00s)\n"
112
+ "##[endgroup]\n"
113
+ "##[group]Run go test ./other\n"
114
+ "FAIL\texample.com/other\t0.001s\n"
115
+ )
116
+ assert "TestTotal" in diagnosis.fix
117
+ assert "example.com/other" not in diagnosis.fix
118
+
119
+
120
+ def test_go_passing_tests_and_indented_source_locations_are_not_errors():
121
+ assert classify("--- PASS: TestTotal (0.00s)") is None
122
+ assert classify(" cart_test.go:12:9: a diagnostic from a test") is None
123
+ assert classify("--- FAIL: this is not a Go test result") is None