1. 所有问题都建 Issue(原先只有阻断级才建) 建 Issue 的判据从 blocking 改为 findings:任何等级的问题都创建/更新 Issue, 等级体现在标题 [OCR][medium] 与标签 medium 上,阻断项在正文标注 「(阻断合并)」。低等级问题不再丢失,也不会挡住合并。 无任何发现时才关闭该分支的 Issue。 2. 合并失败自动重试 Gitea 在算完 PR 可合并性之前会返回 405 Please try again later, 原先直接放弃,晋级随机失败。现在对 405/409/5xx 按指数退避重试 4 次; 权限不足、真实冲突等永久失败立即放弃并往 PR 留言说明。 PR 已被合并(405 already merged)视为成功,幂等收尾。 3. push 与 PR 事件分流(本次新发现的 bug) 合并路径原先按「是否找到关联 PR」判断,于是一个残留的 test→prd PR 会让后续 push 被当成 PR 事件,走错分支并跳过晋级,日志还会给出 「PR targets prd, which is not a checked branch」这种与实际不符的原因。 现在按事件类型决定:trigger 以 pull_request 开头才走合并 PR 路径, push/manual 一律走晋级分支。关联 PR 仅用于评论归属。 验证: - 直接 push test → 建出 issue #4([OCR][medium],标签 code-review,medium) - 合并重试与幂等分支的判定表全部通过 - 事件分流判定表:push/manual → promote,pull_request.* → merge PR
827 lines
31 KiB
JavaScript
827 lines
31 KiB
JavaScript
/** 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A push is always handled as a push, never as a pull request. An open pull
|
|
// request whose head happens to be this branch is only linked for comment
|
|
// routing; letting it decide the merge path would make a leftover promotion
|
|
// pull request hijack the branch's own promotion logic.
|
|
const scoped = repo.review_scope === "pr";
|
|
const linkedPr = scoped ? null : await findOpenPullRequestForRef(cfg, repo, refName, toSha);
|
|
if (scoped && !linkedPr) {
|
|
return { queued: 0, reason: "review_scope=pr and no open pull request" };
|
|
}
|
|
// Compare against what this branch merges into: its linked pull request's
|
|
// target when it has one, otherwise the branch it promotes into.
|
|
const baseRef = linkedPr?.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: linkedPr?.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 }; |