Files
2026-09-28 15:09:16 +00:00

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)

  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 <gitea-container> --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 <gitea-container> 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.