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
+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);
});
});