ci-guardian
README.md
# ci-guardian
A Python MCP server plus a GitHub Actions integration that lets headless Claude
review PRs and triage CI failures on a real GitHub repository, with every
GitHub *write* action routed exclusively through the MCP server (never raw
`git`/`gh`), and that boundary enforced structurally via Claude Code hooks
rather than left as a prompt-level suggestion.
## Status
- [x] Phase 0 — Scaffold and ground rules
- [x] Phase 1 — The ops MCP server (github plugin)
- [x] Phase 2 — Hooks and permissions (structural, not advisory)
- [x] Phase 3 — Headless Claude: PR review and CI-failure triage
- [~] Phase 4 — GitHub Actions: the loop closes itself (deployed, live
trigger pending a real `CLAUDE_CODE_OAUTH_TOKEN` — see below)
- [ ] Phase 5 — Large mechanical change, proposed as a draft PR (capstone)
## Layout
```
src/ci_guardian_mcp/ # the MCP server package
tests/ # unit tests (mocked GitHub client, no live API calls)
scripts/ # headless entry points + live smoke test (Phase 1+)
.github/workflows/ # the Actions workflow that triggers headless Claude (Phase 4)
.claude/settings.json # PreToolUse/PostToolUse hooks (Phase 2)
```
## Local dev
```
python -m venv .venv
./.venv/Scripts/python.exe -m pip install -e ".[dev]"
./.venv/Scripts/python.exe -m pytest -q
./.venv/Scripts/python.exe -m ruff check .
./.venv/Scripts/python.exe -m mypy --strict src/
```
## Headless Claude (Phase 3+)
`scripts/review_pr.sh <repo> <pr_number>` and
`scripts/triage_ci_failure.sh <repo> <run_id>` each run a single
`claude -p "..." --mcp-config .mcp.json --permission-mode bypassPermissions
--output-format json` invocation, no human in the loop. Two choices worth
calling out:
- **`--mcp-config .mcp.json`** points Claude at this project's MCP server
explicitly (`.mcp.json` in the project root) rather than relying on
auto-discovery, so headless runs don't depend on whatever's configured
globally.
- **`--permission-mode bypassPermissions`** skips the interactive
tool-approval prompt, which would otherwise hang forever with no human
to answer it. This is safe specifically *because* Phase 2's hooks are a
separate enforcement layer that still runs regardless of permission
mode — bypassing the "ask a human" gate doesn't bypass the PreToolUse
hooks that block force-pushes, `gh pr merge`, blind patches, etc. That's
the whole point of building hooks as a structural boundary rather than
a permission-prompt convention: it's what makes an unattended run safe
to leave running.
Both scripts need the `claude` CLI on PATH, a resolvable GitHub token
(same resolution as the MCP server itself: `GITHUB_TOKEN`/`GH_TOKEN`, or
`gh auth login` already run), and Claude Code's own auth. In GitHub
Actions (Phase 4) that's a `CLAUDE_CODE_OAUTH_TOKEN` repo secret — a
long-lived token from `claude setup-token` (needs an interactive Claude
subscription login, so it's generated on a real machine, not a runner)
rather than a raw `ANTHROPIC_API_KEY`.
**Live status:** `.github/workflows/ci-guardian.yml` is deployed to
[ci-guardian-sandbox](https://github.com/dishagupta2901/ci-guardian-sandbox)
and its plumbing is proven live — checkout, `pip install`, and the Claude
Code CLI install all succeeded on a real run
([#4](https://github.com/dishagupta2901/ci-guardian-sandbox/pull/4)). The
`CLAUDE_CODE_OAUTH_TOKEN` secret is currently a placeholder
(`PLACEHOLDER_SET_YOUR_REAL_CLAUDE_CODE_OAUTH_TOKEN`), so the `claude -p`
step will fail auth until it's replaced with a real token from
`claude setup-token` (interactive, needs a Claude subscription — run it on
a real machine, then `gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo
dishagupta2901/ci-guardian-sandbox` with the printed value). Once that's
set, pushing any commit to an open PR (or opening a new one) re-triggers
`review-pr` for real, and pushing a branch with a failing test triggers
`triage-ci-failure` once its CI run completes.
## Tool transport: PyGithub, not the `gh` CLI
The MCP server talks to GitHub through **PyGithub** (`dependencies` in
`pyproject.toml`), not by shelling out to `gh`. Reasoning:
- **Testability without a subprocess boundary.** Phase 1 requires unit tests
against a *mocked* GitHub client with zero real API calls in `pytest -q`.
Mocking a Python object (`Github(...)`) with `unittest.mock`/`pytest-mock`
is direct; mocking a CLI means faking subprocess exit codes and parsing its
stdout/stderr, which is slower to write and easier to get subtly wrong.
- **Typed responses map cleanly onto the required Pydantic models.** Every
tool in Phase 1 must return a typed Pydantic model and raise a typed
`ToolError` on failure. PyGithub gives structured objects
(`PullRequest`, `WorkflowRun`, `GithubException` with a real status code)
to build those from; `gh`'s JSON output would need re-parsing and its
error signal is a shell exit code plus stderr text, which is a worse fit
for the `{not_found, invalid_input, upstream_timeout, upstream_unavailable,
rate_limited, conflict}` taxonomy this project requires.
- **No dependency on an external binary being installed/authenticated on
every host.** The MCP server is meant to run inside a GitHub Actions job
in Phase 4; a pure-Python HTTP client (PyGithub) only needs a token in the
environment. `gh` would need to be installed and `gh auth login`'d (or
`GH_TOKEN` wired through) in every environment the server runs in,
duplicating auth plumbing the MCP server already needs for its own client.
- **Trade-off acknowledged:** the `gh` CLI is what Phase 2's hooks are
written *against* — hooks block the **agent** from calling `git push
--force`, `gh pr merge`, `gh pr edit --add-label auto-merge` etc. directly
via Bash. That's a separate concern from what the MCP server uses
internally: the hooks close off the raw-shell bypass path regardless of
which library sits behind the MCP tools.
PyGithub is added to `pyproject.toml` `dependencies` now; no tool
implementation lands until Phase 1.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues