Add server.mjs
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user