ci-doctorr 0.1.0__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.
Files changed (126) hide show
  1. ci_doctorr-0.1.0/.claude/CLAUDE.md +10 -0
  2. ci_doctorr-0.1.0/.claude/settings.json +19 -0
  3. ci_doctorr-0.1.0/.codegraph/.gitignore +5 -0
  4. ci_doctorr-0.1.0/.github/workflows/release.yml +58 -0
  5. ci_doctorr-0.1.0/.gitignore +13 -0
  6. ci_doctorr-0.1.0/.mcp.json +12 -0
  7. ci_doctorr-0.1.0/CHANGELOG.md +35 -0
  8. ci_doctorr-0.1.0/Dockerfile +10 -0
  9. ci_doctorr-0.1.0/LICENSE +21 -0
  10. ci_doctorr-0.1.0/PKG-INFO +19 -0
  11. ci_doctorr-0.1.0/README.md +221 -0
  12. ci_doctorr-0.1.0/ci_doctor/__init__.py +6 -0
  13. ci_doctorr-0.1.0/ci_doctor/cli.py +211 -0
  14. ci_doctorr-0.1.0/ci_doctor/config/__init__.py +0 -0
  15. ci_doctorr-0.1.0/ci_doctor/config/defaults.yml +83 -0
  16. ci_doctorr-0.1.0/ci_doctor/config/loader.py +97 -0
  17. ci_doctorr-0.1.0/ci_doctor/config/schema.py +105 -0
  18. ci_doctorr-0.1.0/ci_doctor/core/__init__.py +0 -0
  19. ci_doctorr-0.1.0/ci_doctor/core/analyze.py +85 -0
  20. ci_doctorr-0.1.0/ci_doctor/core/attribution.py +195 -0
  21. ci_doctorr-0.1.0/ci_doctor/core/budget.py +35 -0
  22. ci_doctorr-0.1.0/ci_doctor/core/denoise.py +69 -0
  23. ci_doctorr-0.1.0/ci_doctor/core/extract.py +82 -0
  24. ci_doctorr-0.1.0/ci_doctor/core/models.py +96 -0
  25. ci_doctorr-0.1.0/ci_doctor/core/phases.py +33 -0
  26. ci_doctorr-0.1.0/ci_doctor/core/ports.py +42 -0
  27. ci_doctorr-0.1.0/ci_doctor/core/redact.py +75 -0
  28. ci_doctorr-0.1.0/ci_doctor/core/select.py +17 -0
  29. ci_doctorr-0.1.0/ci_doctor/llm/__init__.py +0 -0
  30. ci_doctorr-0.1.0/ci_doctor/llm/backends.py +135 -0
  31. ci_doctorr-0.1.0/ci_doctor/llm/client.py +64 -0
  32. ci_doctorr-0.1.0/ci_doctor/llm/prompts/analyze.system.txt +9 -0
  33. ci_doctorr-0.1.0/ci_doctor/llm/prompts/analyze.user.txt +8 -0
  34. ci_doctorr-0.1.0/ci_doctor/llm/report.py +164 -0
  35. ci_doctorr-0.1.0/ci_doctor/llm/schema.py +48 -0
  36. ci_doctorr-0.1.0/ci_doctor/providers/__init__.py +0 -0
  37. ci_doctorr-0.1.0/ci_doctor/providers/github/__init__.py +0 -0
  38. ci_doctorr-0.1.0/ci_doctor/providers/github/provider.py +149 -0
  39. ci_doctorr-0.1.0/ci_doctor/providers/github/reasons.py +26 -0
  40. ci_doctorr-0.1.0/ci_doctor/providers/github/segmenter.py +84 -0
  41. ci_doctorr-0.1.0/ci_doctor/providers/gitlab/__init__.py +0 -0
  42. ci_doctorr-0.1.0/ci_doctor/providers/gitlab/provider.py +163 -0
  43. ci_doctorr-0.1.0/ci_doctor/providers/gitlab/reasons.py +31 -0
  44. ci_doctorr-0.1.0/ci_doctor/providers/gitlab/segmenter.py +108 -0
  45. ci_doctorr-0.1.0/ci_doctor/render/__init__.py +0 -0
  46. ci_doctorr-0.1.0/ci_doctor/render/json_out.py +11 -0
  47. ci_doctorr-0.1.0/ci_doctor/render/markdown.py +39 -0
  48. ci_doctorr-0.1.0/ci_doctor/render/terminal.py +75 -0
  49. ci_doctorr-0.1.0/docs/OFFLINE.md +35 -0
  50. ci_doctorr-0.1.0/docs/PLAN.md +224 -0
  51. ci_doctorr-0.1.0/docs/site/.gitignore +6 -0
  52. ci_doctorr-0.1.0/docs/site/README.md +17 -0
  53. ci_doctorr-0.1.0/docs/site/astro.config.mjs +8 -0
  54. ci_doctorr-0.1.0/docs/site/package-lock.json +4291 -0
  55. ci_doctorr-0.1.0/docs/site/package.json +14 -0
  56. ci_doctorr-0.1.0/docs/site/public/ci-doctor-icon.png +0 -0
  57. ci_doctorr-0.1.0/docs/site/public/ci-doctor-icon.svg +30 -0
  58. ci_doctorr-0.1.0/docs/site/public/ci-doctor-logo-dark.svg +37 -0
  59. ci_doctorr-0.1.0/docs/site/public/ci-doctor-logo.png +0 -0
  60. ci_doctorr-0.1.0/docs/site/public/ci-doctor-logo.svg +37 -0
  61. ci_doctorr-0.1.0/docs/site/public/favicon.svg +4 -0
  62. ci_doctorr-0.1.0/docs/site/src/layouts/Base.astro +52 -0
  63. ci_doctorr-0.1.0/docs/site/src/pages/cicd.astro +109 -0
  64. ci_doctorr-0.1.0/docs/site/src/pages/configuration.astro +122 -0
  65. ci_doctorr-0.1.0/docs/site/src/pages/index.astro +87 -0
  66. ci_doctorr-0.1.0/docs/site/src/pages/requirements.astro +54 -0
  67. ci_doctorr-0.1.0/docs/site/src/pages/usage.astro +72 -0
  68. ci_doctorr-0.1.0/docs/site/src/styles/global.css +146 -0
  69. ci_doctorr-0.1.0/docs/site/tsconfig.json +3 -0
  70. ci_doctorr-0.1.0/examples/github-actions.example.yml +36 -0
  71. ci_doctorr-0.1.0/examples/gitlab-ci.example.yml +21 -0
  72. ci_doctorr-0.1.0/pyproject.toml +49 -0
  73. ci_doctorr-0.1.0/tests/__init__.py +0 -0
  74. ci_doctorr-0.1.0/tests/conftest.py +19 -0
  75. ci_doctorr-0.1.0/tests/fixtures/expected/docker_build_oom.json +5 -0
  76. ci_doctorr-0.1.0/tests/fixtures/expected/exit1_unknown_reason.json +5 -0
  77. ci_doctorr-0.1.0/tests/fixtures/expected/go_test_failure.json +5 -0
  78. ci_doctorr-0.1.0/tests/fixtures/expected/maven_job_timeout.json +5 -0
  79. ci_doctorr-0.1.0/tests/fixtures/expected/missing_artifact_dependency.json +5 -0
  80. ci_doctorr-0.1.0/tests/fixtures/expected/missing_dependency.json +5 -0
  81. ci_doctorr-0.1.0/tests/fixtures/expected/no_runner_empty.json +5 -0
  82. ci_doctorr-0.1.0/tests/fixtures/expected/npm_build_failure.json +5 -0
  83. ci_doctorr-0.1.0/tests/fixtures/expected/oom_137.json +5 -0
  84. ci_doctorr-0.1.0/tests/fixtures/expected/pytest_failure_verbose.json +5 -0
  85. ci_doctorr-0.1.0/tests/fixtures/expected/runner_image_pull_failure.json +5 -0
  86. ci_doctorr-0.1.0/tests/fixtures/expected/runner_system_prepare.json +5 -0
  87. ci_doctorr-0.1.0/tests/fixtures/expected/script_failure_noisy.json +5 -0
  88. ci_doctorr-0.1.0/tests/fixtures/expected/timeout_unclosed_script.json +5 -0
  89. ci_doctorr-0.1.0/tests/fixtures/expected/warning_only_fetch.json +5 -0
  90. ci_doctorr-0.1.0/tests/fixtures/logs/docker_build_oom.log +54 -0
  91. ci_doctorr-0.1.0/tests/fixtures/logs/exit1_unknown_reason.log +7 -0
  92. ci_doctorr-0.1.0/tests/fixtures/logs/go_test_failure.log +52 -0
  93. ci_doctorr-0.1.0/tests/fixtures/logs/maven_job_timeout.log +53 -0
  94. ci_doctorr-0.1.0/tests/fixtures/logs/missing_artifact_dependency.log +51 -0
  95. ci_doctorr-0.1.0/tests/fixtures/logs/missing_dependency.log +5 -0
  96. ci_doctorr-0.1.0/tests/fixtures/logs/no_runner_empty.log +0 -0
  97. ci_doctorr-0.1.0/tests/fixtures/logs/npm_build_failure.log +60 -0
  98. ci_doctorr-0.1.0/tests/fixtures/logs/oom_137.log +6 -0
  99. ci_doctorr-0.1.0/tests/fixtures/logs/pytest_failure_verbose.log +65 -0
  100. ci_doctorr-0.1.0/tests/fixtures/logs/runner_image_pull_failure.log +50 -0
  101. ci_doctorr-0.1.0/tests/fixtures/logs/runner_system_prepare.log +6 -0
  102. ci_doctorr-0.1.0/tests/fixtures/logs/script_failure_noisy.log +24 -0
  103. ci_doctorr-0.1.0/tests/fixtures/logs/timeout_unclosed_script.log +7 -0
  104. ci_doctorr-0.1.0/tests/fixtures/logs/warning_only_fetch.log +11 -0
  105. ci_doctorr-0.1.0/tests/fixtures/sample.log +6 -0
  106. ci_doctorr-0.1.0/tests/test_analyze.py +27 -0
  107. ci_doctorr-0.1.0/tests/test_attribution_fixtures.py +44 -0
  108. ci_doctorr-0.1.0/tests/test_backends.py +79 -0
  109. ci_doctorr-0.1.0/tests/test_budget.py +17 -0
  110. ci_doctorr-0.1.0/tests/test_cli.py +38 -0
  111. ci_doctorr-0.1.0/tests/test_config.py +56 -0
  112. ci_doctorr-0.1.0/tests/test_denoise.py +50 -0
  113. ci_doctorr-0.1.0/tests/test_extract.py +37 -0
  114. ci_doctorr-0.1.0/tests/test_github_adapter.py +90 -0
  115. ci_doctorr-0.1.0/tests/test_gitlab_provider.py +84 -0
  116. ci_doctorr-0.1.0/tests/test_gitlab_reasons.py +16 -0
  117. ci_doctorr-0.1.0/tests/test_models.py +20 -0
  118. ci_doctorr-0.1.0/tests/test_mr_note.py +68 -0
  119. ci_doctorr-0.1.0/tests/test_network_guard.py +9 -0
  120. ci_doctorr-0.1.0/tests/test_offline_pipeline.py +34 -0
  121. ci_doctorr-0.1.0/tests/test_redact.py +30 -0
  122. ci_doctorr-0.1.0/tests/test_render.py +49 -0
  123. ci_doctorr-0.1.0/tests/test_report.py +142 -0
  124. ci_doctorr-0.1.0/tests/test_segmenter.py +70 -0
  125. ci_doctorr-0.1.0/tests/test_select.py +21 -0
  126. ci_doctorr-0.1.0/uv.lock +1935 -0
