updated
This commit is contained in:
+28
-5
@@ -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 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"]
|
||||
|
||||
@@ -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.
|
||||
+15
-2
@@ -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://<gitea-container-name>: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
|
||||
# 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
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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()));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user