Files
claude-hook/server.mjs
T
2026-09-28 04:59:53 +00:00

118 lines
4.4 KiB
JavaScript

import http from 'node:http';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const run = promisify(execFile);
const { GITEA_URL, GITEA_TOKEN, WEBHOOK_SECRET, OWNER_LOGIN = 'selimaj-dev', PORT = 3000 } = process.env;
const gitEnv = {
...process.env,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.extraHeader',
GIT_CONFIG_VALUE_0: `Authorization: token ${GITEA_TOKEN}`,
};
const claudeEnv = { ...process.env };
delete claudeEnv.GITEA_TOKEN;
delete claudeEnv.WEBHOOK_SECRET;
const api = async (method, p, body) => {
const r = await fetch(`${GITEA_URL}/api/v1${p}`, {
method,
headers: { Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${p} -> ${r.status}`);
return r.status === 204 ? null : r.json();
};
const git = (dir, ...args) => run('git', args, { cwd: dir, env: gitEnv });
const busy = new Set();
async function handle(ev) {
const repo = ev.repository.full_name;
const n = ev.issue.number;
const key = `${repo}#${n}`;
if (busy.has(key)) return;
busy.add(key);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-'));
try {
const isPR = !!ev.issue.pull_request;
const base = ev.repository.default_branch;
const ref = isPR ? (await api('GET', `/repos/${repo}/pulls/${n}`)).head.ref : base;
const branch = isPR ? ref : `claude/issue-${n}`;
await run('git', ['clone', '--depth', '50', '--branch', ref, `${GITEA_URL}/${repo}.git`, dir], { env: gitEnv });
if (!isPR) {
try {
await git(dir, 'fetch', 'origin', branch);
await git(dir, 'checkout', '-B', branch, 'FETCH_HEAD');
} catch {
await git(dir, 'checkout', '-B', branch);
}
}
const prompt = [
`Repository: ${repo}, #${n}: ${ev.issue.title}`,
ev.issue.body || '',
`Request from the maintainer:\n${ev.comment.body}`,
'Make the requested changes by editing files. Do not run git commands; the service commits for you. End with a short plain-text summary of what you changed, or your answer if no change was needed.',
].join('\n\n');
const { stdout } = await run(
'claude',
['-p', prompt, '--permission-mode', 'acceptEdits', '--model', 'sonnet', '--max-turns', '25'],
{ cwd: dir, env: claudeEnv, timeout: 20 * 60_000, maxBuffer: 20 * 1024 * 1024 },
);
let reply = stdout.trim() || '(no output)';
const { stdout: status } = await git(dir, 'status', '--porcelain');
if (status.trim()) {
await git(dir, 'add', '-A');
await git(dir, '-c', 'user.name=claude', '-c', '[email protected]',
'commit', '-m', `Claude: address #${n}`);
await git(dir, 'push', 'origin', `HEAD:${branch}`);
if (!isPR) {
const open = await api('GET', `/repos/${repo}/pulls?state=open`);
let pr = open.find((p) => p.head.ref === branch);
if (!pr) {
pr = await api('POST', `/repos/${repo}/pulls`, {
title: `Claude: ${ev.issue.title}`, head: branch, base,
body: `Closes #${n}\n\n${reply}`,
});
}
reply += `\n\nPR: ${pr.html_url}`;
}
}
await api('POST', `/repos/${repo}/issues/${n}/comments`, { body: reply });
} catch (e) {
console.error(key, e.message);
await api('POST', `/repos/${repo}/issues/${n}/comments`,
{ body: `Claude run failed: ${e.message.slice(0, 300)}` }).catch(() => {});
} finally {
busy.delete(key);
await fs.rm(dir, { recursive: true, force: true });
}
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const sig = req.headers['x-gitea-signature'] || '';
const want = crypto.createHmac('sha256', WEBHOOK_SECRET).update(raw).digest('hex');
const ok = sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
if (!ok) { res.writeHead(401).end(); return; }
res.writeHead(200).end('ok');
if (req.headers['x-gitea-event'] !== 'issue_comment') return;
const ev = JSON.parse(raw);
if (ev.action !== 'created') return;
if (ev.comment.user.login !== OWNER_LOGIN) return;
if (!ev.comment.body.includes('@claude')) return;
handle(ev);
});
}).listen(PORT);