@@ -0,0 +1,10 @@
1
+ <!-- CODEGRAPH_START -->
2
+ ## CodeGraph
3
+
4
+ In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
5
+
6
+ - **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search.
7
+ - **Shell** (always works): `codegraph explore "<symbol names or question>"` prints the same output.
8
+
9
+ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
10
+ <!-- CODEGRAPH_END -->
@@ -0,0 +1,19 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "mcp__codegraph__*"
5
+ ]
6
+ },
7
+ "hooks": {
8
+ "UserPromptSubmit": [
9
+ {
10
+ "hooks": [
11
+ {
12
+ "type": "command",
13
+ "command": "codegraph prompt-hook"
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
19
+ }
@@ -0,0 +1,5 @@
1
+ # CodeGraph data files — local to each machine, not for committing.
2
+ # Ignore everything in .codegraph/ except this file itself, so transient
3
+ # files (the database, daemon.pid, sockets, logs) never show up in git.
4
+ *
5
+ !.gitignore
@@ -0,0 +1,58 @@
1
+ name: release
2
+
3
+ # On every push to master: analyze conventional commits, bump the version + tag,
4
+ # generate a GitHub Release from the commits, attach the Docker image as a gzipped
5
+ # tar, and publish to PyPI. If no releasable commit (feat/fix/BREAKING) is found, it no-ops.
6
+ on:
7
+ push:
8
+ branches: [master]
9
+
10
+ concurrency:
11
+ group: release
12
+ cancel-in-progress: false
13
+
14
+ permissions:
15
+ contents: write # push the version-bump commit + tag, create/upload the Release
16
+ id-token: write # PyPI Trusted Publishing (OIDC) — no stored token needed
17
+
18
+ jobs:
19
+ release:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0 # full history so commit analysis works
25
+
26
+ # Bump version (pyproject), update CHANGELOG.md, commit, tag vX.Y.Z, push,
27
+ # create the GitHub Release from the commits, and build the sdist+wheel into dist/.
28
+ - name: Python Semantic Release
29
+ id: release
30
+ uses: python-semantic-release/python-semantic-release@v9
31
+ with:
32
+ github_token: ${{ secrets.GITHUB_TOKEN }}
33
+
34
+ - name: Build Docker image and save as gzipped tar
35
+ if: ${{ steps.release.outputs.released == 'true' }}
36
+ run: |
37
+ IMAGE="ci-doctor:${{ steps.release.outputs.version }}"
38
+ docker build -t "$IMAGE" .
39
+ docker save "$IMAGE" | gzip -9 > "ci-doctor-${{ steps.release.outputs.version }}-image.tar.gz"
40
+
41
+ - name: Attach Docker image to the release
42
+ if: ${{ steps.release.outputs.released == 'true' }}
43
+ env:
44
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45
+ run: gh release upload "${{ steps.release.outputs.tag }}" "ci-doctor-${{ steps.release.outputs.version }}-image.tar.gz" --clobber
46
+
47
+ # Trusted Publishing: authenticates via OIDC (id-token: write above). No token/secret.
48
+ # Requires a matching publisher on PyPI (repo fennet82/ci-doctor, workflow release.yml).
49
+ - name: Publish distributions to PyPI
50
+ if: ${{ steps.release.outputs.released == 'true' }}
51
+ uses: pypa/gh-action-pypi-publish@release/v1
52
+
53
+ - name: Upload the built distributions to the GitHub Release
54
+ if: ${{ steps.release.outputs.released == 'true' }}
55
+ uses: python-semantic-release/publish-action@v9
56
+ with:
57
+ github_token: ${{ secrets.GITHUB_TOKEN }}
58
+ tag: ${{ steps.release.outputs.tag }}
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ # ci-doctor's own artifacts
9
+ report.md
10
+ report.json
11
+ # local Claude Code settings / temp
12
+ .claude/settings.local.json
13
+ .claude/*.tmp*
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "codegraph": {
4
+ "type": "stdio",
5
+ "command": "codegraph",
6
+ "args": [
7
+ "serve",
8
+ "--mcp"
9
+ ]
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,35 @@
1
+ # CHANGELOG
2
+
3
+
4
+ ## v0.1.0 (2026-07-24)
5
+
6
+ ### Documentation
7
+
8
+ - Astro documentation site + GitHub Actions example
9
+ ([`522cf47`](https://github.com/fennet82/ci-doctor/commit/522cf476ca450fd37bcfd06344518f642eeaaae5))
10
+
11
+ - docs/site: static Astro site (overview, requirements, configuration, usage, CI/CD examples) with a
12
+ shared layout, light/dark styles, and Shiki-highlighted snippets. Builds to dist/ (npm run build);
13
+ node_modules/dist gitignored. - examples/github-actions.example.yml: workflow_run-triggered
14
+ analyzer job - README: link to the docs site
15
+
16
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
17
+
18
+ - Refresh README (accurate test count, example endpoint); bump Astro to 7
19
+ ([`03aeabf`](https://github.com/fennet82/ci-doctor/commit/03aeabf4f6fc9a8babd1c09b386b1d506bec9a6a))
20
+
21
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22
+
23
+ ### Features
24
+
25
+ - Automated release pipeline and publishable package
26
+ ([`8339e19`](https://github.com/fennet82/ci-doctor/commit/8339e199cb5f90b3812d0888ef6db039653586a4))
27
+
28
+ Push to master -> python-semantic-release bumps the version from conventional commits, tags,
29
+ generates the GitHub Release, builds the sdist/wheel; the workflow then attaches the Docker image
30
+ (gzipped tar) to the release and publishes to PyPI via Trusted Publishing (OIDC, no token).
31
+
32
+ Distribution renamed to `ci-doctorr` (the `ci-doctor` name was taken on PyPI); the import package
33
+ `ci_doctor` and the CLI command `ci-doctor` are unchanged.
34
+
35
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -0,0 +1,10 @@
1
+ # Self-contained image. Built where there is internet; needs none at runtime
2
+ # (ci-doctor only ever talks to your configured GitLab and LLM endpoints).
3
+ FROM python:3.11-slim
4
+
5
+ WORKDIR /app
6
+ COPY . /app
7
+ RUN pip install --no-cache-dir .
8
+
9
+ # No network at job runtime, no telemetry, no update checks.
10
+ ENTRYPOINT ["ci-doctor"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elad Cohen
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,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: ci-doctorr
3
+ Version: 0.1.0
4
+ Summary: Postmortem CI failure analyzer: deterministic phase attribution plus one LLM explanation. Read-only, air-gap friendly.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: openai>=2.48.0
9
+ Requires-Dist: pydantic>=2.5
10
+ Requires-Dist: python-gitlab>=8.4.0
11
+ Requires-Dist: pyyaml>=6
12
+ Requires-Dist: typer>=0.12
13
+ Provides-Extra: all
14
+ Requires-Dist: anthropic>=0.40; extra == 'all'
15
+ Requires-Dist: litellm>=1.40; extra == 'all'
16
+ Provides-Extra: anthropic
17
+ Requires-Dist: anthropic>=0.40; extra == 'anthropic'
18
+ Provides-Extra: litellm
19
+ Requires-Dist: litellm>=1.40; extra == 'litellm'
@@ -0,0 +1,221 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/site/public/ci-doctor-logo-dark.svg">
4
+ <img src="docs/site/public/ci-doctor-logo.png" alt="ci-doctor" width="540">
5
+ </picture>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <strong>Root-cause analysis for CI/CD.</strong><br>
10
+ A postmortem step that runs when a pipeline fails, works out <em>where</em> and <em>why</em> it broke,
11
+ and emits a structured report — without ever touching your code.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white">
16
+ <img alt="Providers: GitLab | GitHub" src="https://img.shields.io/badge/providers-GitLab%20%7C%20GitHub-fc6d26">
17
+ <img alt="Read-only, always exit 0" src="https://img.shields.io/badge/read--only-always%20exit%200-2dd4bf">
18
+ <img alt="LLM: bring your own (optional)" src="https://img.shields.io/badge/LLM-bring%20your%20own%20·%20optional-1f6feb">
19
+ </p>
20
+
21
+ ---
22
+
23
+ When a pipeline fails, the logs are long and loud. An LLM handed the whole trace will
24
+ confidently blame the runner or a missing cache — because that text is alarming — even
25
+ when the real failure was an `exit code 1` three sections down. ci-doctor fixes that with
26
+ one rule:
27
+
28
+ > **Deterministic code decides *where* the job failed. The LLM only explains *why*.**
29
+
30
+ Phase attribution is a pure function of job metadata and log structure, computed **before**
31
+ any model is called. A loud, non-fatal `WARNING:` can never outrank the actual failure. If
32
+ the classifier is wrong, that's a bug with a failing test — not a prompt to tune.
33
+
34
+ ## Features
35
+
36
+ - **Deterministic phase attribution** — a pure, fully-tested classifier decides where a job
37
+ broke (`provision` · `prepare` · `fetch` · `script` · `post`) from metadata and log
38
+ structure. Non-fatal `WARNING:` lines can never be blamed.
39
+ - **Read-only and safe** — never edits code, commits, or opens MRs, and **always exits 0**,
40
+ so it can't change your pipeline's status or hide the real failure.
41
+ - **Useful with no LLM** — ships a deterministic report out of the box: phase, reason,
42
+ terminal command, evidence excerpt, and templated remediation.
43
+ - **Bring-your-own model** — optional LLM step via `openai` (any OpenAI-compatible endpoint),
44
+ `litellm`, `anthropic`, or the local `claude` CLI, selected by config.
45
+ - **GitLab & GitHub** — one provider-neutral core; the GitHub adapter was added with *zero*
46
+ changes to core.
47
+ - **Air-gap friendly** — no telemetry, no update checks, no runtime downloads; ship as a
48
+ self-contained Docker image or an offline wheel bundle.
49
+ - **Secret redaction** — scrubs secrets from the prompt *and* the report (round-trip tested).
50
+ - **Structured output** — a rich terminal report, `report.md` + `report.json` artifacts (with
51
+ a self-contained `handoff_prompt` you can paste into a coding agent), and one idempotent
52
+ MR/PR note.
53
+
54
+ ## How it works
55
+
56
+ ```
57
+ acquire → segment → attribute → denoise → extract → budget → (LLM) → render / deliver
58
+ └── deterministic: decides WHERE it failed ──┘ └── explains WHY ──┘
59
+ ```
60
+
61
+ 1. **Acquire** the failed jobs and their logs (a missing log is valid data — the
62
+ "never got a runner" case).
63
+ 2. **Segment** the trace into sections (GitLab `section_*` markers / GitHub `##[group]`).
64
+ 3. **Attribute** the failure to a phase — the pure classifier, first-match-wins precedence
65
+ ladder with a `WARNING:`-is-never-fatal rule.
66
+ 4. **Denoise / extract / budget** the blamed section into a small, high-signal evidence slice
67
+ (ANSI/CR/dedup denoising, anchored windows, token budgeting — every truncation is visible).
68
+ 5. **LLM (optional)** explains the cause *within* the already-decided phase, returning a
69
+ validated JSON report. Disabled or unreachable → deterministic report instead.
70
+ 6. **Render / deliver** to the terminal, `report.md`/`report.json`, and an idempotent MR/PR note.
71
+
72
+ ## Install
73
+
74
+ > On PyPI the distribution is **`ci-doctorr`** (the `ci-doctor` name was taken); the import package is `ci_doctor` and the command stays `ci-doctor`.
75
+
76
+ ```sh
77
+ git clone https://github.com/fennet82/ci-doctor
78
+ cd ci-doctor
79
+ uv sync # or: pip install .
80
+
81
+ # optional LLM backends:
82
+ uv sync --extra anthropic # or: pip install '.[anthropic]' / '.[litellm]' / '.[all]'
83
+ ```
84
+
85
+ Or build the self-contained image:
86
+
87
+ ```sh
88
+ docker build -t ci-doctor .
89
+ ```
90
+
91
+ ## Quickstart
92
+
93
+ ```sh
94
+ # Replay a captured log offline — no network, no LLM:
95
+ uv run ci-doctor analyze --from-file failing-job.log
96
+
97
+ # Against a live pipeline (reads $CI_PIPELINE_ID etc. inside CI):
98
+ uv run ci-doctor analyze "$CI_PIPELINE_ID"
99
+ ```
100
+
101
+ ```
102
+ ci-doctor analyze [RUN_ID] [OPTIONS]
103
+
104
+ --from-file PATH Replay a raw job log offline (no network, no LLM).
105
+ --job-id TEXT Analyze a single job id.
106
+ --config PATH Path to .ci-doctor.yml.
107
+ --no-color Disable coloured output (also honours NO_COLOR).
108
+ -v, --verbose Enable debug logging.
109
+ --version Show version and exit.
110
+ ```
111
+
112
+ ## Configuration
113
+
114
+ Layered and pydantic-validated — `defaults.yml` < repo `.ci-doctor.yml` < `CI_DOCTOR_*` env
115
+ < CLI flags. Unknown keys are an error. Minimal config:
116
+
117
+ ```yaml
118
+ provider: gitlab
119
+
120
+ gitlab:
121
+ base_url: https://gitlab.com # default; override for self-hosted
122
+ token_env: CI_DOCTOR_GITLAB_TOKEN # or gitlab.token_file for a secret mount
123
+
124
+ llm:
125
+ enabled: true # false => deterministic-only report
126
+ backend: openai # openai | litellm | anthropic | claude_code
127
+ model: qwen2.5-coder:32b
128
+ api_base: http://openai-compatible-endpoint.internal:8000/v1
129
+ ```
130
+
131
+ Nested env vars use `__`: `CI_DOCTOR_LLM__MODEL=…`. See the full reference in
132
+ [docs/site](docs/site) or [docs/PLAN.md §9](docs/PLAN.md).
133
+
134
+ ### LLM backends
135
+
136
+ | `llm.backend` | Calls | Needs |
137
+ |---|---|---|
138
+ | `openai` *(default)* | any OpenAI-compatible endpoint (Ollama, vLLM, llama.cpp, gateway) | `model` + `api_base` — base install |
139
+ | `litellm` | any [litellm](https://docs.litellm.ai/) provider | `model`; `pip install '.[litellm]'` |
140
+ | `anthropic` | Claude via the official SDK (defaults to `claude-opus-4-8`) | API key / `ant` profile; `'.[anthropic]'` |
141
+ | `claude_code` | the local `claude` CLI, headless | `claude` on PATH |
142
+
143
+ The LLM step is optional throughout — when it's disabled, unconfigured, or unreachable,
144
+ ci-doctor emits the deterministic report instead of failing.
145
+
146
+ ## Use it in CI
147
+
148
+ **GitLab** (`.gitlab-ci.yml`) — runs only on failure, always exits 0:
149
+
150
+ ```yaml
151
+ ci-doctor:
152
+ stage: .post
153
+ image: registry.internal.example.com/ci-doctor:latest
154
+ rules:
155
+ - when: on_failure
156
+ allow_failure: true
157
+ variables:
158
+ CI_DOCTOR_GITLAB_TOKEN: "$CI_DOCTOR_TOKEN"
159
+ script:
160
+ - ci-doctor analyze "$CI_PIPELINE_ID"
161
+ artifacts:
162
+ when: always
163
+ paths: [report.md, report.json]
164
+ ```
165
+
166
+ **GitHub Actions** — triggered on a failed workflow run. Full examples in
167
+ [`examples/`](examples/).
168
+
169
+ ## Air-gapped / offline
170
+
171
+ ci-doctor makes no network calls except to the GitLab/GitHub and LLM endpoints you
172
+ configure. Build once where there is internet, then ship inside — a Docker image, or an
173
+ offline wheel bundle (`pip install --no-index --find-links ./wheels ci-doctorr`). See
174
+ [docs/OFFLINE.md](docs/OFFLINE.md).
175
+
176
+ ## Documentation
177
+
178
+ A full documentation site (overview, requirements, configuration, usage, CI/CD examples)
179
+ lives in [`docs/site/`](docs/site) — built with Astro:
180
+
181
+ ```sh
182
+ cd docs/site && npm install && npm run dev # or: npm run build -> dist/
183
+ ```
184
+
185
+ The original design spec is [docs/PLAN.md](docs/PLAN.md).
186
+
187
+ ## Development
188
+
189
+ ```sh
190
+ uv sync
191
+ uv run pytest # 88 tests, no network, no LLM
192
+ ```
193
+
194
+ ```
195
+ ci_doctor/
196
+ cli.py typer entrypoint (always exits 0)
197
+ config/ pydantic schema + defaults.yml + layered loader
198
+ core/ provider-neutral: models, ports, attribution (pure), denoise,
199
+ extract, budget, redact, analyze
200
+ llm/ Report schema, prompt templates, backends (openai/litellm/anthropic/claude_code)
201
+ render/ terminal (rich), markdown, json
202
+ providers/
203
+ gitlab/ python-gitlab adapter + segmenter + reasons
204
+ github/ GitHub Actions adapter + segmenter + reasons
205
+ tests/ fixtures + golden-file attribution suite
206
+ docs/site/ Astro documentation site
207
+ examples/ .gitlab-ci.yml + GitHub Actions snippets
208
+ Dockerfile
209
+ ```
210
+
211
+ ### Design guardrails
212
+
213
+ - The LLM never selects the failure phase — that lives in `core/attribution.py`.
214
+ - `core/` depends on no provider or vendor SDK — `grep -riE 'gitlab|github|openai' ci_doctor/core/`
215
+ is clean. Adding the GitHub adapter required no core changes.
216
+ - `attribution.py` is a pure function — no I/O, network, or clock.
217
+ - Always exits 0 · never emits unredacted log content · every truncation is visible.
218
+
219
+ ## License
220
+
221
+ [MIT](LICENSE) © Elad Cohen
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("ci-doctorr") # PyPI distribution name (import pkg stays ci_doctor)
5
+ except PackageNotFoundError: # not installed (e.g. running from a raw checkout)
6
+ __version__ = "0.0.0"
@@ -0,0 +1,211 @@
1
+ """ci-doctor CLI (typer).
2
+
3
+ `analyze <pipeline_id>` against live GitLab, or `--from-file` to replay a raw log
4
+ offline. Both run the same pipeline: segment -> classify -> evidence -> report ->
5
+ render/deliver.
6
+
7
+ Guardrail #3: the analyzer must never change a pipeline's outcome, so `main()`
8
+ catches everything and always exits 0.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import typer
19
+
20
+ from ci_doctor import __version__
21
+ from ci_doctor.config.loader import load_config
22
+ from ci_doctor.core.models import FailureReason, Job, Run
23
+
24
+ app = typer.Typer(add_completion=False, help="Explain why a CI pipeline failed (read-only postmortem).")
25
+
26
+ log = logging.getLogger("ci_doctor.cli")
27
+
28
+
29
+ def _configure_logging(verbose: bool) -> None:
30
+ """`--verbose` (or CI_DOCTOR_LOG_LEVEL=DEBUG) turns on debug logs across ci_doctor.*."""
31
+ level_name = os.environ.get("CI_DOCTOR_LOG_LEVEL", "").upper()
32
+ level = logging.DEBUG if verbose else getattr(logging, level_name, logging.INFO)
33
+ logging.getLogger("ci_doctor").setLevel(level)
34
+ if verbose:
35
+ log.debug("verbose logging enabled")
36
+
37
+
38
+ def _version_callback(value: bool) -> None:
39
+ if value:
40
+ typer.echo(__version__)
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.callback()
45
+ def _root(
46
+ version: bool = typer.Option(
47
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
48
+ ),
49
+ ) -> None:
50
+ """Explain why a CI pipeline failed (read-only postmortem)."""
51
+
52
+
53
+ @app.command()
54
+ def analyze(
55
+ run_id: str = typer.Argument(None, help="Pipeline/run id. Omit when using --from-file."),
56
+ from_file: Path = typer.Option(
57
+ None, "--from-file", help="Replay a raw job log offline (no network, no LLM)."
58
+ ),
59
+ job_id: str = typer.Option(None, "--job-id", help="Analyze a single job id."),
60
+ config_path: Path = typer.Option(None, "--config", help="Path to .ci-doctor.yml."),
61
+ no_color: bool = typer.Option(False, "--no-color", help="Disable coloured terminal output."),
62
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
63
+ ) -> None:
64
+ _configure_logging(verbose)
65
+ cfg = load_config(repo_config=config_path)
66
+ log.debug("provider=%s from_file=%s run_id=%s job_id=%s", cfg.provider, from_file, run_id, job_id)
67
+
68
+ run = provider = None
69
+ if from_file is not None:
70
+ results = [_process(job, cfg) for job in _run_from_file(from_file).jobs]
71
+ elif run_id is not None:
72
+ run, provider, results = _analyze_live(run_id, cfg, job_id)
73
+ else:
74
+ typer.echo("nothing to do: pass a pipeline id, or --from-file to replay a log.", err=True)
75
+ return
76
+
77
+ _deliver(results, cfg, no_color=no_color)
78
+ _maybe_post_mr(provider, run, results, cfg)
79
+
80
+
81
+ def _make_provider(cfg):
82
+ # Import adapters lazily so `core` and the offline path never pull a provider in.
83
+ if cfg.provider == "gitlab":
84
+ from ci_doctor.providers.gitlab.provider import GitLabProvider
85
+
86
+ return GitLabProvider(cfg)
87
+ if cfg.provider == "github":
88
+ from ci_doctor.providers.github.provider import GitHubProvider
89
+
90
+ return GitHubProvider(cfg)
91
+ raise ValueError(f"unsupported provider: {cfg.provider}")
92
+
93
+
94
+ def _make_segmenter(cfg):
95
+ if cfg.provider == "github":
96
+ from ci_doctor.providers.github.segmenter import GitHubSegmenter
97
+
98
+ return GitHubSegmenter()
99
+ from ci_doctor.providers.gitlab.segmenter import GitLabSegmenter
100
+
101
+ return GitLabSegmenter()
102
+
103
+
104
+ def _analyze_live(run_id: str, cfg, job_id: str | None):
105
+ from ci_doctor.core.select import select_failed_jobs
106
+
107
+ provider = _make_provider(cfg)
108
+ run = provider.fetch_run(run_id)
109
+ jobs = select_failed_jobs(run.jobs, cfg.analysis.include_allowed_failures)
110
+ if job_id is not None:
111
+ jobs = [j for j in jobs if j.id == job_id]
112
+ log.debug("run %s: %d jobs, %d failed and selected", run_id, len(run.jobs), len(jobs))
113
+
114
+ selected = jobs[: cfg.analysis.max_jobs_analyzed]
115
+ log.info("analyzing %d failed job(s) from run %s", len(selected), run_id)
116
+ results = []
117
+ for job in selected:
118
+ job.log = provider.fetch_job_log(job)
119
+ results.append(_process(job, cfg))
120
+ return run, provider, results
121
+
122
+
123
+ def _run_from_file(path: Path) -> Run:
124
+ """Load a raw job log into the domain model for offline replay."""
125
+ log = path.read_text()
126
+ job = Job(id="local", name=path.stem, status="failed",
127
+ failure_reason=FailureReason.UNKNOWN, log=log)
128
+ return Run(id="local", jobs=[job])
129
+
130
+
131
+ def _process(job: Job, cfg):
132
+ """Segment + classify + assemble evidence + produce the report for one job.
133
+
134
+ Deterministic when the LLM is disabled/unconfigured; one LLM call otherwise.
135
+ GitLab log format is assumed (the only provider today); a provider-driven
136
+ segmenter arrives with M6.
137
+ """
138
+ from ci_doctor.core.analyze import build_bundle
139
+ from ci_doctor.core.attribution import attribute
140
+ from ci_doctor.core.phases import assign_phases
141
+ from ci_doctor.llm.report import produce_report
142
+
143
+ job.sections = _make_segmenter(cfg).segment(job.log or "")
144
+ log.debug("job %s: %d top-level sections", job.name, len(job.sections))
145
+ assign_phases(job.sections, cfg.phases)
146
+ attr = attribute(job, job.sections)
147
+ log.debug("job %s: attribution phase=%s reason=%s rule=%s confidence=%s",
148
+ job.name, attr.phase, attr.reason, attr.rule_id, attr.confidence)
149
+ bundle = build_bundle(job, attr, job.sections, cfg)
150
+ report = produce_report(job, attr, bundle, cfg)
151
+ log.debug("job %s: report category=%s confidence=%s infra=%s",
152
+ job.name, report.category, report.confidence, report.is_infra_not_code)
153
+ return job, attr, report
154
+
155
+
156
+ def _deliver(results, cfg, *, no_color: bool) -> None:
157
+ import json
158
+ import os
159
+
160
+ from ci_doctor.render.markdown import MarkdownRenderer
161
+ from ci_doctor.render.terminal import render_terminal
162
+
163
+ if not results:
164
+ typer.echo("no failed jobs to analyze.")
165
+ return
166
+
167
+ reports = [report for *_, report in results]
168
+ if cfg.output.terminal:
169
+ wrap = os.environ.get("GITLAB_CI") == "true" # collapsible only inside GitLab CI
170
+ for report in reports:
171
+ render_terminal(report, no_color=no_color, wrap_section=wrap)
172
+
173
+ md = "\n\n---\n\n".join(MarkdownRenderer().render(r) for r in reports)
174
+ Path(cfg.output.markdown_path).write_text(md)
175
+ Path(cfg.output.json_path).write_text(json.dumps([r.model_dump(mode="json") for r in reports], indent=2))
176
+ typer.echo(f"wrote {cfg.output.markdown_path} and {cfg.output.json_path}", err=True)
177
+
178
+
179
+ def _maybe_post_mr(provider, run, results, cfg) -> None:
180
+ if not cfg.output.mr_note or provider is None or run is None or run.mr is None:
181
+ return
182
+ reports = [report for *_, report in results]
183
+ if not any(r.confidence in ("medium", "high") for r in reports): # user's gate
184
+ typer.echo("MR note skipped: confidence below medium.", err=True)
185
+ return
186
+
187
+ from ci_doctor.render.markdown import MarkdownRenderer
188
+
189
+ body = "\n\n---\n\n".join(MarkdownRenderer().render(r) for r in reports)
190
+ marker = f"<!-- ci-doctor:pipeline:{run.id} -->"
191
+ try:
192
+ provider.post_note(run.mr, body, marker)
193
+ typer.echo("posted/updated MR note.", err=True)
194
+ except Exception as exc: # noqa: BLE001 - delivery must never break the run
195
+ typer.echo(f"MR note failed (ignored): {exc}", err=True)
196
+
197
+
198
+ def main() -> None:
199
+ logging.basicConfig(level=logging.INFO, format="ci-doctor [%(levelname)s] %(message)s")
200
+ try:
201
+ app()
202
+ except SystemExit:
203
+ # typer/click signal both normal and usage exits via SystemExit; force 0.
204
+ raise SystemExit(0)
205
+ except BaseException as exc: # noqa: BLE001 - analyzer must never alter pipeline outcome
206
+ print(f"ci-doctor: internal error, exiting 0 to preserve pipeline status: {exc}", file=sys.stderr)
207
+ raise SystemExit(0)
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
File without changes