From 34b7dc977af176ceb8fbe55fc75b81a7b7057cc6 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Mon, 28 Sep 2026 16:15:50 +0200 Subject: [PATCH] updated --- Dockerfile | 33 +++++++++-- README.md | 107 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 19 +++++- package.json | 15 +++++ src/app.js | 50 ++++++++++++++++ src/claude.js | 33 +++++++++++ src/config.js | 51 ++++++++++++++++ src/events.js | 62 ++++++++++++++++++++ src/exec.js | 84 +++++++++++++++++++++++++++ src/git.js | 62 ++++++++++++++++++++ src/gitea.js | 74 ++++++++++++++++++++++++ src/index.js | 62 ++++++++++++++++++++ src/job.js | 109 +++++++++++++++++++++++++++++++++++ src/jobQueue.js | 35 +++++++++++ src/logger.js | 61 ++++++++++++++++++++ src/prompt.js | 14 +++++ src/server.js | 108 ++++++++++++++++++++++++++++++++++ test/config.test.js | 50 ++++++++++++++++ test/events.test.js | 92 +++++++++++++++++++++++++++++ test/exec.test.js | 34 +++++++++++ test/gitea.test.js | 74 ++++++++++++++++++++++++ test/job.test.js | 131 ++++++++++++++++++++++++++++++++++++++++++ test/jobQueue.test.js | 55 ++++++++++++++++++ test/logger.test.js | 67 +++++++++++++++++++++ test/server.test.js | 121 ++++++++++++++++++++++++++++++++++++++ 25 files changed, 1595 insertions(+), 8 deletions(-) create mode 100644 README.md create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/claude.js create mode 100644 src/config.js create mode 100644 src/events.js create mode 100644 src/exec.js create mode 100644 src/git.js create mode 100644 src/gitea.js create mode 100644 src/index.js create mode 100644 src/job.js create mode 100644 src/jobQueue.js create mode 100644 src/logger.js create mode 100644 src/prompt.js create mode 100644 src/server.js create mode 100644 test/config.test.js create mode 100644 test/events.test.js create mode 100644 test/exec.test.js create mode 100644 test/gitea.test.js create mode 100644 test/job.test.js create mode 100644 test/jobQueue.test.js create mode 100644 test/logger.test.js create mode 100644 test/server.test.js diff --git a/Dockerfile b/Dockerfile index 71c4fe0..08e20de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,31 @@ +# syntax=docker/dockerfile:1 FROM node:22-slim -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ + +# git: needed to clone/commit/push. ca-certificates: TLS to Gitea/Anthropic. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ && rm -rf /var/lib/apt/lists/* \ - && npm install -g @anthropic-ai/claude-code + && npm install -g @anthropic-ai/claude-code \ + && npm cache clean --force + WORKDIR /app -COPY server.mjs . -USER node -CMD ["node", "server.mjs"] \ No newline at end of file + +# No runtime dependencies today (package.json has none), but this keeps +# the image correct if any are added later, and gets Docker's layer cache. +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund || true + +COPY src ./src + +# Run as an unprivileged user rather than root. +RUN useradd --create-home --uid 1000 claude \ + && chown -R claude:claude /app +USER claude + +ENV NODE_ENV=production +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..780311c --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# 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). 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 + +- Only `issue_comment` and `issues` events are handled; PR *review* comments + (`pull_request_review_comment`) are a separate event type and are not + wired up. +- 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. diff --git a/docker-compose.yml b/docker-compose.yml index 237bb45..3db2532 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,27 @@ services: build: . container_name: claude-hook restart: unless-stopped + # No ports/domains published: this service is only reachable from other + # containers on the shared network below (i.e. Gitea calls it directly + # by container name). Nothing on the public internet can reach it. environment: - - GITEA_URL - - GITEA_TOKEN + - GITEA_URL # e.g. http://:3000 + - GITEA_TOKEN # access token for the "claude" Gitea user - CLAUDE_CODE_OAUTH_TOKEN + - OWNER_LOGIN # e.g. selimaj-dev — only this user's comments trigger a run + - WEBHOOK_SECRET=${WEBHOOK_SECRET:-} + - CLAUDE_MODEL=${CLAUDE_MODEL:-sonnet} + - CLAUDE_MAX_TURNS=${CLAUDE_MAX_TURNS:-25} + - JOB_TIMEOUT_MS=${JOB_TIMEOUT_MS:-1200000} + - LOG_LEVEL=${LOG_LEVEL:-info} + - PORT=3000 networks: - gitea-network networks: gitea-network: external: true - name: bqiohrkncfda0as0f7lrdk7j \ No newline at end of file + # Replace with your actual Docker network name/id, e.g. the one shown + # by `docker network ls` for the network Gitea's container is on + # (find it with: docker inspect --format '{{json .NetworkSettings.Networks}}'). + name: bqiohrkncfda0as0f7lrdk7j diff --git a/package.json b/package.json new file mode 100644 index 0000000..f5622bd --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "claude-hook", + "version": "1.0.0", + "description": "Runs Claude Code against Gitea issue/PR comments that mention @claude", + "type": "module", + "private": true, + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": {} +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..ae24cba --- /dev/null +++ b/src/app.js @@ -0,0 +1,50 @@ +// Wires config + logger into the concrete dependencies (Gitea client, +// Claude runner, job runner, queue) and returns an HTTP server ready to +// listen. Kept separate from index.js so tests can build an app with fake +// dependencies without touching process.env or opening real sockets. + +import { GiteaClient } from './gitea.js'; +import { ClaudeRunner } from './claude.js'; +import { JobRunner } from './job.js'; +import { JobQueue } from './jobQueue.js'; +import { createServer } from './server.js'; + +export function buildApp(config, logger) { + const gitea = new GiteaClient({ + baseUrl: config.giteaUrl, + token: config.giteaToken, + logger: logger.child({ component: 'gitea' }), + }); + + const claude = new ClaudeRunner({ + oauthToken: config.claudeOauthToken, + model: config.model, + maxTurns: config.maxTurns, + timeoutMs: config.jobTimeoutMs, + logger: logger.child({ component: 'claude' }), + }); + + const jobRunner = new JobRunner({ + gitea, + claude, + config, + logger: logger.child({ component: 'job' }), + }); + + const queue = new JobQueue(logger.child({ component: 'queue' })); + + const server = createServer({ + config, + logger: logger.child({ component: 'server' }), + onTrigger: (trigger, log) => { + const key = `${trigger.repo}#${trigger.issueNumber}`; + queue.runExclusive(key, () => jobRunner.run(trigger)).catch((err) => { + // runExclusive already catches job errors internally; this is a + // final safety net so a bug there can never crash the process. + log.error('unexpected error scheduling job', { error: err }); + }); + }, + }); + + return { server, gitea, claude, jobRunner, queue }; +} diff --git a/src/claude.js b/src/claude.js new file mode 100644 index 0000000..cef7dc1 --- /dev/null +++ b/src/claude.js @@ -0,0 +1,33 @@ +// Wraps invoking the `claude` CLI as a single-shot, non-interactive agent +// run. The OAuth token is passed through env only (never argv), so it +// never appears in process listings or in logged command lines. + +import { run } from './exec.js'; + +export class ClaudeRunner { + constructor({ oauthToken, model, maxTurns, timeoutMs, logger }) { + this.oauthToken = oauthToken; + this.model = model; + this.maxTurns = maxTurns; + this.timeoutMs = timeoutMs; + this.logger = logger; + } + + /** Runs Claude Code against `cwd` with `prompt`. Returns trimmed stdout. */ + async runOnce(cwd, prompt) { + const env = { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: this.oauthToken }; + const args = [ + '-p', prompt, + '--permission-mode', 'acceptEdits', + '--model', this.model, + '--max-turns', String(this.maxTurns), + ]; + const start = Date.now(); + const { stdout, stderr } = await run('claude', args, { cwd, env, timeoutMs: this.timeoutMs }); + this.logger?.info('claude run completed', { + durationMs: Date.now() - start, + stderrPreview: stderr ? stderr.slice(0, 500) : undefined, + }); + return stdout.trim(); + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..37b251b --- /dev/null +++ b/src/config.js @@ -0,0 +1,51 @@ +// All configuration is read from the environment once, at startup, and +// validated eagerly so a misconfiguration fails fast with a clear message +// instead of surfacing later as a confusing runtime error. + +const REQUIRED = ['GITEA_URL', 'GITEA_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'OWNER_LOGIN']; + +/** + * Builds and validates config from an env-like object. Exported so tests + * can pass a fake env instead of mutating process.env. + */ +export function loadConfig(env = process.env) { + const missing = REQUIRED.filter((key) => !env[key]); + if (missing.length > 0) { + throw new Error(`Missing required environment variable(s): ${missing.join(', ')}`); + } + + const giteaUrl = env.GITEA_URL.replace(/\/+$/, ''); + let parsedUrl; + try { + parsedUrl = new URL(giteaUrl); + } catch { + throw new Error(`GITEA_URL is not a valid URL: ${env.GITEA_URL}`); + } + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new Error(`GITEA_URL must be http or https: ${env.GITEA_URL}`); + } + + const port = Number.parseInt(env.PORT ?? '3000', 10); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`PORT must be a valid port number, got: ${env.PORT}`); + } + + const jobTimeoutMs = Number.parseInt(env.JOB_TIMEOUT_MS ?? '', 10) || 20 * 60_000; + const maxTurns = Number.parseInt(env.CLAUDE_MAX_TURNS ?? '', 10) || 25; + const model = env.CLAUDE_MODEL || 'sonnet'; + const webhookSecret = env.WEBHOOK_SECRET || null; + + return { + giteaUrl, + giteaToken: env.GITEA_TOKEN, + claudeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, + ownerLogin: env.OWNER_LOGIN, + port, + jobTimeoutMs, + maxTurns, + model, + webhookSecret, + triggerPhrase: env.TRIGGER_PHRASE || '@claude', + logLevel: (env.LOG_LEVEL || 'info').toLowerCase(), + }; +} diff --git a/src/events.js b/src/events.js new file mode 100644 index 0000000..f69a9bc --- /dev/null +++ b/src/events.js @@ -0,0 +1,62 @@ +// Pure logic for deciding whether an incoming Gitea webhook payload should +// trigger a Claude run, and for extracting the fields the rest of the +// service needs. Kept free of I/O so it's cheap to unit test exhaustively. + +/** + * @typedef {object} Trigger + * @property {string} repo full_name, e.g. "owner/repo" + * @property {number} issueNumber + * @property {string} issueTitle + * @property {string} issueBody + * @property {boolean} isPullRequest + * @property {string} defaultBranch + * @property {string} requestText the comment or issue body containing the trigger phrase + * @property {string} author + */ + +/** + * Decides whether a webhook event should trigger a run and, if so, extracts + * a Trigger. Returns { trigger: null, reason } when it should be ignored. + */ +export function classifyEvent(eventName, payload, { ownerLogin, triggerPhrase }) { + if (!payload || typeof payload !== 'object') { + return { trigger: null, reason: 'empty or invalid payload' }; + } + + let author; + let text; + + if (eventName === 'issue_comment' && payload.action === 'created') { + author = payload.comment?.user?.login; + text = payload.comment?.body; + } else if (eventName === 'issues' && ['opened', 'edited'].includes(payload.action)) { + author = payload.issue?.user?.login; + text = payload.issue?.body; + } else { + return { trigger: null, reason: `event "${eventName}" action "${payload.action}" is not handled` }; + } + + if (author !== ownerLogin) { + return { trigger: null, reason: `author "${author}" is not the configured owner "${ownerLogin}"` }; + } + if (!text || !text.includes(triggerPhrase)) { + return { trigger: null, reason: `text does not contain trigger phrase "${triggerPhrase}"` }; + } + if (!payload.repository?.full_name || !payload.issue?.number) { + return { trigger: null, reason: 'payload is missing repository or issue information' }; + } + + return { + trigger: { + repo: payload.repository.full_name, + issueNumber: payload.issue.number, + issueTitle: payload.issue.title ?? '', + issueBody: payload.issue.body ?? '', + isPullRequest: Boolean(payload.issue.pull_request), + defaultBranch: payload.repository.default_branch || 'main', + requestText: text, + author, + }, + reason: null, + }; +} diff --git a/src/exec.js b/src/exec.js new file mode 100644 index 0000000..2ac1530 --- /dev/null +++ b/src/exec.js @@ -0,0 +1,84 @@ +// Thin wrapper around child_process, isolated in its own module so tests +// can inject a fake runner instead of spawning real processes. + +import { spawn } from 'node:child_process'; + +export class ProcessError extends Error { + constructor(command, args, code, signal, stdout, stderr) { + super(`${command} ${args.join(' ')} exited with code ${code}${signal ? ` (signal ${signal})` : ''}`); + this.name = 'ProcessError'; + this.command = command; + this.args = args; + this.code = code; + this.signal = signal; + this.stdout = stdout; + this.stderr = stderr; + } +} + +export class ProcessTimeoutError extends Error { + constructor(command, args, timeoutMs) { + super(`${command} ${args.join(' ')} timed out after ${timeoutMs}ms`); + this.name = 'ProcessTimeoutError'; + this.command = command; + this.args = args; + this.timeoutMs = timeoutMs; + } +} + +const MAX_BUFFER = 20 * 1024 * 1024; + +/** + * Runs a command to completion and resolves with its output, or rejects + * with ProcessError / ProcessTimeoutError. Truncates captured output to + * MAX_BUFFER bytes rather than buffering without limit. + */ +export function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let timer = null; + + const finish = (fn, value) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + fn(value); + }; + + child.stdout.on('data', (chunk) => { + stdoutBytes += chunk.length; + if (stdoutBytes <= MAX_BUFFER) stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderrBytes += chunk.length; + if (stderrBytes <= MAX_BUFFER) stderr += chunk; + }); + + child.on('error', (err) => finish(reject, err)); + + child.on('close', (code, signal) => { + if (code === 0) { + finish(resolve, { stdout, stderr }); + } else { + finish(reject, new ProcessError(command, args, code, signal, stdout, stderr)); + } + }); + + if (options.timeoutMs) { + timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(reject, new ProcessTimeoutError(command, args, options.timeoutMs)); + }, options.timeoutMs); + } + }); +} diff --git a/src/git.js b/src/git.js new file mode 100644 index 0000000..05faa13 --- /dev/null +++ b/src/git.js @@ -0,0 +1,62 @@ +// Git operations for a single job's working directory. The token is passed +// via a git http.extraHeader env override rather than embedded in the +// clone URL, so it never appears in argv (visible via `ps aux`) or in any +// URL that gets logged. + +import { run } from './exec.js'; + +export function gitAuthEnv(token, baseEnv = process.env) { + return { + ...baseEnv, + GIT_TERMINAL_PROMPT: '0', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.extraHeader', + GIT_CONFIG_VALUE_0: `Authorization: token ${token}`, + }; +} + +export class GitRepo { + constructor({ dir, giteaUrl, token, logger }) { + this.dir = dir; + this.giteaUrl = giteaUrl.replace(/\/+$/, ''); + this.env = gitAuthEnv(token); + this.logger = logger; + } + + async #git(...args) { + this.logger?.debug('git', { args }); + return run('git', args, { cwd: this.dir, env: this.env }); + } + + async clone(repo, ref) { + const url = `${this.giteaUrl}/${repo}.git`; + await run('git', ['clone', '--depth', '50', '--branch', ref, url, '.'], { + cwd: this.dir, + env: this.env, + }); + } + + /** Checks out `branch`, resuming it from the remote if it already exists. */ + async checkoutWorkBranch(branch) { + try { + await this.#git('fetch', 'origin', branch); + await this.#git('checkout', '-B', branch, 'FETCH_HEAD'); + } catch { + await this.#git('checkout', '-B', branch); + } + } + + async hasChanges() { + const { stdout } = await this.#git('status', '--porcelain'); + return stdout.trim().length > 0; + } + + async commitAll(message, { name = 'claude', email = 'noreply@anthropic.com' } = {}) { + await this.#git('add', '-A'); + await this.#git('-c', `user.name=${name}`, '-c', `user.email=${email}`, 'commit', '-m', message); + } + + async push(branch) { + await this.#git('push', 'origin', `HEAD:${branch}`); + } +} diff --git a/src/gitea.js b/src/gitea.js new file mode 100644 index 0000000..fa89468 --- /dev/null +++ b/src/gitea.js @@ -0,0 +1,74 @@ +// A minimal Gitea API client covering exactly the endpoints this service +// needs. Kept dependency-free (global fetch, available since Node 18) and +// injectable (accepts a custom `fetchImpl` for tests). + +export class GiteaApiError extends Error { + constructor(method, path, status, body) { + super(`Gitea API ${method} ${path} -> ${status}: ${body.slice(0, 300)}`); + this.name = 'GiteaApiError'; + this.method = method; + this.path = path; + this.status = status; + this.body = body; + } +} + +export class GiteaClient { + constructor({ baseUrl, token, logger, fetchImpl = fetch }) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.token = token; + this.logger = logger; + this.fetchImpl = fetchImpl; + } + + async #request(method, path, body) { + const url = `${this.baseUrl}/api/v1${path}`; + const res = await this.fetchImpl(url, { + method, + headers: { + Authorization: `token ${this.token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + this.logger?.debug('gitea api call', { method, path, status: res.status }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new GiteaApiError(method, path, res.status, text); + } + if (res.status === 204) return null; + const text = await res.text(); + return text ? JSON.parse(text) : null; + } + + /** Returns the pull request, or null if `n` is an issue, not a PR. */ + async getPullRequest(repo, n) { + try { + return await this.#request('GET', `/repos/${repo}/pulls/${n}`); + } catch (err) { + if (err instanceof GiteaApiError && err.status === 404) return null; + throw err; + } + } + + async listOpenPullRequests(repo) { + return this.#request('GET', `/repos/${repo}/pulls?state=open`); + } + + async createPullRequest(repo, { title, head, base, body }) { + return this.#request('POST', `/repos/${repo}/pulls`, { title, head, base, body }); + } + + async createIssueComment(repo, issueNumber, body) { + return this.#request('POST', `/repos/${repo}/issues/${issueNumber}/comments`, { body }); + } + + /** Repo clone URL with the token embedded, for git-over-http auth. */ + authenticatedCloneUrl(repo) { + const u = new URL(`${this.baseUrl}/${repo}.git`); + u.username = 'oauth2'; + u.password = this.token; + return u.toString(); + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e90910b --- /dev/null +++ b/src/index.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// Process entrypoint: load config, build the app, start listening, and +// handle shutdown signals so an in-flight job's logs aren't cut off by an +// abrupt container stop. + +import { loadConfig } from './config.js'; +import { createLogger } from './logger.js'; +import { buildApp } from './app.js'; + +const logger = createLogger({ component: 'claude-hook' }); + +let config; +try { + config = loadConfig(); +} catch (err) { + logger.error('invalid configuration, exiting', { error: err }); + process.exit(1); +} + +logger.info('starting', { + giteaUrl: config.giteaUrl, + ownerLogin: config.ownerLogin, + model: config.model, + maxTurns: config.maxTurns, + jobTimeoutMs: config.jobTimeoutMs, + webhookSignatureEnabled: Boolean(config.webhookSecret), + port: config.port, +}); + +const { server, queue } = buildApp(config, logger); + +server.listen(config.port, () => { + logger.info('listening', { port: config.port }); +}); + +function shutdown(signal) { + logger.info('shutdown signal received', { signal }); + server.close(() => { + logger.info('http server closed'); + process.exit(0); + }); + // If a job is still running, give it a grace period rather than exiting + // immediately underneath it; force-exit afterward so the container + // doesn't hang on a stuck job forever. + setTimeout(() => { + logger.warn('forcing exit after shutdown grace period', { + stillRunning: queue.running.size, + }); + process.exit(0); + }, 30_000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +process.on('unhandledRejection', (reason) => { + logger.error('unhandled promise rejection', { error: reason }); +}); +process.on('uncaughtException', (err) => { + logger.error('uncaught exception', { error: err }); + process.exit(1); +}); diff --git a/src/job.js b/src/job.js new file mode 100644 index 0000000..d6634e4 --- /dev/null +++ b/src/job.js @@ -0,0 +1,109 @@ +// Orchestrates one end-to-end run: determine the branch, check out the +// repo, run Claude, commit/push if anything changed, open or reuse a PR, +// and always post a reply comment (including on failure). + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { GitRepo } from './git.js'; +import { buildPrompt } from './prompt.js'; + +const MAX_COMMENT_BODY = 60_000; // stay well under Gitea's comment size limit + +function truncate(text, max) { + return text.length > max ? text.slice(0, max) + '\n\n[truncated]' : text; +} + +export class JobRunner { + constructor({ gitea, claude, config, logger, gitFactory = (opts) => new GitRepo(opts) }) { + this.gitea = gitea; + this.claude = claude; + this.config = config; + this.logger = logger; + this.gitFactory = gitFactory; + } + + async run(trigger) { + const key = `${trigger.repo}#${trigger.issueNumber}`; + const log = this.logger.child({ key }); + log.info('job started', { isPullRequest: trigger.isPullRequest }); + + let workDir; + try { + workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-hook-')); + const reply = await this.#execute(trigger, workDir, log); + await this.gitea.createIssueComment(trigger.repo, trigger.issueNumber, truncate(reply, MAX_COMMENT_BODY)); + log.info('job finished'); + } catch (err) { + log.error('job errored', { error: err }); + const message = err?.stderr ? String(err.stderr) : err?.message || String(err); + await this.gitea + .createIssueComment( + trigger.repo, + trigger.issueNumber, + `Claude run failed:\n\n\`\`\`\n${truncate(message, 4000)}\n\`\`\``, + ) + .catch((commentErr) => log.error('could not post failure comment', { error: commentErr })); + } finally { + if (workDir) { + await fs.rm(workDir, { recursive: true, force: true }).catch((rmErr) => + log.warn('failed to clean up work dir', { workDir, error: rmErr }), + ); + } + } + } + + async #execute(trigger, workDir, log) { + const { repo, issueNumber, defaultBranch, isPullRequest } = trigger; + + let ref = defaultBranch; + if (isPullRequest) { + const pr = await this.gitea.getPullRequest(repo, issueNumber); + if (!pr) throw new Error(`issue #${issueNumber} looked like a PR but the PR lookup returned nothing`); + ref = pr.head.ref; + } + const branch = isPullRequest ? ref : `claude/issue-${issueNumber}`; + log.info('resolved target branch', { ref, branch, isPullRequest }); + + const git = this.gitFactory({ dir: workDir, giteaUrl: this.gitea.baseUrl, token: this.gitea.token, logger: log }); + await git.clone(repo, ref); + if (!isPullRequest) { + await git.checkoutWorkBranch(branch); + } + + const prompt = buildPrompt(trigger); + log.info('running claude', { promptLength: prompt.length }); + let reply = await this.claude.runOnce(workDir, prompt); + if (!reply) reply = '(Claude produced no textual output.)'; + + if (await git.hasChanges()) { + log.info('changes detected, committing'); + await git.commitAll(`Claude: address #${issueNumber}`); + await git.push(branch); + + if (!isPullRequest) { + const prUrl = await this.#openOrReusePullRequest(trigger, branch, reply); + reply += `\n\nPull request: ${prUrl}`; + } + } else { + log.info('no changes made'); + } + + return reply; + } + + async #openOrReusePullRequest(trigger, branch, reply) { + const { repo, issueNumber, issueTitle, defaultBranch } = trigger; + const open = await this.gitea.listOpenPullRequests(repo); + const existing = open.find((pr) => pr.head.ref === branch); + if (existing) return existing.html_url; + + const pr = await this.gitea.createPullRequest(repo, { + title: `Claude: ${issueTitle}`, + head: branch, + base: defaultBranch, + body: `Closes #${issueNumber}\n\n${truncate(reply, 4000)}`, + }); + return pr.html_url; + } +} diff --git a/src/jobQueue.js b/src/jobQueue.js new file mode 100644 index 0000000..9a13df5 --- /dev/null +++ b/src/jobQueue.js @@ -0,0 +1,35 @@ +// Prevents two runs for the same issue/PR from overlapping (e.g. a +// duplicate webhook delivery, or a fast follow-up comment while a run is +// still in progress). Keyed by "repo#issueNumber". + +export class JobQueue { + constructor(logger) { + this.running = new Set(); + this.logger = logger; + } + + /** + * Runs `fn()` for `key` unless a job for that key is already running, in + * which case it logs and returns without calling `fn`. Errors thrown by + * `fn` are caught and logged here so a bad job never crashes the process + * or leaves the key stuck as "running". + */ + async runExclusive(key, fn) { + if (this.running.has(key)) { + this.logger.warn('job already running for key, skipping', { key }); + return; + } + this.running.add(key); + try { + await fn(); + } catch (err) { + this.logger.error('job failed', { key, error: err }); + } finally { + this.running.delete(key); + } + } + + isRunning(key) { + return this.running.has(key); + } +} diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..1735bab --- /dev/null +++ b/src/logger.js @@ -0,0 +1,61 @@ +// Structured JSON logger. No dependencies, stdout only — Coolify/Docker +// captures stdout as the log stream, so there is nothing else to configure. +// Each line is one JSON object: { time, level, msg, ...fields }. + +const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; + +function resolveLevel() { + const configured = (process.env.LOG_LEVEL || 'info').toLowerCase(); + return LEVELS[configured] !== undefined ? configured : 'info'; +} + +const minLevel = LEVELS[resolveLevel()]; + +function write(level, msg, fields) { + if (LEVELS[level] < minLevel) return; + const record = { + time: new Date().toISOString(), + level, + msg, + ...fields, + }; + const line = JSON.stringify(record, safeReplacer()); + if (level === 'error' || level === 'warn') { + process.stderr.write(line + '\n'); + } else { + process.stdout.write(line + '\n'); + } +} + +// Prevents JSON.stringify from throwing on circular structures (e.g. an +// Error with a `cause` cycle, or accidentally logging a raw HTTP object). +function safeReplacer() { + const seen = new WeakSet(); + return (_key, value) => { + if (value instanceof Error) { + return { name: value.name, message: value.message, stack: value.stack }; + } + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]'; + seen.add(value); + } + return value; + }; +} + +/** + * Creates a logger bound to a set of fields (e.g. a request id or job key), + * so every subsequent call carries that context without repeating it. + */ +export function createLogger(bindings = {}) { + const withFields = (extra) => ({ ...bindings, ...extra }); + return { + debug: (msg, fields) => write('debug', msg, withFields(fields)), + info: (msg, fields) => write('info', msg, withFields(fields)), + warn: (msg, fields) => write('warn', msg, withFields(fields)), + error: (msg, fields) => write('error', msg, withFields(fields)), + child: (extra) => createLogger(withFields(extra)), + }; +} + +export const logger = createLogger({ component: 'claude-hook' }); diff --git a/src/prompt.js b/src/prompt.js new file mode 100644 index 0000000..c7a41ce --- /dev/null +++ b/src/prompt.js @@ -0,0 +1,14 @@ +// Builds the prompt sent to Claude Code for a triggered job. Isolated so +// its wording can be tuned and tested without touching job orchestration. + +export function buildPrompt({ repo, issueNumber, issueTitle, issueBody, requestText }) { + return [ + `Repository: ${repo}, issue #${issueNumber}: ${issueTitle}`, + issueBody || '(no description)', + `Request from the maintainer:\n${requestText}`, + 'Make the requested changes by editing files in this checkout. Do not run git commands ' + + '(commit, push, branch) — the calling process handles version control. ' + + 'End your response with a short, plain-text summary of what you changed, ' + + 'or your answer directly if no code change was needed.', + ].join('\n\n'); +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..c9c9d9a --- /dev/null +++ b/src/server.js @@ -0,0 +1,108 @@ +// The webhook HTTP endpoint. Deliberately small: read the body, verify the +// signature if configured, classify the event, and hand off to the job +// queue. All decisions are logged so a silent drop is always explainable +// from the logs alone. + +import http from 'node:http'; +import crypto from 'node:crypto'; +import { classifyEvent } from './events.js'; + +const MAX_BODY_BYTES = 5 * 1024 * 1024; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let bytes = 0; + req.on('data', (chunk) => { + bytes += chunk.length; + if (bytes > MAX_BODY_BYTES) { + req.destroy(); + reject(new Error('request body too large')); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function verifySignature(secret, signatureHeader, rawBody) { + if (!secret) return true; + const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + const given = signatureHeader || ''; + const a = Buffer.from(expected); + const b = Buffer.from(given); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +/** + * Builds the HTTP server. `onTrigger(trigger)` is called (fire-and-forget + * from the server's point of view — the caller decides how to queue it) + * whenever an incoming event should start a job. + */ +export function createServer({ config, logger, onTrigger }) { + return http.createServer(async (req, res) => { + const requestId = crypto.randomUUID(); + const log = logger.child({ requestId }); + + if (req.method === 'GET' && req.url === '/healthz') { + res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + + let rawBody; + try { + rawBody = await readBody(req); + } catch (err) { + log.warn('failed to read request body', { error: err }); + res.writeHead(413).end(); + return; + } + + const eventName = req.headers['x-gitea-event']; + const delivery = req.headers['x-gitea-delivery']; + log.info('webhook received', { event: eventName, delivery, bytes: rawBody.length }); + + if (!verifySignature(config.webhookSecret, req.headers['x-gitea-signature'], rawBody)) { + log.warn('rejected webhook: invalid signature', { delivery }); + res.writeHead(401).end(); + return; + } + + // Acknowledge immediately; Gitea only cares that we accepted delivery. + // The job itself can run far longer than any sane webhook timeout. + res.writeHead(202).end('accepted'); + + if (!eventName) { + log.debug('ignored: no X-Gitea-Event header (likely a health check)'); + return; + } + + let payload; + try { + payload = JSON.parse(rawBody.toString('utf8')); + } catch (err) { + log.warn('ignored: invalid JSON payload', { error: err }); + return; + } + + const { trigger, reason } = classifyEvent(eventName, payload, config); + if (!trigger) { + log.info('ignored', { reason }); + return; + } + + log.info('trigger matched', { + repo: trigger.repo, + issueNumber: trigger.issueNumber, + isPullRequest: trigger.isPullRequest, + }); + onTrigger(trigger, log); + }); +} diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..9b186a4 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; + +const validEnv = { + GITEA_URL: 'https://git.example.com/', + GITEA_TOKEN: 'tok', + CLAUDE_CODE_OAUTH_TOKEN: 'oauth', + OWNER_LOGIN: 'selimaj-dev', +}; + +test('loads valid config and strips trailing slash from url', () => { + const cfg = loadConfig(validEnv); + assert.equal(cfg.giteaUrl, 'https://git.example.com'); + assert.equal(cfg.port, 3000); + assert.equal(cfg.model, 'sonnet'); + assert.equal(cfg.maxTurns, 25); + assert.equal(cfg.webhookSecret, null); +}); + +test('throws listing all missing required vars', () => { + assert.throws(() => loadConfig({}), /GITEA_URL.*GITEA_TOKEN.*CLAUDE_CODE_OAUTH_TOKEN.*OWNER_LOGIN/s); +}); + +test('throws on invalid GITEA_URL', () => { + assert.throws(() => loadConfig({ ...validEnv, GITEA_URL: 'not a url' }), /not a valid URL/); +}); + +test('throws on non-http(s) protocol', () => { + assert.throws(() => loadConfig({ ...validEnv, GITEA_URL: 'ftp://example.com' }), /http or https/); +}); + +test('throws on invalid PORT', () => { + assert.throws(() => loadConfig({ ...validEnv, PORT: 'abc' }), /valid port number/); + assert.throws(() => loadConfig({ ...validEnv, PORT: '0' }), /valid port number/); + assert.throws(() => loadConfig({ ...validEnv, PORT: '99999' }), /valid port number/); +}); + +test('applies numeric overrides', () => { + const cfg = loadConfig({ ...validEnv, PORT: '8080', JOB_TIMEOUT_MS: '5000', CLAUDE_MAX_TURNS: '10' }); + assert.equal(cfg.port, 8080); + assert.equal(cfg.jobTimeoutMs, 5000); + assert.equal(cfg.maxTurns, 10); +}); + +test('passes through webhook secret and trigger phrase', () => { + const cfg = loadConfig({ ...validEnv, WEBHOOK_SECRET: 's3cret', TRIGGER_PHRASE: '@bot' }); + assert.equal(cfg.webhookSecret, 's3cret'); + assert.equal(cfg.triggerPhrase, '@bot'); +}); diff --git a/test/events.test.js b/test/events.test.js new file mode 100644 index 0000000..757a716 --- /dev/null +++ b/test/events.test.js @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyEvent } from '../src/events.js'; + +const opts = { ownerLogin: 'selimaj-dev', triggerPhrase: '@claude' }; + +function issueCommentPayload(overrides = {}) { + return { + action: 'created', + comment: { user: { login: 'selimaj-dev' }, body: '@claude please fix this' }, + issue: { number: 42, title: 'Bug', body: 'It is broken', pull_request: undefined }, + repository: { full_name: 'selimaj-dev/repo', default_branch: 'main' }, + ...overrides, + }; +} + +test('triggers on an issue comment from the owner mentioning the phrase', () => { + const { trigger, reason } = classifyEvent('issue_comment', issueCommentPayload(), opts); + assert.equal(reason, null); + assert.deepEqual(trigger, { + repo: 'selimaj-dev/repo', + issueNumber: 42, + issueTitle: 'Bug', + issueBody: 'It is broken', + isPullRequest: false, + defaultBranch: 'main', + requestText: '@claude please fix this', + author: 'selimaj-dev', + }); +}); + +test('marks isPullRequest true when issue.pull_request is present', () => { + const payload = issueCommentPayload({ issue: { number: 7, title: 'PR', body: '', pull_request: { url: 'x' } } }); + const { trigger } = classifyEvent('issue_comment', payload, opts); + assert.equal(trigger.isPullRequest, true); +}); + +test('ignores comments not from the configured owner', () => { + const payload = issueCommentPayload({ comment: { user: { login: 'someone-else' }, body: '@claude do it' } }); + const { trigger, reason } = classifyEvent('issue_comment', payload, opts); + assert.equal(trigger, null); + assert.match(reason, /is not the configured owner/); +}); + +test('ignores comments without the trigger phrase', () => { + const payload = issueCommentPayload({ comment: { user: { login: 'selimaj-dev' }, body: 'just a note' } }); + const { trigger, reason } = classifyEvent('issue_comment', payload, opts); + assert.equal(trigger, null); + assert.match(reason, /trigger phrase/); +}); + +test('ignores non-created issue_comment actions', () => { + const payload = issueCommentPayload({ action: 'deleted' }); + const { trigger, reason } = classifyEvent('issue_comment', payload, opts); + assert.equal(trigger, null); + assert.match(reason, /not handled/); +}); + +test('triggers on a new issue whose body mentions the phrase', () => { + const payload = { + action: 'opened', + issue: { number: 1, title: 'New issue', body: 'please @claude help', user: { login: 'selimaj-dev' } }, + repository: { full_name: 'selimaj-dev/repo', default_branch: 'main' }, + }; + const { trigger } = classifyEvent('issues', payload, opts); + assert.ok(trigger); + assert.equal(trigger.requestText, 'please @claude help'); +}); + +test('ignores unrelated event names', () => { + const { trigger, reason } = classifyEvent('push', {}, opts); + assert.equal(trigger, null); + assert.match(reason, /not handled/); +}); + +test('ignores null/undefined payload without throwing', () => { + assert.doesNotThrow(() => classifyEvent('issue_comment', null, opts)); + assert.doesNotThrow(() => classifyEvent('issue_comment', undefined, opts)); +}); + +test('ignores payload missing repository/issue info', () => { + const payload = issueCommentPayload({ repository: { full_name: undefined } }); + const { trigger, reason } = classifyEvent('issue_comment', payload, opts); + assert.equal(trigger, null); + assert.match(reason, /missing repository or issue/); +}); + +test('defaults default_branch to main when absent', () => { + const payload = issueCommentPayload({ repository: { full_name: 'a/b', default_branch: undefined } }); + const { trigger } = classifyEvent('issue_comment', payload, opts); + assert.equal(trigger.defaultBranch, 'main'); +}); diff --git a/test/exec.test.js b/test/exec.test.js new file mode 100644 index 0000000..6f5f620 --- /dev/null +++ b/test/exec.test.js @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { run, ProcessError, ProcessTimeoutError } from '../src/exec.js'; + +test('resolves with stdout on success', async () => { + const { stdout } = await run('node', ['-e', 'process.stdout.write("hi")']); + assert.equal(stdout, 'hi'); +}); + +test('rejects with ProcessError on non-zero exit, capturing stderr', async () => { + await assert.rejects( + run('node', ['-e', 'process.stderr.write("bad"); process.exit(2)']), + (err) => { + assert.ok(err instanceof ProcessError); + assert.equal(err.code, 2); + assert.equal(err.stderr, 'bad'); + return true; + }, + ); +}); + +test('rejects with ProcessTimeoutError when the process exceeds the timeout', async () => { + await assert.rejects( + run('node', ['-e', 'setTimeout(() => {}, 5000)'], { timeoutMs: 100 }), + ProcessTimeoutError, + ); +}); + +test('passes cwd and env through to the child process', async () => { + const { stdout } = await run('node', ['-e', 'process.stdout.write(process.env.FOO || "")'], { + env: { ...process.env, FOO: 'bar' }, + }); + assert.equal(stdout, 'bar'); +}); diff --git a/test/gitea.test.js b/test/gitea.test.js new file mode 100644 index 0000000..13b92fd --- /dev/null +++ b/test/gitea.test.js @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { GiteaClient, GiteaApiError } from '../src/gitea.js'; + +function fakeFetch(handler) { + return async (url, opts) => handler(url, opts); +} + +function jsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body), + }; +} + +test('getPullRequest returns the PR body on success', async () => { + const client = new GiteaClient({ + baseUrl: 'https://git.example.com', + token: 't', + fetchImpl: fakeFetch(async (url) => { + assert.equal(url, 'https://git.example.com/api/v1/repos/o/r/pulls/5'); + return jsonResponse(200, { head: { ref: 'feature' } }); + }), + }); + const pr = await client.getPullRequest('o/r', 5); + assert.equal(pr.head.ref, 'feature'); +}); + +test('getPullRequest returns null on 404 (issue, not a PR)', async () => { + const client = new GiteaClient({ + baseUrl: 'https://git.example.com', + token: 't', + fetchImpl: fakeFetch(async () => jsonResponse(404, { message: 'not found' })), + }); + const pr = await client.getPullRequest('o/r', 5); + assert.equal(pr, null); +}); + +test('getPullRequest rethrows non-404 errors', async () => { + const client = new GiteaClient({ + baseUrl: 'https://git.example.com', + token: 't', + fetchImpl: fakeFetch(async () => jsonResponse(500, { message: 'boom' })), + }); + await assert.rejects(() => client.getPullRequest('o/r', 5), GiteaApiError); +}); + +test('createIssueComment sends the expected method, path and body', async () => { + let captured; + const client = new GiteaClient({ + baseUrl: 'https://git.example.com/', + token: 'secret-token', + fetchImpl: fakeFetch(async (url, opts) => { + captured = { url, opts }; + return jsonResponse(201, { id: 1 }); + }), + }); + await client.createIssueComment('o/r', 9, 'hello'); + assert.equal(captured.url, 'https://git.example.com/api/v1/repos/o/r/issues/9/comments'); + assert.equal(captured.opts.method, 'POST'); + assert.equal(captured.opts.headers.Authorization, 'token secret-token'); + assert.deepEqual(JSON.parse(captured.opts.body), { body: 'hello' }); +}); + +test('authenticatedCloneUrl embeds credentials', () => { + const client = new GiteaClient({ baseUrl: 'https://git.example.com', token: 'abc' }); + assert.equal(client.authenticatedCloneUrl('o/r'), 'https://oauth2:abc@git.example.com/o/r.git'); +}); + +test('trailing slash on baseUrl does not produce a double slash', () => { + const client = new GiteaClient({ baseUrl: 'https://git.example.com/', token: 'abc' }); + assert.equal(client.baseUrl, 'https://git.example.com'); +}); diff --git a/test/job.test.js b/test/job.test.js new file mode 100644 index 0000000..aa413c1 --- /dev/null +++ b/test/job.test.js @@ -0,0 +1,131 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JobRunner } from '../src/job.js'; +import { createLogger } from '../src/logger.js'; + +process.env.LOG_LEVEL = 'error'; // keep test output clean +const logger = createLogger({}); + +function baseTrigger(overrides = {}) { + return { + repo: 'o/r', + issueNumber: 3, + issueTitle: 'Do a thing', + issueBody: 'body', + isPullRequest: false, + defaultBranch: 'main', + requestText: '@claude do the thing', + author: 'selimaj-dev', + ...overrides, + }; +} + +function fakeGitea({ existingPRs = [] } = {}) { + const calls = { comments: [], createdPRs: [] }; + return { + baseUrl: 'https://git.example.com', + token: 'tok', + getPullRequest: async () => null, + listOpenPullRequests: async () => existingPRs, + createPullRequest: async (repo, body) => { + calls.createdPRs.push({ repo, body }); + return { html_url: 'https://git.example.com/o/r/pulls/99' }; + }, + createIssueComment: async (repo, n, body) => { + calls.comments.push({ repo, n, body }); + }, + calls, + }; +} + +function fakeGitFactory({ hasChanges = false } = {}) { + const calls = { cloned: false, checkedOutBranch: null, committed: null, pushed: null }; + const factory = () => ({ + clone: async () => { calls.cloned = true; }, + checkoutWorkBranch: async (b) => { calls.checkedOutBranch = b; }, + hasChanges: async () => hasChanges, + commitAll: async (msg) => { calls.committed = msg; }, + push: async (b) => { calls.pushed = b; }, + }); + factory.calls = calls; + return factory; +} + +test('posts a comment with claude output when nothing changed', async () => { + const gitea = fakeGitea(); + const gitFactory = fakeGitFactory({ hasChanges: false }); + const claude = { runOnce: async () => 'Just an answer, no code change needed.' }; + const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory }); + + await runner.run(baseTrigger()); + + assert.equal(gitea.calls.comments.length, 1); + assert.match(gitea.calls.comments[0].body, /Just an answer/); + assert.equal(gitFactory.calls.committed, null); + assert.equal(gitea.calls.createdPRs.length, 0); +}); + +test('commits, pushes and opens a PR when changes are made on an issue', async () => { + const gitea = fakeGitea(); + const gitFactory = fakeGitFactory({ hasChanges: true }); + const claude = { runOnce: async () => 'Fixed it.' }; + const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory }); + + await runner.run(baseTrigger()); + + assert.equal(gitFactory.calls.checkedOutBranch, 'claude/issue-3'); + assert.match(gitFactory.calls.committed, /#3/); + assert.equal(gitFactory.calls.pushed, 'claude/issue-3'); + assert.equal(gitea.calls.createdPRs.length, 1); + assert.match(gitea.calls.comments[0].body, /pulls\/99/); +}); + +test('reuses an existing open PR instead of creating a duplicate', async () => { + const gitea = fakeGitea({ + existingPRs: [{ head: { ref: 'claude/issue-3' }, html_url: 'https://git.example.com/o/r/pulls/7' }], + }); + const gitFactory = fakeGitFactory({ hasChanges: true }); + const claude = { runOnce: async () => 'Fixed it.' }; + const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory }); + + await runner.run(baseTrigger()); + + assert.equal(gitea.calls.createdPRs.length, 0); + assert.match(gitea.calls.comments[0].body, /pulls\/7/); +}); + +test('does not open a PR when the trigger is already a PR conversation', async () => { + const gitea = fakeGitea(); + gitea.getPullRequest = async () => ({ head: { ref: 'existing-branch' } }); + const gitFactory = fakeGitFactory({ hasChanges: true }); + const claude = { runOnce: async () => 'Updated per review comment.' }; + const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory }); + + await runner.run(baseTrigger({ isPullRequest: true })); + + assert.equal(gitFactory.calls.pushed, 'existing-branch'); + assert.equal(gitFactory.calls.checkedOutBranch, null); // issue-only path skipped + assert.equal(gitea.calls.createdPRs.length, 0); +}); + +test('posts a failure comment when claude throws, and never throws itself', async () => { + const gitea = fakeGitea(); + const gitFactory = fakeGitFactory(); + const claude = { runOnce: async () => { throw new Error('claude exploded'); } }; + const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory }); + + await assert.doesNotReject(runner.run(baseTrigger())); + + assert.equal(gitea.calls.comments.length, 1); + assert.match(gitea.calls.comments[0].body, /claude exploded/); +}); + +test('swallows an error thrown while posting the failure comment itself', async () => { + const gitea = fakeGitea(); + gitea.createIssueComment = async () => { throw new Error('gitea is down'); }; + const gitFactory = fakeGitFactory(); + const claude = { runOnce: async () => { throw new Error('claude exploded'); } }; + const runner = new JobRunner({ gitea, claude, config: {}, logger, gitFactory }); + + await assert.doesNotReject(runner.run(baseTrigger())); +}); diff --git a/test/jobQueue.test.js b/test/jobQueue.test.js new file mode 100644 index 0000000..c24474d --- /dev/null +++ b/test/jobQueue.test.js @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JobQueue } from '../src/jobQueue.js'; +import { createLogger } from '../src/logger.js'; + +const silentLogger = createLogger({}).child({}); +// Silence output for these tests by pointing at a level above error. +process.env.LOG_LEVEL = 'error'; + +test('runs the job for a fresh key', async () => { + const queue = new JobQueue(silentLogger); + let ran = false; + await queue.runExclusive('a', async () => { + ran = true; + }); + assert.equal(ran, true); + assert.equal(queue.isRunning('a'), false); +}); + +test('skips a second call for the same key while the first is running', async () => { + const queue = new JobQueue(silentLogger); + let resolveFirst; + const first = queue.runExclusive('a', () => new Promise((r) => (resolveFirst = r))); + + assert.equal(queue.isRunning('a'), true); + let secondRan = false; + await queue.runExclusive('a', async () => { + secondRan = true; + }); + assert.equal(secondRan, false); + + resolveFirst(); + await first; + assert.equal(queue.isRunning('a'), false); +}); + +test('different keys run independently', async () => { + const queue = new JobQueue(silentLogger); + const order = []; + let resolveA; + const a = queue.runExclusive('a', () => new Promise((r) => (resolveA = r))).then(() => order.push('a')); + const b = queue.runExclusive('b', async () => order.push('b')); + await b; + resolveA(); + await a; + assert.deepEqual(order, ['b', 'a']); +}); + +test('clears the running key even when the job throws', async () => { + const queue = new JobQueue(silentLogger); + await queue.runExclusive('a', async () => { + throw new Error('boom'); + }); + assert.equal(queue.isRunning('a'), false); +}); diff --git a/test/logger.test.js b/test/logger.test.js new file mode 100644 index 0000000..48c9d0a --- /dev/null +++ b/test/logger.test.js @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +// Regression test: the logger must produce real JSON lines, not the +// literal string "undefined" (a bug caused by passing JSON.stringify a +// replacer *factory* instead of calling it first). +test('logger prints a parseable JSON object with the expected fields, not "undefined"', () => { + const script = ` + import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}'; + createLogger({ component: 'x' }).info('hello', { foo: 'bar' }); + `; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const line = result.stdout.trim(); + assert.notEqual(line, 'undefined'); + const parsed = JSON.parse(line); + assert.equal(parsed.level, 'info'); + assert.equal(parsed.msg, 'hello'); + assert.equal(parsed.component, 'x'); + assert.equal(parsed.foo, 'bar'); + assert.ok(parsed.time); +}); + +test('logger.child merges bindings and does not mutate the parent', () => { + const script = ` + import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}'; + const parent = createLogger({ a: 1 }); + const child = parent.child({ b: 2 }); + child.info('from child'); + parent.info('from parent'); + `; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const [childLine, parentLine] = result.stdout.trim().split('\n').map((l) => JSON.parse(l)); + assert.deepEqual({ a: childLine.a, b: childLine.b }, { a: 1, b: 2 }); + assert.equal(parentLine.b, undefined); +}); + +test('debug messages are suppressed at the default (info) log level', () => { + const script = ` + import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}'; + createLogger({}).debug('should not appear'); + createLogger({}).info('should appear'); + `; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + encoding: 'utf8', + env: { ...process.env, LOG_LEVEL: 'info' }, + }); + assert.equal(result.status, 0, result.stderr); + const lines = result.stdout.trim().split('\n').filter(Boolean); + assert.equal(lines.length, 1); + assert.equal(JSON.parse(lines[0]).msg, 'should appear'); +}); + +test('errors are logged with name/message/stack, not serialized to {}', () => { + const script = ` + import { createLogger } from '${import.meta.resolve('../src/logger.js').replace('file://', '')}'; + createLogger({}).error('boom', { error: new Error('bad thing') }); + `; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stderr.trim()); + assert.equal(parsed.error.message, 'bad thing'); + assert.equal(parsed.error.name, 'Error'); + assert.ok(parsed.error.stack); +}); diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..cc596ba --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,121 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { createServer } from '../src/server.js'; +import { createLogger } from '../src/logger.js'; + +process.env.LOG_LEVEL = 'error'; +const logger = createLogger({}); + +function baseConfig(overrides = {}) { + return { ownerLogin: 'selimaj-dev', triggerPhrase: '@claude', webhookSecret: null, ...overrides }; +} + +async function withServer(config, fn) { + const triggers = []; + const server = createServer({ config, logger, onTrigger: (t) => triggers.push(t) }); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address(); + try { + await fn(`http://127.0.0.1:${port}`, triggers); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +function issuePayload() { + return { + action: 'created', + comment: { user: { login: 'selimaj-dev' }, body: '@claude fix it' }, + issue: { number: 1, title: 't', body: 'b' }, + repository: { full_name: 'o/r', default_branch: 'main' }, + }; +} + +test('GET /healthz returns 200 without touching onTrigger', async () => { + await withServer(baseConfig(), async (base, triggers) => { + const res = await fetch(`${base}/healthz`); + assert.equal(res.status, 200); + assert.equal(triggers.length, 0); + }); +}); + +test('rejects non-POST, non-health requests', async () => { + await withServer(baseConfig(), async (base) => { + const res = await fetch(base, { method: 'GET' }); + assert.equal(res.status, 405); + }); +}); + +test('accepts a matching issue_comment and calls onTrigger', async () => { + await withServer(baseConfig(), async (base, triggers) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'X-Gitea-Event': 'issue_comment', 'Content-Type': 'application/json' }, + body: JSON.stringify(issuePayload()), + }); + assert.equal(res.status, 202); + assert.equal(triggers.length, 1); + assert.equal(triggers[0].repo, 'o/r'); + }); +}); + +test('does not call onTrigger for an unrelated author', async () => { + await withServer(baseConfig(), async (base, triggers) => { + const payload = issuePayload(); + payload.comment.user.login = 'someone-else'; + await fetch(base, { + method: 'POST', + headers: { 'X-Gitea-Event': 'issue_comment' }, + body: JSON.stringify(payload), + }); + assert.equal(triggers.length, 0); + }); +}); + +test('accepts requests with no event header (health probes) without error', async () => { + await withServer(baseConfig(), async (base, triggers) => { + const res = await fetch(base, { method: 'POST', body: '{}' }); + assert.equal(res.status, 202); + assert.equal(triggers.length, 0); + }); +}); + +test('rejects a request with a bad signature when a secret is configured', async () => { + await withServer(baseConfig({ webhookSecret: 's3cret' }), async (base, triggers) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'X-Gitea-Event': 'issue_comment', 'X-Gitea-Signature': 'wrong' }, + body: JSON.stringify(issuePayload()), + }); + assert.equal(res.status, 401); + assert.equal(triggers.length, 0); + }); +}); + +test('accepts a request with a correct signature when a secret is configured', async () => { + const secret = 's3cret'; + await withServer(baseConfig({ webhookSecret: secret }), async (base, triggers) => { + const body = JSON.stringify(issuePayload()); + const sig = crypto.createHmac('sha256', secret).update(body).digest('hex'); + const res = await fetch(base, { + method: 'POST', + headers: { 'X-Gitea-Event': 'issue_comment', 'X-Gitea-Signature': sig }, + body, + }); + assert.equal(res.status, 202); + assert.equal(triggers.length, 1); + }); +}); + +test('does not crash on invalid JSON body', async () => { + await withServer(baseConfig(), async (base, triggers) => { + const res = await fetch(base, { + method: 'POST', + headers: { 'X-Gitea-Event': 'issue_comment' }, + body: 'not json', + }); + assert.equal(res.status, 202); // already acknowledged before parse failure + assert.equal(triggers.length, 0); + }); +});