4.8 KiB
claude-hook
Listens for Gitea webhooks and runs Claude Code against an issue or pull
request whenever the configured owner mentions @claude in a comment or
issue body. On success it commits any changes Claude made, pushes a branch,
opens (or reuses) a pull request, and replies on the issue with a summary.
Failures are reported the same way, as a comment, so nothing is silent.
Layout
src/
config.js env parsing + validation, fails fast on startup
logger.js structured JSON logging to stdout/stderr
exec.js process spawning with timeout + output capture
gitea.js Gitea API client (issues, pulls, comments)
git.js git clone/checkout/commit/push for one job's working dir
claude.js invokes the `claude` CLI non-interactively
prompt.js builds the prompt sent to Claude
events.js pure webhook-payload -> trigger classification logic
jobQueue.js prevents overlapping runs for the same issue/PR
job.js orchestrates one end-to-end run
server.js HTTP server: signature check, parse, classify, dispatch
app.js wires the above together
index.js process entrypoint, signal handling
test/ node:test unit + integration tests, no external services
Every module that touches the outside world (HTTP, git, subprocess) is
constructed with its dependencies passed in, so test/ exercises the real
logic against fakes rather than a real Gitea instance or a real claude
binary.
Running the tests
npm test
No dependencies to install — everything uses Node's built-in node:test,
fetch and child_process. Requires Node 20+.
Configuration
All via environment variables, validated on startup (a bad or missing value exits immediately with a clear error rather than failing later):
| Variable | Required | Default | Notes |
|---|---|---|---|
GITEA_URL |
yes | — | e.g. http://gitea:3000 internally, or https://git.example.com |
GITEA_TOKEN |
yes | — | access token for the bot user, needs write access to target repos |
CLAUDE_CODE_OAUTH_TOKEN |
yes | — | from claude setup-token |
OWNER_LOGIN |
yes | — | only comments/issues from this Gitea username trigger a run |
WEBHOOK_SECRET |
no | unset | if set, requires a valid X-Gitea-Signature header |
TRIGGER_PHRASE |
no | @claude |
|
CLAUDE_MODEL |
no | sonnet |
|
CLAUDE_MAX_TURNS |
no | 25 |
|
JOB_TIMEOUT_MS |
no | 1200000 (20 min) |
|
PORT |
no | 3000 |
|
LOG_LEVEL |
no | info |
debug | info | warn | error |
See .env.example.
Deploying (Coolify, Docker Compose build pack)
- Push this project to a Gitea repo (e.g.
selimaj-dev/claude-hook). - In Coolify: + New Resource -> Public Repository, build pack Docker Compose, pointing at that repo.
- Edit
docker-compose.yml'snetworks.gitea-network.nameto the actual Docker network your Gitea container is on (docker inspect <gitea-container> --format '{{json .NetworkSettings.Networks}}'). - Set the environment variables listed above in Coolify's UI. Leave Domains empty — this service has no public route by design.
- Deploy, then confirm from Gitea's container:
docker exec <gitea-container> wget -qO- http://claude-hook:3000/healthz - In Gitea: Site Administration -> Integrations -> Webhooks -> System
Webhooks -> Add, URL
http://claude-hook:3000/, trigger on Issue Comment (and Issues, if you want@claudein a fresh issue body to work too). To also support pull requests, enable Pull Request (for@claudein a fresh PR description), Pull Request Comment (general PR comments — routed the same as issue comments), Pull Request Review, and Pull Request Review Comment (inline diff comments). If you setWEBHOOK_SECRET, put the same value here. - Add
GITEA__webhook__ALLOWED_HOST_LIST=privateto Gitea's own environment and restart it — Gitea refuses to call private addresses otherwise.
Logs
Every log line is one JSON object on stdout (or stderr for warn/error),
so docker logs claude-hook / Coolify's log viewer is directly greppable
or pipeable into jq. Key events to look for:
webhook received— Gitea reached the service at allignoredwith areasonfield — why an event didn't trigger a runtrigger matched/job started/job finished— a run went throughjob errored— something failed; the same message was also posted as a Gitea comment on the issue
Known limitations
- Runs execute inside this one long-lived container, not a fresh sandbox
per job — don't point
OWNER_LOGINchecks loosely, and don't run this against repos where you don't trust every collaborator who can comment. - Uses your Claude subscription (via
claude setup-token), so runs share the same usage limits as interactive Claude Code / claude.ai use.