A lightweight FastAPI service that listens for Jira webhooks and autonomously implements tickets using the Claude Agent SDK.
When a Jira issue webhook fires, this service:
- Pulls the latest branch from the remote
- Creates a git worktree on a branch
ticket/<jira-key>(e.g.ticket/proj-1) - Runs Claude Code to implement the ticket (up to 50 turns,
acceptEditspermission mode) - (optional) Runs a second Claude Code session to code-review the uncommitted changes
- (optional) If review/lint/test issues are found, resumes the session to fix them
- Commits, pushes the branch, and opens a PR via
gh
The webhook handler responds with 202 Accepted immediately — all processing is
fire-and-forget on the event loop.
Every ticket gets its own git worktree under <WORKSPACE_DIR>/worktrees/<branch>,
checked out from a single shared clone in <WORKSPACE_DIR>/repo. The shared clone is
git fetched (clone-if-missing-else-fetch) so the object store is reused across runs,
while each worktree is a fully isolated working directory branched off origin/main.
This means concurrent tickets never stomp on each other's files, there's no
git checkout thrash on a shared tree, and cleanup is a single git worktree remove.
Under DRY_RUN the worktree is deliberately left in place (with a copy-pasteable
git worktree remove … --force hint) so you can inspect exactly what Claude produced.
Implementation is only step one. The pipeline runs opt-in quality gates, and each one feeds its failures back into the same Claude session to be fixed:
implement ──▶ code-review ──▶ (issues?) ──▶ resume & fix
└─▶ run_lint ──▶ (failed?) ──▶ resume & fix
└─▶ run_tests ──▶ (failed?) ──▶ resume & fix
└─▶ commit ▸ push ▸ open PR
Because review-fix, lint-fix, and test-fix all resume the original session_id,
Claude keeps full context of what it just wrote instead of cold-starting — it fixes its
own lint and test failures the way a developer would. Lint and tests run against the
target repo's toolchain (pnpm-based), not this service.
The Claude Agent SDK doesn't run in-process — it spawns the @anthropic-ai/claude-code
Node CLI over stdio. To hide that cold-start, claude_code.warm() opens the SDK
connection before the first prompt is built, so the initial turn isn't paying
connection-setup latency.
Jira retries created/updated events, so handle_ticket holds an in-flight lock
(an _active_tickets set keyed by ticket key) — duplicate webhooks for a ticket already
running are dropped, not double-processed. The webhook itself returns 202 instantly and
hands the work to a FastAPI BackgroundTask, wrapped in a top-level try/except so a
failure is logged against the ticket key rather than silently swallowed.
The webhook is protected by three independent layers:
- Cloudflare Access service token at the edge (see deployment below).
- A static
X-Webhook-Tokenshared-token header matchingWEBHOOK_SECRET(required ≥16 chars in production, no-op in dev). A static token rather than a body HMAC because Jira Automation can only send fixed header values. - Rate limiting via
slowapi(10/minute).
config.py fails fast at startup in production if REPO_URL, ANTHROPIC_API_KEY,
GH_TOKEN, or a valid WEBHOOK_SECRET is missing.
Each owned service (claude_code, github, checks, langfuse, runner) is a
self-contained package: base.py (ABC) · client.py (concrete) · mock_client.py
(offline/test) · factory.py (@lru_cache singleton) · __init__.py (re-exports).
RunnerClient receives its collaborators by dependency injection rather than
importing free functions:
Settings (config.py)
→ factories (@lru_cache singletons)
→ lifespan builds singletons onto app.state
→ api/dependencies.py injects them into routes (RunnerDep)
The mock_client variants let the whole pipeline run offline with zero network calls,
which is exactly what the test suite exercises.
Every git / gh / lint / test invocation uses argument-list subprocess form
(no shell), eliminating shell-injection risk. All Claude SDK runs are traced end-to-end
via Langfuse (instrumented in lifespan, flushed after each ticket) — no on-disk run
logs, full traces in the dashboard.
uv sync --extra dev # create .venv and install deps (incl. dev tools)
cp .env.example .env # set REPO_URL (or REPO_DIR for local dev)# Develop (hot-reload)
uv run uvicorn app.main:app --reload --port 3001
# Start (production)
uv run auto-code
# Test
uv run pytest
# Lint
uv run ruff check .app/
├── main.py # FastAPI app bootstrap, /health endpoint
├── config.py # pydantic-settings env validation
├── logger.py # Minimal structured JSON logger (stdout/stderr)
├── models.py # Pydantic models (Ticket, PipelineOptions, Jira payload)
├── routes/
│ └── jira.py # POST /webhook/jira — parses payload, fires runner
├── api/
│ └── dependencies.py # DI providers — injects app.state singletons (RunnerDep)
├── services/ # each is a base/client/mock_client/factory package
│ ├── runner/ # Orchestrates the full implementation pipeline
│ ├── claude_code/ # Wraps Claude Agent SDK query() / ClaudeSDKClient
│ ├── github/ # subprocess wrappers for git + gh (clone/worktree/commit/PR)
│ ├── checks/ # run_lint / run_tests against the target repo
│ └── langfuse/ # Claude SDK tracing instrumentation
└── prompts/
└── ticket_prompts.py # build_implementation/review/fix prompts
| Variable | Default | Description |
|---|---|---|
CLAUDE_CODE_API_PORT |
3001 |
HTTP port the server listens on |
NODE_ENV |
development |
development or production |
WORKSPACE_DIR |
/workspace |
Volume root; repo cloned to <WORKSPACE_DIR>/repo, worktrees under <WORKSPACE_DIR>/worktrees |
REPO_URL |
(unset) | HTTPS clone URL of the target repo. Required in production |
REPO_DIR |
current dir | Legacy dev/dry-run fallback: path to a pre-existing local repo |
ANTHROPIC_API_KEY |
(unset) | Read by the spawned Claude Code CLI. Required in production |
GH_TOKEN |
(unset) | GitHub token for HTTPS clone/push. Required in production |
WEBHOOK_SECRET |
(unset) | Static X-Webhook-Token value. Required in production (≥16 chars); no-op in dev |
DRY_RUN |
false |
Skip commit/push/PR; leave worktree in place for inspection |
LANGFUSE_PUBLIC_KEY |
(unset) | Langfuse public key (set with secret key to enable tracing) |
LANGFUSE_SECRET_KEY |
(unset) | Langfuse secret key (both keys required to enable tracing) |
LANGFUSE_BASE_URL |
https://cloud.langfuse.com |
Langfuse host |
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness check — returns { "status": "ok" } |
| POST | /webhook/jira |
Jira issue webhook — triggers ticket processing |
{
"issue": {
"key": "PROJ-1",
"fields": {
"summary": "Add dark mode toggle",
"description": "Optional detailed description"
}
}
}services/claude_code/client.py uses query() and ClaudeSDKClient from
claude_agent_sdk. Runs are traced via Langfuse.
Two modes:
run_implementation—permission_mode="acceptEdits", up to 50 turns. Writes code but does not commit. Optionally reuses a pre-warmed client or resumes a session.run_code_review—permission_mode="bypassPermissions", up to 10 turns. Output is parsed forHAS_ISSUES: true|falseand aFINDINGS:block.
The SDK does not run the agent in-process — it spawns the
@anthropic-ai/claude-code(Node) CLI over stdio, so the container ships both Python and Node.js + the Claude Code CLI.
services/github/client.py and services/checks/client.py shell out to git / gh /
the target repo's lint+test tooling using argument-list subprocess calls (no shell).
Requires git in PATH and gh authenticated (gh auth login / GH_TOKEN).
The service is containerized (Dockerfile — Python + uv + Node + Claude Code CLI + git +
gh, non-root) and deployed via compose/auto-code.yml, which runs a cloudflared
tunnel alongside the app with no published ports. Inbound Jira webhooks reach the
service only through the Cloudflare edge, gated by a Cloudflare Access service token —
the host itself exposes nothing to the public internet.
docker build -t auto-code:latest .
docker compose --env-file .env -f compose/auto-code.yml up -dSee deployment-plan.md for the full isolated-host + Cloudflare Tunnel/Access setup.
For local development without the tunnel, expose the dev server directly:
# 1. Start the service
uv run uvicorn app.main:app --reload --port 3001
# 2. Expose it
ngrok http 3001
# 3. Register the public URL in Jira as a webhook:
# https://<your-subdomain>.ngrok-free.app/webhook/jira