From 7b62b1a86a9ae9fde22aa71344daf5ee7f63febe Mon Sep 17 00:00:00 2001 From: Klesti Selimaj <1+selimaj-dev@noreply.localhost> Date: Mon, 28 Sep 2026 04:59:53 +0000 Subject: [PATCH] Add server.mjs --- server.mjs | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 server.mjs diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..b44a373 --- /dev/null +++ b/server.mjs @@ -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', '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', () => { + 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); \ No newline at end of file