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', 'user.email=noreply@anthropic.com', '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);