This commit is contained in:
2026-09-28 16:15:50 +02:00
parent a0ae396b33
commit 34b7dc977a
25 changed files with 1595 additions and 8 deletions
+28 -5
View File
@@ -1,8 +1,31 @@
# syntax=docker/dockerfile:1
FROM node:22-slim 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/* \ && 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 WORKDIR /app
COPY server.mjs .
USER node # No runtime dependencies today (package.json has none), but this keeps
CMD ["node", "server.mjs"] # 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"]
+107
View File
@@ -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 <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). 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.
+16 -3
View File
@@ -3,14 +3,27 @@ services:
build: . build: .
container_name: claude-hook container_name: claude-hook
restart: unless-stopped 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: environment:
- GITEA_URL - GITEA_URL # e.g. http://<gitea-container-name>:3000
- GITEA_TOKEN - GITEA_TOKEN # access token for the "claude" Gitea user
- CLAUDE_CODE_OAUTH_TOKEN - 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: networks:
- gitea-network - gitea-network
networks: networks:
gitea-network: gitea-network:
external: true external: true
name: bqiohrkncfda0as0f7lrdk7j # 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 <gitea-container> --format '{{json .NetworkSettings.Networks}}').
name: bqiohrkncfda0as0f7lrdk7j
+15
View File
@@ -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": {}
}
+50
View File
@@ -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 };
}
+33
View File
@@ -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();
}
}
+51
View File
@@ -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(),
};
}
+62
View File
@@ -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,
};
}
+84
View File
@@ -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);
}
});
}
+62
View File
@@ -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 = '[email protected]' } = {}) {
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}`);
}
}
+74
View File
@@ -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();
}
}
+62
View File
@@ -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);
});
+109
View File
@@ -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;
}
}
+35
View File
@@ -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);
}
}
+61
View File
@@ -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' });
+14
View File
@@ -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');
}
+108
View File
@@ -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);
});
}
+50
View File
@@ -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');
});
+92
View File
@@ -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');
});
+34
View File
@@ -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');
});
+74
View File
@@ -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:[email protected]/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');
});
+131
View File
@@ -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()));
});
+55
View File
@@ -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);
});
+67
View File
@@ -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);
});
+121
View File
@@ -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);
});
});