Files
2026-09-28 05:29:37 +00:00

113 lines
4.1 KiB
JavaScript

import http from 'node:http';
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 } = process.env;
const OWNER = 'selimaj-dev';
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;
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', () => {
res.writeHead(200).end('ok');
try {
if (req.headers['x-gitea-event'] !== 'issue_comment') return;
const ev = JSON.parse(Buffer.concat(chunks));
if (ev.action !== 'created') return;
if (ev.comment.user.login !== OWNER) return;
if (!ev.comment.body.includes('@claude')) return;
handle(ev);
} catch (e) { console.error(e.message); }
});
}).listen(3000);