# 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) 1. Push this project to a Gitea repo (e.g. `selimaj-dev/claude-hook`). 2. In Coolify: **+ New Resource -> Public Repository**, build pack **Docker Compose**, pointing at that repo. 3. Edit `docker-compose.yml`'s `networks.gitea-network.name` to the actual Docker network your Gitea container is on (`docker inspect --format '{{json .NetworkSettings.Networks}}'`). 4. Set the environment variables listed above in Coolify's UI. Leave **Domains** empty — this service has no public route by design. 5. Deploy, then confirm from Gitea's container: ``` docker exec wget -qO- http://claude-hook:3000/healthz ``` 6. In Gitea: Site Administration -> Integrations -> Webhooks -> **System Webhooks** -> Add, URL `http://claude-hook:3000/`, trigger on **Issue Comment** (and **Issues**, if you want `@claude` in a fresh issue body to work too). To also support pull requests, enable **Pull Request** (for `@claude` in 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 set `WEBHOOK_SECRET`, put the same value here. 7. Add `GITEA__webhook__ALLOWED_HOST_LIST=private` to 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 all - `ignored` with a `reason` field — why an event didn't trigger a run - `trigger matched` / `job started` / `job finished` — a run went through - `job 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_LOGIN` checks 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.