Skip to main content
Glama
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.