/** gitea-codereview HTTP server: webhooks, REST API, and admin UI. */ import { createServer } from "node:http"; import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository, getJob, getReviewRecord, jobStats, listJobs, listRepositories, listReviewRecords, openDatabase, recordDelivery, pruneDeliveries, requeueStaleSummaries, setSetting, updateReviewRecord, upsertRepository, getRepository, } from "./lib/db.js"; import { GiteaClient } from "./lib/gitea.js"; import { OcrRunner } from "./lib/ocr.js"; import { JobQueue } from "./lib/queue.js"; import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl, pullRequestMatches, PROMOTION_MARKER, } from "./lib/review.js"; const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url))); const ROOT_DIR = resolve(APP_DIR, ".."); const STATIC_DIR = join(APP_DIR, "static"); const SECRET_SETTING_KEYS = new Set(["giteaToken", "llmToken", "adminToken", "webhookSecret"]); function readConfig() { const dataDir = process.env.CR_DATA_DIR || join(ROOT_DIR, "data"); return { dataDir, dbPath: process.env.CR_DB_PATH || join(dataDir, "codereview.db"), port: Number(process.env.CR_PORT || 8090), host: process.env.CR_HOST || "0.0.0.0", ocrCommand: process.env.CR_OCR_COMMAND || "ocr", reviewTimeoutMs: Number(process.env.CR_REVIEW_TIMEOUT_MS || 45 * 60 * 1000), httpTimeoutMs: Number(process.env.CR_HTTP_TIMEOUT_MS || 60000), defaultConcurrency: Number(process.env.CR_CONCURRENCY || 4), maxTokensBudget: Number(process.env.CR_MAX_TOKENS_BUDGET || 0), pollMs: Number(process.env.CR_POLL_MS || 3000), maxAttempts: Number(process.env.CR_MAX_ATTEMPTS || 2), // Bootstrap defaults; persisted settings win once set through the UI. giteaUrl: process.env.CR_GITEA_URL || "http://127.0.0.1:80", giteaToken: process.env.CR_GITEA_TOKEN || "", webhookSecret: process.env.CR_WEBHOOK_SECRET || "", webhookUrl: process.env.CR_WEBHOOK_URL || "", adminToken: process.env.CR_ADMIN_TOKEN || "", llmUrl: process.env.CR_LLM_URL || process.env.OCR_LLM_URL || "", llmToken: process.env.CR_LLM_TOKEN || process.env.OCR_LLM_TOKEN || "", llmModel: process.env.CR_LLM_MODEL || process.env.OCR_LLM_MODEL || "", llmProtocol: process.env.CR_LLM_PROTOCOL || process.env.OCR_LLM_PROTOCOL || "", llmAuthHeader: process.env.CR_LLM_AUTH_HEADER || "", llmExtraHeaders: process.env.CR_LLM_EXTRA_HEADERS || "", llmTimeoutSeconds: Number(process.env.CR_LLM_TIMEOUT || 180), rulePath: process.env.CR_RULE_PATH || "", }; } const CONFIG = readConfig(); const db = openDatabase(CONFIG.dbPath); const logger = { info: (m) => console.log(`[${new Date().toISOString()}] ${m}`), warn: (m) => console.warn(`[${new Date().toISOString()}] WARN ${m}`), error: (m) => console.error(`[${new Date().toISOString()}] ERROR ${m}`), }; // Persisted settings override environment bootstrap values. function effectiveConfig() { const saved = allSettings(db); return { ...CONFIG, giteaUrl: saved.giteaUrl || CONFIG.giteaUrl, giteaToken: saved.giteaToken || CONFIG.giteaToken, webhookSecret: saved.webhookSecret ?? CONFIG.webhookSecret, webhookUrl: saved.webhookUrl || CONFIG.webhookUrl, adminToken: saved.adminToken ?? CONFIG.adminToken, llmUrl: saved.llmUrl || CONFIG.llmUrl, llmToken: saved.llmToken || CONFIG.llmToken, llmModel: saved.llmModel || CONFIG.llmModel, llmProtocol: saved.llmProtocol || CONFIG.llmProtocol, llmAuthHeader: saved.llmAuthHeader || CONFIG.llmAuthHeader, llmExtraHeaders: saved.llmExtraHeaders || CONFIG.llmExtraHeaders, rulePath: saved.rulePath || CONFIG.rulePath, }; } const engine = new ReviewEngine({ db, config: effectiveConfig(), logger }); const queue = new JobQueue({ db, engine, logger, pollMs: CONFIG.pollMs, maxAttempts: CONFIG.maxAttempts, }); // The engine reads config at call time through a getter so UI changes apply // without a restart. Object.defineProperty(engine, "config", { get: effectiveConfig, configurable: true, }); /* ---------------------------------- utils --------------------------------- */ function json(res, status, payload) { const body = JSON.stringify(payload, null, 2); res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", }); res.end(body); } function text(res, status, body, type = "text/plain; charset=utf-8") { res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" }); res.end(body); } async function readBody(req, limit = 5 * 1024 * 1024) { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > limit) throw new Error("request body too large"); chunks.push(chunk); } return Buffer.concat(chunks); } function verifySignature(secret, rawBody, signature) { if (!secret) return true; if (!signature) return false; const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(String(signature).trim(), "utf8"); return a.length === b.length && timingSafeEqual(a, b); } /** * The webhook URL Gitea should call. CR_WEBHOOK_URL wins; otherwise assume the * service shares a host with Gitea and reuse that hostname. */ function webhookUrlFor(cfg) { if (cfg.webhookUrl) return cfg.webhookUrl; try { const gitea = new URL(cfg.giteaUrl); return `${gitea.protocol}//${gitea.hostname}:${cfg.port}/webhook/gitea`; } catch { return `http://127.0.0.1:${cfg.port}/webhook/gitea`; } } function safeParse(value, fallback) { try { return JSON.parse(value || "null") ?? fallback; } catch { return fallback; } } function safeCount(value) { const parsed = safeParse(value, []); return Array.isArray(parsed) ? parsed.length : 0; } function authorized(req, cfg) { if (!cfg.adminToken) return true; const header = req.headers.authorization || ""; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; if (!token) return false; const a = Buffer.from(token); const b = Buffer.from(cfg.adminToken); return a.length === b.length && timingSafeEqual(a, b); } /* -------------------------------- webhooks -------------------------------- */ function refNameFromPayload(payload) { const ref = payload.ref || ""; return ref.replace(/^refs\/heads\//, ""); } async function handlePush(payload, cfg) { const owner = payload.repository?.owner?.username || payload.repository?.owner?.login; const name = payload.repository?.name; if (!owner || !name) return { queued: 0 }; const repo = findRepository(db, owner, name); if (!repo || !repo.enabled) return { queued: 0, reason: "repository not configured" }; const refName = refNameFromPayload(payload); if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" }; if (!branchMatches(refName, repo.check_branches)) { return { queued: 0, reason: `branch ${refName} is not in the checked branch list` }; } const toSha = payload.after || payload.head_commit?.id; if (!toSha) return { queued: 0, reason: "no head commit in payload" }; // A merge that a human performed in Gitea also ends the current round: the // code has landed, so the open review issues are stale. if (refName === (repo.managed_branch || repo.base_branch) && payload.commits?.length) { const workspace = join(CONFIG.dataDir, "workspaces", `${repo.owner}__${repo.name}`); if (existsSync(join(workspace, ".git"))) { try { const runner = new OcrRunner({ command: cfg.ocrCommand }); const parents = await runner.parentCount(workspace, toSha); if (parents > 1) { const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); const engine = new ReviewEngine({ db, config: cfg, logger }); const closed = await engine.closeRepoIssues({ client, repo, appendLog: () => {}, reason: `管理分支 ${refName} 收到合并提交 ${toSha.slice(0, 10)}`, }); if (closed) logger.info(`closed ${closed} issue(s) after merge into ${refName}`); return { queued: 0, reason: `merge detected on ${refName}; closed ${closed} issue(s)` }; } } catch (err) { logger.warn(`merge detection failed on ${owner}/${name}: ${err.message}`); } } } const scoped = repo.review_scope === "pr"; const pr = scoped ? null : await findOpenPullRequestForRef(cfg, repo, refName, toSha); if (scoped && !pr) { return { queued: 0, reason: "review_scope=pr and no open pull request" }; } // Base the review on what the push would merge into: the open PR's target // when there is one, otherwise the managed branch. const baseRef = pr?.base?.ref || repo.managed_branch; if (findJobBySha(db, repo.id, toSha)) { return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` }; } const before = payload.before && !/^0+$/.test(payload.before) ? payload.before : null; const jobId = enqueueJob(db, { repoId: repo.id, trigger: "push", refName, baseRef, fromSha: before, toSha, prNumber: pr?.number ?? null, }); logger.info(`queued job #${jobId} for ${owner}/${name} ${refName}@${toSha.slice(0, 10)}`); return { queued: 1, jobId }; } // Gitea's webhook payload uses "synchronized"; only its Actions runner rewrites // that to the GitHub-style "synchronize", so accept every spelling. const PR_ACTIONS = new Set([ "opened", "reopened", "synchronize", "synchronized", "ready_for_review", ]); async function handlePullRequest(payload, cfg) { const action = payload.action; const owner = payload.repository?.owner?.username || payload.repository?.owner?.login; const name = payload.repository?.name; const repo = owner && name ? findRepository(db, owner, name) : null; if (!repo || !repo.enabled) return { queued: 0, reason: "repository not configured" }; // A merged pull request is the authoritative "code has landed" signal: the // open review issues describe a state that no longer exists. if (action === "closed" && payload.pull_request?.merged) { const pr = payload.pull_request; const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); const engine = new ReviewEngine({ db, config: cfg, logger }); const closed = await engine.closeRepoIssues({ client, repo, appendLog: () => {}, reason: `PR #${pr.number} 已合并到 ${pr.base?.ref ?? "目标分支"}`, }); logger.info(`PR #${pr.number} merged into ${pr.base?.ref}; closed ${closed} issue(s)`); return { queued: 0, closedIssues: closed, reason: "pull request merged" }; } if (!PR_ACTIONS.has(action)) { return { queued: 0, reason: `action ${action} ignored` }; } if (repo.review_scope === "push") { return { queued: 0, reason: "review_scope=push; PRs reviewed via push events" }; } const pr = payload.pull_request; if (!pr) return { queued: 0, reason: "no pull_request in payload" }; if (pr.draft) return { queued: 0, reason: "draft pull request" }; // A promotion pull request this service opened itself: the same commits were // already reviewed when they were pushed, so reviewing again would produce a // duplicate issue for code that has already been merged. if (String(pr.body || "").includes(PROMOTION_MARKER)) { return { queued: 0, reason: "self-created promotion pull request; already reviewed on push" }; } if (!pullRequestMatches(pr.head?.ref, pr.base?.ref, repo.check_branches)) { return { queued: 0, reason: `neither head ${pr.head?.ref} nor base ${pr.base?.ref} is in the checked branch list`, }; } const toSha = pr.head?.sha; if (!toSha) return { queued: 0, reason: "no head sha" }; if (findJobBySha(db, repo.id, toSha)) { return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` }; } const jobId = enqueueJob(db, { repoId: repo.id, trigger: `pull_request.${action}`, refName: pr.head.ref, baseRef: pr.base?.ref ?? repo.managed_branch, fromSha: pr.base?.sha ?? null, toSha, prNumber: pr.number, }); logger.info(`queued job #${jobId} for PR #${pr.number} (${owner}/${name})`); return { queued: 1, jobId }; } async function findOpenPullRequestForRef(cfg, repo, refName, sha) { try { const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); const list = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50 }); return (list || []).find((p) => p.head?.ref === refName || p.head?.sha === sha) ?? null; } catch (err) { logger.warn(`cannot look up pull request for ${refName}: ${err.message}`); return null; } } /* ---------------------------------- routes -------------------------------- */ async function handleApi(req, res, url, cfg) { const path = url.pathname.replace(/^\/api/, ""); if (path === "/health") { return json(res, 200, { ok: true, queue: jobStats(db), currentJob: queue.currentJobId, giteaUrl: cfg.giteaUrl, llmModel: cfg.llmModel || null, }); } if (!authorized(req, cfg)) return json(res, 401, { error: "unauthorized" }); if (path === "/settings" && req.method === "GET") { const saved = allSettings(db); const out = { giteaUrl: cfg.giteaUrl, webhookSecretSet: Boolean(cfg.webhookSecret), adminTokenSet: Boolean(cfg.adminToken), llmUrl: cfg.llmUrl, llmModel: cfg.llmModel, llmProtocol: cfg.llmProtocol, rulePath: cfg.rulePath, webhookUrl: webhookUrlFor(cfg), giteaTokenSet: Boolean(cfg.giteaToken), llmTokenSet: Boolean(cfg.llmToken), raw: Object.fromEntries(Object.entries(saved).filter(([k]) => !SECRET_SETTING_KEYS.has(k))), }; return json(res, 200, out); } if (path === "/settings" && req.method === "PUT") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); const allowed = [ "giteaUrl", "giteaToken", "webhookSecret", "adminToken", "webhookUrl", "llmUrl", "llmToken", "llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath", ]; for (const key of allowed) { if (body[key] !== undefined) setSetting(db, key, body[key]); } return json(res, 200, { ok: true }); } if (path === "/repos" && req.method === "GET") { return json(res, 200, listRepositories(db)); } if (path === "/repos" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); let owner = body.owner; let name = body.name; let repoUrl = body.repo_url || null; // Owner/name are normally derived from the git URL, not typed by hand. if (repoUrl && (!owner || !name)) { try { const parsed = parseRepoUrl(repoUrl); owner = parsed.owner; name = parsed.name; } catch (err) { return json(res, 400, { error: err.message }); } } if (!owner || !name) return json(res, 400, { error: "请填写 Git 地址" }); if (!repoUrl) { repoUrl = `${String(cfg.giteaUrl).replace(/\/+$/, "")}/${owner}/${name}.git`; } const managed = body.managed_branch || body.base_branch || "main"; const checks = body.check_branches || body.branch_patterns || managed; const repo = upsertRepository(db, { owner, name, repo_url: repoUrl, enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)), managed_branch: managed, check_branches: checks, review_scope: body.review_scope || "both", create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)), auto_merge: Number(Boolean(body.auto_merge)), }); // Install the webhook straight away: an imported repository that never // receives events looks identical to a broken service. let webhook = null; if (body.install_webhook !== false) { try { const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); webhook = await client.ensureRepoWebhook(repo.owner, repo.name, { url: webhookUrlFor(cfg), secret: cfg.webhookSecret, }); } catch (err) { webhook = { error: err.message }; logger.warn(`cannot install webhook for ${owner}/${name}: ${err.message}`); } } return json(res, 201, { ...repo, webhook }); } /** Repositories the global token can see, for one-click import. */ if (path === "/gitea/repos" && req.method === "GET") { const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken }); try { const repos = await client.listAccessibleRepos(); const configured = new Set(listRepositories(db).map((r) => `${r.owner}/${r.name}`)); return json(res, 200, repos.map((r) => ({ full_name: r.full_name, owner: r.owner?.login ?? r.full_name.split("/")[0], name: r.name, clone_url: r.clone_url, ssh_url: r.ssh_url, default_branch: r.default_branch, private: Boolean(r.private), empty: Boolean(r.empty), archived: Boolean(r.archived), description: r.description ?? "", configured: configured.has(r.full_name), }))); } catch (err) { return json(res, 502, { error: err.message }); } } /** Validate a git URL without saving anything. */ if (path === "/repos/parse" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); try { const parsed = parseRepoUrl(body.repo_url); return json(res, 200, parsed); } catch (err) { return json(res, 400, { error: err.message }); } } const repoMatch = /^\/repos\/(\d+)$/.exec(path); if (repoMatch) { const id = Number(repoMatch[1]); const repo = getRepository(db, id); if (!repo) return json(res, 404, { error: "repository not found" }); if (req.method === "GET") return json(res, 200, repo); if (req.method === "PATCH") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); const updated = upsertRepository(db, { ...body, id }); return json(res, 200, updated); } if (req.method === "DELETE") { deleteRepository(db, id); return json(res, 200, { ok: true }); } } const discoverMatch = /^\/repos\/(\d+)\/discover$/.exec(path); if (discoverMatch && req.method === "POST") { const repo = getRepository(db, Number(discoverMatch[1])); if (!repo) return json(res, 404, { error: "repository not found" }); const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); try { const info = await client.getRepo(repo.owner, repo.name); const branches = await client.listRepoBranches(repo.owner, repo.name); const names = branches.map((b) => b.name); // Seed sensible branch defaults the first time a repository is opened. const patch = { id: repo.id }; if (!repo.managed_branch) patch.managed_branch = info.default_branch || "main"; if (!repo.check_branches) patch.check_branches = names.join(",") || patch.managed_branch; const updated = upsertRepository(db, patch); return json(res, 200, { repo: updated, default_branch: info.default_branch, has_issues: info.has_issues, has_pull_requests: info.has_pull_requests, branches: names, managed_branch_exists: names.includes(updated.managed_branch), }); } catch (err) { return json(res, 502, { error: err.message }); } } /** Report whether this repository's webhook is installed. */ const hookMatch = /^\/repos\/(\d+)\/webhook$/.exec(path); if (hookMatch && req.method === "GET") { const repo = getRepository(db, Number(hookMatch[1])); if (!repo) return json(res, 404, { error: "repository not found" }); const url = webhookUrlFor(cfg); const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); try { const hooks = (await client.listRepoWebhooks(repo.owner, repo.name)) ?? []; const match = hooks.find((h) => h.config?.url === url); return json(res, 200, { installed: Boolean(match), active: match?.active ?? false, events: match?.events ?? [], url, expectedEvents: ["push", "pull_request"], id: match?.id ?? null, }); } catch (err) { return json(res, 200, { installed: false, url, error: err.message }); } } /** Install or repair this repository's webhook. */ if (hookMatch && req.method === "POST") { const repo = getRepository(db, Number(hookMatch[1])); if (!repo) return json(res, 404, { error: "repository not found" }); const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); const url = webhookUrlFor(cfg); try { const result = await client.ensureRepoWebhook(repo.owner, repo.name, { url, secret: cfg.webhookSecret, }); return json(res, 200, { ...result, url, hasSecret: Boolean(cfg.webhookSecret) }); } catch (err) { return json(res, 502, { error: err.message, url }); } } /** List branches for a repository that is not saved yet. */ if (path === "/repos/branches" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); try { const parsed = body.owner && body.name ? body : parseRepoUrl(body.repo_url); const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken }); const info = await client.getRepo(parsed.owner, parsed.name); const branches = await client.listRepoBranches(parsed.owner, parsed.name); return json(res, 200, { owner: parsed.owner, name: parsed.name, default_branch: info.default_branch, has_issues: info.has_issues, has_pull_requests: info.has_pull_requests, branches: branches.map((b) => b.name), }); } catch (err) { return json(res, 502, { error: err.message }); } } /** Create the managed branch, optionally cloned from another branch. */ const branchMatch = /^\/repos\/(\d+)\/branches$/.exec(path); if (branchMatch && req.method === "POST") { const repo = getRepository(db, Number(branchMatch[1])); if (!repo) return json(res, 404, { error: "repository not found" }); const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); const newBranch = String(body.new_branch || "").trim(); const fromBranch = String(body.from_branch || "").trim(); if (!newBranch) return json(res, 400, { error: "请填写要创建的分支名" }); const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: repo.gitea_token || cfg.giteaToken, }); try { await client.createBranch(repo.owner, repo.name, { newBranch, fromBranch: fromBranch || undefined }); const branches = await client.listRepoBranches(repo.owner, repo.name); const names = branches.map((b) => b.name); const updated = upsertRepository(db, { id: repo.id, managed_branch: newBranch, check_branches: names.join(","), }); return json(res, 201, { repo: updated, branches: names }); } catch (err) { return json(res, 502, { error: err.message }); } } /* ------------------------------ review records ------------------------- */ if (path === "/records" && req.method === "GET") { const repoId = url.searchParams.get("repo_id"); const limit = Math.min(Number(url.searchParams.get("limit") || 100), 500); const rows = listReviewRecords(db, { repoId: repoId ? Number(repoId) : undefined, limit }); return json(res, 200, rows.map((r) => ({ ...r, findings_json: undefined, params_json: undefined, findings_count: safeCount(r.findings_json), }))); } const recordMatch = /^\/records\/(\d+)$/.exec(path); if (recordMatch && req.method === "GET") { const record = getReviewRecord(db, Number(recordMatch[1])); if (!record) return json(res, 404, { error: "record not found" }); const repo = getRepository(db, record.repo_id); return json(res, 200, { ...record, findings: safeParse(record.findings_json, []), params: safeParse(record.params_json, {}), repository: repo ? `${repo.owner}/${repo.name}` : null, }); } const resummariseMatch = /^\/records\/(\d+)\/summarise$/.exec(path); if (resummariseMatch && req.method === "POST") { const record = getReviewRecord(db, Number(resummariseMatch[1])); if (!record) return json(res, 404, { error: "record not found" }); updateReviewRecord(db, record.id, { summary_status: "pending", summary_error: null, }); return json(res, 202, { ok: true, queued: true }); } if (path === "/jobs" && req.method === "GET") { const repoId = url.searchParams.get("repo_id"); const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200); return json(res, 200, listJobs(db, { repoId: repoId ? Number(repoId) : undefined, limit })); } const jobMatch = /^\/jobs\/(\d+)$/.exec(path); if (jobMatch && req.method === "GET") { const job = getJob(db, Number(jobMatch[1])); if (!job) return json(res, 404, { error: "job not found" }); const repo = getRepository(db, job.repo_id); return json(res, 200, { ...job, repository: repo ? `${repo.owner}/${repo.name}` : null }); } const retryMatch = /^\/jobs\/(\d+)\/retry$/.exec(path); if (retryMatch && req.method === "POST") { const job = getJob(db, Number(retryMatch[1])); if (!job) return json(res, 404, { error: "job not found" }); const newId = enqueueJob(db, { repoId: job.repo_id, trigger: `${job.trigger}+retry`, refName: job.ref_name, baseRef: job.base_ref, fromSha: job.from_sha, toSha: job.to_sha, prNumber: job.pr_number, }); return json(res, 201, { jobId: newId }); } if (path === "/review" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); const repo = body.repo_id ? getRepository(db, Number(body.repo_id)) : findRepository(db, body.owner, body.name); if (!repo) return json(res, 404, { error: "repository not configured" }); const toSha = body.sha; if (!toSha) return json(res, 400, { error: "sha is required" }); const jobId = enqueueJob(db, { repoId: repo.id, trigger: "manual", refName: body.ref || repo.managed_branch, baseRef: body.base_ref || repo.managed_branch, fromSha: body.from_sha || null, toSha, prNumber: body.pr_number || null, }); return json(res, 201, { jobId }); } if (path === "/selftest" && req.method === "POST") { const runner = new OcrRunner({ command: cfg.ocrCommand, llm: { url: cfg.llmUrl, token: cfg.llmToken, model: cfg.llmModel, protocol: cfg.llmProtocol, authHeader: cfg.llmAuthHeader, extraHeaders: cfg.llmExtraHeaders, timeoutSeconds: cfg.llmTimeoutSeconds, }, }); try { const result = await runner.selfTest(); return json(res, 200, result); } catch (err) { return json(res, 502, { error: err.message, stderr: err.stderr ?? null }); } } if (path === "/gitea/test" && req.method === "POST") { try { const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken, }); const version = await client.getVersion(); let user = null; try { user = await client.getCurrentUser(); } catch { /* token may be missing */ } return json(res, 200, { version: version?.version ?? null, user: user?.login ?? null }); } catch (err) { return json(res, 502, { error: err.message }); } } return json(res, 404, { error: "not found" }); } async function serveStatic(res, path) { const rel = path === "/" ? "/index.html" : path; const full = join(STATIC_DIR, rel); if (!resolve(full).startsWith(STATIC_DIR)) return text(res, 403, "forbidden"); if (!existsSync(full)) { return text(res, 404, "not found"); } const info = await stat(full); if (!info.isFile()) return text(res, 404, "not found"); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".json": "application/json; charset=utf-8", }; return text(res, 200, await readFile(full), types[extname(full)] || "application/octet-stream"); } /* ---------------------------------- server -------------------------------- */ const server = createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); try { if (url.pathname === "/webhook/gitea" && req.method === "POST") { const cfg = effectiveConfig(); const raw = await readBody(req); const signature = req.headers["x-gitea-signature"]; if (!verifySignature(cfg.webhookSecret, raw, signature)) { logger.warn("webhook rejected: bad signature"); return json(res, 401, { error: "invalid signature" }); } const deliveryId = req.headers["x-gitea-delivery"] || randomUUID(); if (!recordDelivery(db, deliveryId)) { return json(res, 200, { ok: true, duplicate: true }); } pruneDeliveries(db); const event = req.headers["x-gitea-event"] || "unknown"; let payload; try { payload = JSON.parse(raw.toString("utf8") || "{}"); } catch { return json(res, 400, { error: "invalid JSON payload" }); } let result = { queued: 0 }; if (event === "push") result = await handlePush(payload, cfg); else if (event === "pull_request") result = await handlePullRequest(payload, cfg); else result = { queued: 0, reason: `event ${event} ignored` }; logger.info(`webhook ${event}: ${JSON.stringify(result)}`); return json(res, 202, { ok: true, event, ...result }); } if (url.pathname.startsWith("/api")) { return await handleApi(req, res, url, effectiveConfig()); } if (req.method === "GET") return await serveStatic(res, url.pathname); return text(res, 405, "method not allowed"); } catch (err) { logger.error(`${req.method} ${url.pathname} -> ${err.stack || err.message}`); return json(res, 500, { error: err.message }); } }); server.listen(CONFIG.port, CONFIG.host, () => { logger.info(`gitea-codereview listening on http://${CONFIG.host}:${CONFIG.port}`); logger.info(`database: ${CONFIG.dbPath}`); logger.info(`webhook endpoint: /webhook/gitea`); queue.start(); }); function shutdown(signal) { logger.info(`${signal} received, shutting down`); queue.stop(); server.close(() => { try { db.close(); } catch { /* ignore */ } process.exit(0); }); setTimeout(() => process.exit(0), 15000).unref(); } process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); export { server, db, engine, queue };