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.
- ci_doctorr-0.1.0/.claude/CLAUDE.md +10 -0
- ci_doctorr-0.1.0/.claude/settings.json +19 -0
- ci_doctorr-0.1.0/.codegraph/.gitignore +5 -0
- ci_doctorr-0.1.0/.github/workflows/release.yml +58 -0
- ci_doctorr-0.1.0/.gitignore +13 -0
- ci_doctorr-0.1.0/.mcp.json +12 -0
- ci_doctorr-0.1.0/CHANGELOG.md +35 -0
- ci_doctorr-0.1.0/Dockerfile +10 -0
- ci_doctorr-0.1.0/LICENSE +21 -0
- ci_doctorr-0.1.0/PKG-INFO +19 -0
- ci_doctorr-0.1.0/README.md +221 -0
- ci_doctorr-0.1.0/ci_doctor/__init__.py +6 -0
- ci_doctorr-0.1.0/ci_doctor/cli.py +211 -0
- ci_doctorr-0.1.0/ci_doctor/config/__init__.py +0 -0
- ci_doctorr-0.1.0/ci_doctor/config/defaults.yml +83 -0
- ci_doctorr-0.1.0/ci_doctor/config/loader.py +97 -0
- ci_doctorr-0.1.0/ci_doctor/config/schema.py +105 -0
- ci_doctorr-0.1.0/ci_doctor/core/__init__.py +0 -0
- ci_doctorr-0.1.0/ci_doctor/core/analyze.py +85 -0
- ci_doctorr-0.1.0/ci_doctor/core/attribution.py +195 -0
- ci_doctorr-0.1.0/ci_doctor/core/budget.py +35 -0
- ci_doctorr-0.1.0/ci_doctor/core/denoise.py +69 -0
- ci_doctorr-0.1.0/ci_doctor/core/extract.py +82 -0
- ci_doctorr-0.1.0/ci_doctor/core/models.py +96 -0
- ci_doctorr-0.1.0/ci_doctor/core/phases.py +33 -0
- ci_doctorr-0.1.0/ci_doctor/core/ports.py +42 -0
- ci_doctorr-0.1.0/ci_doctor/core/redact.py +75 -0
- ci_doctorr-0.1.0/ci_doctor/core/select.py +17 -0
- ci_doctorr-0.1.0/ci_doctor/llm/__init__.py +0 -0
- ci_doctorr-0.1.0/ci_doctor/llm/backends.py +135 -0
- ci_doctorr-0.1.0/ci_doctor/llm/client.py +64 -0
- ci_doctorr-0.1.0/ci_doctor/llm/prompts/analyze.system.txt +9 -0
- ci_doctorr-0.1.0/ci_doctor/llm/prompts/analyze.user.txt +8 -0
- ci_doctorr-0.1.0/ci_doctor/llm/report.py +164 -0
- ci_doctorr-0.1.0/ci_doctor/llm/schema.py +48 -0
- ci_doctorr-0.1.0/ci_doctor/providers/__init__.py +0 -0
- ci_doctorr-0.1.0/ci_doctor/providers/github/__init__.py +0 -0
- ci_doctorr-0.1.0/ci_doctor/providers/github/provider.py +149 -0
- ci_doctorr-0.1.0/ci_doctor/providers/github/reasons.py +26 -0
- ci_doctorr-0.1.0/ci_doctor/providers/github/segmenter.py +84 -0
- ci_doctorr-0.1.0/ci_doctor/providers/gitlab/__init__.py +0 -0
- ci_doctorr-0.1.0/ci_doctor/providers/gitlab/provider.py +163 -0
- ci_doctorr-0.1.0/ci_doctor/providers/gitlab/reasons.py +31 -0
- ci_doctorr-0.1.0/ci_doctor/providers/gitlab/segmenter.py +108 -0
- ci_doctorr-0.1.0/ci_doctor/render/__init__.py +0 -0
- ci_doctorr-0.1.0/ci_doctor/render/json_out.py +11 -0
- ci_doctorr-0.1.0/ci_doctor/render/markdown.py +39 -0
- ci_doctorr-0.1.0/ci_doctor/render/terminal.py +75 -0
- ci_doctorr-0.1.0/docs/OFFLINE.md +35 -0
- ci_doctorr-0.1.0/docs/PLAN.md +224 -0
- ci_doctorr-0.1.0/docs/site/.gitignore +6 -0
- ci_doctorr-0.1.0/docs/site/README.md +17 -0
- ci_doctorr-0.1.0/docs/site/astro.config.mjs +8 -0
- ci_doctorr-0.1.0/docs/site/package-lock.json +4291 -0
- ci_doctorr-0.1.0/docs/site/package.json +14 -0
- ci_doctorr-0.1.0/docs/site/public/ci-doctor-icon.png +0 -0
- ci_doctorr-0.1.0/docs/site/public/ci-doctor-icon.svg +30 -0
- ci_doctorr-0.1.0/docs/site/public/ci-doctor-logo-dark.svg +37 -0
- ci_doctorr-0.1.0/docs/site/public/ci-doctor-logo.png +0 -0
- ci_doctorr-0.1.0/docs/site/public/ci-doctor-logo.svg +37 -0
- ci_doctorr-0.1.0/docs/site/public/favicon.svg +4 -0
- ci_doctorr-0.1.0/docs/site/src/layouts/Base.astro +52 -0
- ci_doctorr-0.1.0/docs/site/src/pages/cicd.astro +109 -0
- ci_doctorr-0.1.0/docs/site/src/pages/configuration.astro +122 -0
- ci_doctorr-0.1.0/docs/site/src/pages/index.astro +87 -0
- ci_doctorr-0.1.0/docs/site/src/pages/requirements.astro +54 -0
- ci_doctorr-0.1.0/docs/site/src/pages/usage.astro +72 -0
- ci_doctorr-0.1.0/docs/site/src/styles/global.css +146 -0
- ci_doctorr-0.1.0/docs/site/tsconfig.json +3 -0
- ci_doctorr-0.1.0/examples/github-actions.example.yml +36 -0
- ci_doctorr-0.1.0/examples/gitlab-ci.example.yml +21 -0
- ci_doctorr-0.1.0/pyproject.toml +49 -0
- ci_doctorr-0.1.0/tests/__init__.py +0 -0
- ci_doctorr-0.1.0/tests/conftest.py +19 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/docker_build_oom.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/exit1_unknown_reason.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/go_test_failure.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/maven_job_timeout.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/missing_artifact_dependency.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/missing_dependency.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/no_runner_empty.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/npm_build_failure.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/oom_137.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/pytest_failure_verbose.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/runner_image_pull_failure.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/runner_system_prepare.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/script_failure_noisy.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/timeout_unclosed_script.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/expected/warning_only_fetch.json +5 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/docker_build_oom.log +54 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/exit1_unknown_reason.log +7 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/go_test_failure.log +52 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/maven_job_timeout.log +53 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/missing_artifact_dependency.log +51 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/missing_dependency.log +5 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/no_runner_empty.log +0 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/npm_build_failure.log +60 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/oom_137.log +6 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/pytest_failure_verbose.log +65 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/runner_image_pull_failure.log +50 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/runner_system_prepare.log +6 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/script_failure_noisy.log +24 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/timeout_unclosed_script.log +7 -0
- ci_doctorr-0.1.0/tests/fixtures/logs/warning_only_fetch.log +11 -0
- ci_doctorr-0.1.0/tests/fixtures/sample.log +6 -0
- ci_doctorr-0.1.0/tests/test_analyze.py +27 -0
- ci_doctorr-0.1.0/tests/test_attribution_fixtures.py +44 -0
- ci_doctorr-0.1.0/tests/test_backends.py +79 -0
- ci_doctorr-0.1.0/tests/test_budget.py +17 -0
- ci_doctorr-0.1.0/tests/test_cli.py +38 -0
- ci_doctorr-0.1.0/tests/test_config.py +56 -0
- ci_doctorr-0.1.0/tests/test_denoise.py +50 -0
- ci_doctorr-0.1.0/tests/test_extract.py +37 -0
- ci_doctorr-0.1.0/tests/test_github_adapter.py +90 -0
- ci_doctorr-0.1.0/tests/test_gitlab_provider.py +84 -0
- ci_doctorr-0.1.0/tests/test_gitlab_reasons.py +16 -0
- ci_doctorr-0.1.0/tests/test_models.py +20 -0
- ci_doctorr-0.1.0/tests/test_mr_note.py +68 -0
- ci_doctorr-0.1.0/tests/test_network_guard.py +9 -0
- ci_doctorr-0.1.0/tests/test_offline_pipeline.py +34 -0
- ci_doctorr-0.1.0/tests/test_redact.py +30 -0
- ci_doctorr-0.1.0/tests/test_render.py +49 -0
- ci_doctorr-0.1.0/tests/test_report.py +142 -0
- ci_doctorr-0.1.0/tests/test_segmenter.py +70 -0
- ci_doctorr-0.1.0/tests/test_select.py +21 -0
- 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,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,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"]
|
ci_doctorr-0.1.0/LICENSE
ADDED
|
@@ -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,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
|