feat: Gitea 自动代码审查服务

基于 OpenCodeReview 的 webhook 服务:监听 Gitea 的 push 与 Pull Request 事件,
调用 OCR 审查 diff,把结果发布回 Gitea,并按阻断阈值决定是否自动合并。

主要能力:
- Push / PR 事件触发,支持分支 glob 过滤与 PR-only / push-only 范围
- PR 内联评论(按 diff 行号定位)、汇总评论、Issue 生命周期、提交状态
- 可配置阻断阈值(严重级别 / 类别 / 任意意见)
- 无阻断问题时自动合并,审查覆盖不完整时拒绝合并
- 内置 Web 后台:仓库配置、任务日志、失败重跑、连通性自检
- SQLite 持久化,worker 重启回收卡死任务,失败自动重试

实现为独立服务而非 Gitea Action:本机 act_runner 指向的实例不可达,
且后台配置与任务历史需要独立进程承载。
This commit is contained in:
2026-09-20 12:09:46 +08:00
commit ed172bf369
18 changed files with 3451 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
const SCHEMA = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
base_branch TEXT NOT NULL DEFAULT 'main',
branch_patterns TEXT NOT NULL DEFAULT '*',
review_scope TEXT NOT NULL DEFAULT 'both',
gitea_token TEXT,
llm_provider TEXT,
llm_model TEXT,
llm_base_url TEXT,
llm_token TEXT,
rule_path TEXT,
background_template TEXT,
excludes TEXT,
publish_mode TEXT NOT NULL DEFAULT 'inline',
create_issue INTEGER NOT NULL DEFAULT 1,
issue_labels TEXT NOT NULL DEFAULT 'code-review',
block_severity TEXT NOT NULL DEFAULT 'critical,high',
block_categories TEXT NOT NULL DEFAULT '',
fail_on_findings INTEGER NOT NULL DEFAULT 0,
auto_merge INTEGER NOT NULL DEFAULT 0,
auto_merge_mode TEXT NOT NULL DEFAULT 'when_checks_succeed',
merge_method TEXT NOT NULL DEFAULT 'squash',
delete_branch INTEGER NOT NULL DEFAULT 0,
max_comments INTEGER NOT NULL DEFAULT 30,
concurrency INTEGER NOT NULL DEFAULT 4,
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (owner, name)
);
CREATE TABLE IF NOT EXISTS branch_state (
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
ref_name TEXT NOT NULL,
last_sha TEXT NOT NULL,
reviewed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (repo_id, ref_name)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
trigger TEXT NOT NULL,
ref_name TEXT NOT NULL,
base_ref TEXT,
from_sha TEXT,
to_sha TEXT NOT NULL,
pr_number INTEGER,
status TEXT NOT NULL DEFAULT 'queued',
phase TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
findings INTEGER NOT NULL DEFAULT 0,
blocking INTEGER NOT NULL DEFAULT 0,
comment_count INTEGER NOT NULL DEFAULT 0,
issue_number INTEGER,
merged INTEGER NOT NULL DEFAULT 0,
result_json TEXT,
error TEXT,
log TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, id);
CREATE INDEX IF NOT EXISTS idx_jobs_repo ON jobs (repo_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_sha ON jobs (repo_id, to_sha);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
delivery_id TEXT PRIMARY KEY,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`;
export function openDatabase(path) {
mkdirSync(dirname(path), { recursive: true });
const db = new DatabaseSync(path);
db.exec(SCHEMA);
return db;
}
export function getSetting(db, key, fallback = null) {
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
return row ? row.value : fallback;
}
export function setSetting(db, key, value) {
db.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
).run(key, value == null ? "" : String(value));
}
export function allSettings(db) {
const out = {};
for (const row of db.prepare("SELECT key, value FROM settings").all()) {
out[row.key] = row.value;
}
return out;
}
export function listRepositories(db) {
return db.prepare("SELECT * FROM repositories ORDER BY owner, name").all();
}
export function getRepository(db, id) {
return db.prepare("SELECT * FROM repositories WHERE id = ?").get(id);
}
export function findRepository(db, owner, name) {
return db.prepare("SELECT * FROM repositories WHERE owner = ? AND name = ?").get(owner, name);
}
const REPO_FIELDS = [
"owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
"gitea_token", "llm_provider", "llm_model", "llm_base_url", "llm_token",
"rule_path", "background_template", "excludes", "publish_mode", "create_issue",
"issue_labels", "block_severity", "block_categories", "fail_on_findings",
"auto_merge", "auto_merge_mode", "merge_method", "delete_branch", "max_comments",
"concurrency",
];
export function upsertRepository(db, input) {
const existing = input.id
? getRepository(db, input.id)
: findRepository(db, input.owner, input.name);
const values = {};
for (const field of REPO_FIELDS) {
if (input[field] !== undefined) values[field] = input[field];
}
if (existing) {
const sets = Object.keys(values).map((k) => `${k} = ?`);
if (sets.length > 0) {
db.prepare(
`UPDATE repositories SET ${sets.join(", ")}, updated_at = datetime('now') WHERE id = ?`,
).run(...Object.values(values), existing.id);
}
return getRepository(db, existing.id);
}
const cols = ["owner", "name", ...Object.keys(values)];
const params = [input.owner, input.name, ...Object.values(values)];
const placeholders = cols.map(() => "?").join(", ");
const info = db.prepare(
`INSERT INTO repositories (${cols.join(", ")}) VALUES (${placeholders})`,
).run(...params);
return getRepository(db, Number(info.lastInsertRowid));
}
export function deleteRepository(db, id) {
db.prepare("DELETE FROM repositories WHERE id = ?").run(id);
}
export function enqueueJob(db, job) {
const info = db.prepare(
`INSERT INTO jobs (repo_id, trigger, ref_name, base_ref, from_sha, to_sha, pr_number, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')`,
).run(
job.repoId, job.trigger, job.refName, job.baseRef ?? null,
job.fromSha ?? null, job.toSha, job.prNumber ?? null,
);
return Number(info.lastInsertRowid);
}
export function claimNextJob(db) {
const row = db.prepare(
"SELECT * FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1",
).get();
if (!row) return null;
const info = db.prepare(
`UPDATE jobs SET status = 'running', attempts = attempts + 1,
started_at = datetime('now'), phase = 'starting'
WHERE id = ? AND status = 'queued'`,
).run(row.id);
if (info.changes === 0) return null;
return db.prepare("SELECT * FROM jobs WHERE id = ?").get(row.id);
}
export function updateJob(db, id, patch) {
const allowed = [
"status", "phase", "findings", "blocking", "comment_count",
"issue_number", "merged", "result_json", "error", "log", "pr_number",
];
const keys = Object.keys(patch).filter((k) => allowed.includes(k));
if (keys.length === 0) return;
const sets = keys.map((k) => `${k} = ?`);
if (patch.status && ["succeeded", "failed", "skipped"].includes(patch.status)) {
sets.push("finished_at = datetime('now')");
}
db.prepare(`UPDATE jobs SET ${sets.join(", ")} WHERE id = ?`).run(
...keys.map((k) => patch[k]), id,
);
}
export function getJob(db, id) {
return db.prepare("SELECT * FROM jobs WHERE id = ?").get(id);
}
export function listJobs(db, { repoId, limit = 50 } = {}) {
if (repoId) {
return db.prepare(
"SELECT j.*, r.owner, r.name FROM jobs j JOIN repositories r ON r.id = j.repo_id " +
"WHERE j.repo_id = ? ORDER BY j.id DESC LIMIT ?",
).all(repoId, limit);
}
return db.prepare(
"SELECT j.*, r.owner, r.name FROM jobs j JOIN repositories r ON r.id = j.repo_id " +
"ORDER BY j.id DESC LIMIT ?",
).all(limit);
}
export function findJobBySha(db, repoId, sha) {
return db.prepare(
"SELECT * FROM jobs WHERE repo_id = ? AND to_sha = ? AND status IN ('queued','running') ORDER BY id DESC LIMIT 1",
).get(repoId, sha);
}
export function getBranchState(db, repoId, refName) {
return db.prepare(
"SELECT * FROM branch_state WHERE repo_id = ? AND ref_name = ?",
).get(repoId, refName);
}
export function setBranchState(db, repoId, refName, sha) {
db.prepare(
`INSERT INTO branch_state (repo_id, ref_name, last_sha, reviewed_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (repo_id, ref_name) DO UPDATE SET
last_sha = excluded.last_sha, reviewed_at = datetime('now')`,
).run(repoId, refName, sha);
}
export function recordDelivery(db, deliveryId) {
try {
db.prepare("INSERT INTO webhook_deliveries (delivery_id) VALUES (?)").run(deliveryId);
return true;
} catch {
return false;
}
}
export function pruneDeliveries(db, keep = 2000) {
db.prepare(
`DELETE FROM webhook_deliveries WHERE delivery_id IN (
SELECT delivery_id FROM webhook_deliveries ORDER BY received_at DESC LIMIT -1 OFFSET ?
)`,
).run(keep);
}
export function jobStats(db) {
const rows = db.prepare("SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").all();
const out = { queued: 0, running: 0, succeeded: 0, failed: 0, skipped: 0 };
for (const r of rows) out[r.status] = r.n;
return out;
}
+133
View File
@@ -0,0 +1,133 @@
/** Parse unified git diffs into per-file hunks with old/new line maps. */
const FILE_HEADER = /^diff --git "?a\/(.+?)"? "?b\/(.+?)"?$/;
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
function stripPrefixQuotes(value) {
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replace(/\\(.)/g, "$1");
}
return value;
}
/**
* Parse a unified diff string.
* Returns a Map of newPath -> { oldPath, status, hunks: [{ oldStart, oldLines,
* newStart, newLines, newLineNumbers: number[], lines: string[] }] }.
* `newLineNumbers[i]` is the new-file line number for hunk line i, or 0 for
* removed lines / "\ No newline" markers.
*/
export function parseUnifiedDiff(diffText) {
const files = new Map();
if (!diffText) return files;
let current = null;
let hunk = null;
let newLine = 0;
let oldLine = 0;
for (const raw of diffText.split("\n")) {
const header = FILE_HEADER.exec(raw);
if (header) {
const newPath = stripPrefixQuotes(header[2]);
current = {
oldPath: stripPrefixQuotes(header[1]),
newPath,
status: "modified",
hunks: [],
};
files.set(newPath, current);
hunk = null;
continue;
}
if (!current) continue;
if (raw.startsWith("new file mode")) { current.status = "added"; continue; }
if (raw.startsWith("deleted file mode")) { current.status = "deleted"; continue; }
if (raw.startsWith("rename to ")) { current.newPath = stripPrefixQuotes(raw.slice(10).trim()); continue; }
if (raw.startsWith("--- ") || raw.startsWith("+++ ") || raw.startsWith("index ")
|| raw.startsWith("similarity index") || raw.startsWith("rename from")
|| raw.startsWith("old mode") || raw.startsWith("new mode")) {
continue;
}
const h = HUNK_HEADER.exec(raw);
if (h) {
oldLine = Number(h[1]);
newLine = Number(h[3]);
hunk = {
oldStart: oldLine,
oldLines: h[2] === undefined ? 1 : Number(h[2]),
newStart: newLine,
newLines: h[4] === undefined ? 1 : Number(h[4]),
newLineNumbers: [],
lines: [],
};
current.hunks.push(hunk);
continue;
}
if (!hunk) continue;
hunk.lines.push(raw);
if (raw.startsWith("+")) {
hunk.newLineNumbers.push(newLine);
newLine += 1;
} else if (raw.startsWith("-")) {
hunk.newLineNumbers.push(0);
oldLine += 1;
} else if (raw.startsWith("\\")) {
hunk.newLineNumbers.push(0);
} else {
hunk.newLineNumbers.push(newLine);
newLine += 1;
oldLine += 1;
}
}
return files;
}
/** All new-file line numbers present in the diff for a given path. */
export function addedLineNumbers(fileEntry) {
const added = new Set();
for (const hunk of fileEntry.hunks) {
for (let i = 0; i < hunk.lines.length; i += 1) {
if (hunk.lines[i].startsWith("+")) {
const line = hunk.newLineNumbers[i];
if (line > 0) added.add(line);
}
}
}
return added;
}
/** All new-file line numbers in any hunk (added + context) for a path. */
export function diffLineNumbers(fileEntry) {
const lines = new Set();
for (const hunk of fileEntry.hunks) {
for (let i = 0; i < hunk.lines.length; i += 1) {
const line = hunk.newLineNumbers[i];
if (line > 0) lines.add(line);
}
}
return lines;
}
/**
* Choose the best inline anchor for a finding.
* Prefers a line inside the diff (Gitea renders those inline); falls back to
* the start line so the finding is still recorded on the pull request.
*/
export function pickAnchorLine(fileEntry, startLine, endLine) {
const inDiff = diffLineNumbers(fileEntry);
const added = addedLineNumbers(fileEntry);
const lo = Math.max(1, Math.min(startLine || endLine || 1, endLine || startLine || 1));
const hi = Math.max(startLine || endLine || 1, endLine || startLine || 1);
for (let line = lo; line <= hi; line += 1) {
if (added.has(line)) return { line, inDiff: true };
}
for (let line = lo; line <= hi; line += 1) {
if (inDiff.has(line)) return { line, inDiff: true };
}
return { line: lo, inDiff: false };
}
+268
View File
@@ -0,0 +1,268 @@
/** Minimal Gitea REST client (no external dependencies). */
export class GiteaError extends Error {
constructor(message, { status, body, method, url } = {}) {
super(message);
this.name = "GiteaError";
this.status = status;
this.body = body;
this.method = method;
this.url = url;
}
}
/**
* Normalize a Gitea base URL to the server root.
* Accepts `http://host`, `http://host/`, or `http://host/api/v1` and always
* returns the root form, because every request path already carries the
* `/api/v1` prefix.
*/
export function normalizeBaseUrl(input) {
const trimmed = String(input || "").trim().replace(/\/+$/, "");
if (!trimmed) throw new Error("Gitea base URL is required");
return trimmed.replace(/\/api\/v1$/, "");
}
export class GiteaClient {
constructor({ baseUrl, token, timeoutMs = 60000, userAgent = "gitea-codereview" }) {
this.baseUrl = normalizeBaseUrl(baseUrl);
this.token = token;
this.timeoutMs = timeoutMs;
this.userAgent = userAgent;
}
async request(method, path, { body, query, raw = false, headers = {}, timeoutMs } = {}) {
const url = new URL(this.baseUrl + path);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
}
}
const init = {
method,
headers: {
Accept: raw ? "text/plain, application/json" : "application/json",
"User-Agent": this.userAgent,
...headers,
},
signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs),
};
if (this.token) init.headers.Authorization = `token ${this.token}`;
if (body !== undefined) {
init.headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(url, init);
} catch (err) {
throw new GiteaError(`Gitea request failed: ${method} ${url.pathname}: ${err.message}`, {
method, url: url.toString(),
});
}
const text = await res.text();
if (!res.ok) {
let parsed = null;
try { parsed = JSON.parse(text); } catch { /* keep raw text */ }
const detail = parsed?.message || text.slice(0, 400) || res.statusText;
throw new GiteaError(
`Gitea ${method} ${url.pathname} -> ${res.status}: ${detail}`,
{ status: res.status, body: parsed ?? text, method, url: url.toString() },
);
}
if (raw) return text;
if (!text) return null;
try { return JSON.parse(text); } catch { return text; }
}
get(path, options) { return this.request("GET", path, options); }
post(path, body, options) { return this.request("POST", path, { ...options, body }); }
patch(path, body, options) { return this.request("PATCH", path, { ...options, body }); }
put(path, body, options) { return this.request("PUT", path, { ...options, body }); }
del(path, options) { return this.request("DELETE", path, options); }
/** Verify the token and return the authenticated user. */
async getCurrentUser() {
return this.get("/api/v1/user");
}
async getVersion() {
return this.get("/api/v1/version");
}
async getRepo(owner, repo) {
return this.get(`/api/v1/repos/${owner}/${repo}`);
}
async listRepoBranches(owner, repo, limit = 100) {
const out = [];
for (let page = 1; page <= 20; page += 1) {
const batch = await this.get(`/api/v1/repos/${owner}/${repo}/branches`, {
query: { limit, page },
});
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
}
return out;
}
async getBranch(owner, repo, branch) {
return this.get(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`);
}
async getIssue(owner, repo, index) {
return this.get(`/api/v1/repos/${owner}/${repo}/issues/${index}`);
}
async listIssues(owner, repo, query = {}) {
return this.get(`/api/v1/repos/${owner}/${repo}/issues`, { query });
}
async createIssue(owner, repo, { title, body, labels, assignees }) {
const payload = { title, body };
if (labels?.length) payload.labels = labels;
if (assignees?.length) payload.assignees = assignees;
return this.post(`/api/v1/repos/${owner}/${repo}/issues`, payload);
}
async updateIssue(owner, repo, index, patch) {
return this.patch(`/api/v1/repos/${owner}/${repo}/issues/${index}`, patch);
}
async listIssueComments(owner, repo, index, limit = 100) {
const out = [];
for (let page = 1; page <= 20; page += 1) {
const batch = await this.get(`/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, {
query: { limit, page },
});
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
}
return out;
}
async createIssueComment(owner, repo, index, body) {
return this.post(`/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, { body });
}
async updateIssueComment(owner, repo, commentId, body) {
return this.patch(`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
}
async createCommitStatus(owner, repo, sha, { state, context, description, targetUrl }) {
return this.post(`/api/v1/repos/${owner}/${repo}/statuses/${sha}`, {
state,
context,
description: description?.slice(0, 255) ?? "",
target_url: targetUrl ?? "",
});
}
async getCommitStatuses(owner, repo, ref) {
return this.get(`/api/v1/repos/${owner}/${repo}/commits/${ref}/statuses`);
}
async getPullRequest(owner, repo, index) {
return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}`);
}
async listPullRequests(owner, repo, query = {}) {
return this.get(`/api/v1/repos/${owner}/${repo}/pulls`, { query });
}
async listPullRequestFiles(owner, repo, index) {
return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/files`);
}
async listPullRequestCommits(owner, repo, index) {
return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/commits`);
}
/**
* Create a review with optional inline comments.
* `comments` entries use { path, body, newPosition, oldPosition }.
*/
async createPullReview(owner, repo, index, { event = "COMMENT", body = "", commitId, comments = [] }) {
const payload = { event, body };
if (commitId) payload.commit_id = commitId;
if (comments.length) {
payload.comments = comments.map((c) => {
const entry = { path: c.path, body: c.body };
if (c.newPosition) entry.new_position = c.newPosition;
else if (c.oldPosition) entry.old_position = c.oldPosition;
return entry;
});
}
return this.post(`/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, payload);
}
async listPullReviews(owner, repo, index, limit = 50) {
const out = [];
for (let page = 1; page <= 20; page += 1) {
const batch = await this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, {
query: { limit, page },
});
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
}
return out;
}
async mergePullRequest(owner, repo, index, { style = "squash", title, message, deleteBranch, headCommitId, mergeWhenChecksSucceed = false } = {}) {
const payload = { do: style };
if (title) payload.merge_title_field = title;
if (message) payload.merge_message_field = message;
if (deleteBranch !== undefined) payload.delete_branch_after_merge = Boolean(deleteBranch);
if (headCommitId) payload.head_commit_id = headCommitId;
if (mergeWhenChecksSucceed) payload.merge_when_checks_succeed = true;
return this.post(`/api/v1/repos/${owner}/${repo}/pulls/${index}/merge`, payload);
}
async listRepoLabels(owner, repo) {
return this.get(`/api/v1/repos/${owner}/${repo}/labels`, { query: { limit: 100 } });
}
async createRepoLabel(owner, repo, { name, color = "#1f6feb", description = "" }) {
return this.post(`/api/v1/repos/${owner}/${repo}/labels`, { name, color, description });
}
/**
* Resolve label names to repository label IDs, creating missing labels.
* Gitea's issue API takes label IDs, not names.
* @returns {Promise<number[]>} label IDs that could be resolved.
*/
async ensureLabels(owner, repo, names) {
if (!names?.length) return [];
let existing = [];
try {
existing = (await this.listRepoLabels(owner, repo)) ?? [];
} catch {
existing = [];
}
const byName = new Map(existing.map((l) => [l.name, l.id]));
for (const name of names) {
if (byName.has(name)) continue;
try {
const created = await this.createRepoLabel(owner, repo, { name });
if (created?.id) byName.set(name, created.id);
} catch {
// Label creation is best-effort; issue creation still proceeds.
}
}
return names.map((n) => byName.get(n)).filter((id) => Number.isInteger(id));
}
async getUser(login) {
return this.get(`/api/v1/users/${encodeURIComponent(login)}`);
}
}
/** Derive the repository web URL from an API base URL. */
export function webBaseUrl(apiBaseUrl) {
return normalizeBaseUrl(apiBaseUrl);
}
+272
View File
@@ -0,0 +1,272 @@
/** Runs the OpenCodeReview CLI and parses its JSON output. */
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
export class OcrError extends Error {
constructor(message, { exitCode, stderr, stdout } = {}) {
super(message);
this.name = "OcrError";
this.exitCode = exitCode;
this.stderr = stderr;
this.stdout = stdout;
}
}
export const CATEGORY_VALUES = [
"bug", "security", "performance", "maintainability",
"test", "style", "documentation", "other",
];
export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
const MAX_CAPTURE = 2 * 1024 * 1024;
function run(command, args, { cwd, env, timeoutMs, onOutput } = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, { cwd, env, windowsHide: true });
let stdout = "";
let stderr = "";
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
const timer = timeoutMs
? setTimeout(() => {
try { child.kill("SIGKILL"); } catch { /* already gone */ }
finish({ code: 124, stdout, stderr: `${stderr}\n[timeout after ${timeoutMs} ms]` });
}, timeoutMs)
: null;
child.stdout.on("data", (chunk) => {
const text = chunk.toString();
if (stdout.length < MAX_CAPTURE) stdout += text;
onOutput?.("stdout", text);
});
child.stderr.on("data", (chunk) => {
const text = chunk.toString();
if (stderr.length < MAX_CAPTURE) stderr += text;
onOutput?.("stderr", text);
});
child.on("error", (err) => {
finish({ code: 127, stdout, stderr: `${stderr}\n${err.message}` });
});
child.on("close", (code) => finish({ code, stdout, stderr }));
});
}
function git(args, { cwd, timeoutMs = 300000 } = {}) {
return run("git", args, { cwd, timeoutMs });
}
export class OcrRunner {
constructor({
command = "ocr",
workspaceDir,
llm = {},
timeoutMs = 45 * 60 * 1000,
gitTimeoutMs = 10 * 60 * 1000,
logger = () => {},
} = {}) {
this.command = command;
this.workspaceDir = workspaceDir;
this.llm = llm;
this.timeoutMs = timeoutMs;
this.gitTimeoutMs = gitTimeoutMs;
this.logger = logger;
}
ocrEnv(extra = {}) {
const env = { ...process.env, ...extra };
if (this.llm.url) env.OCR_LLM_URL = this.llm.url;
if (this.llm.token) env.OCR_LLM_TOKEN = this.llm.token;
if (this.llm.model) env.OCR_LLM_MODEL = this.llm.model;
if (this.llm.protocol) env.OCR_LLM_PROTOCOL = this.llm.protocol;
if (this.llm.authHeader) env.OCR_LLM_AUTH_HEADER = this.llm.authHeader;
if (this.llm.extraHeaders) env.OCR_LLM_EXTRA_HEADERS = this.llm.extraHeaders;
if (this.llm.timeoutSeconds) env.OCR_LLM_TIMEOUT = String(this.llm.timeoutSeconds);
env.OCR_ENABLE_TELEMETRY = "0";
return env;
}
/** Verify the OCR binary and the configured LLM endpoint. */
async selfTest() {
const version = await run(this.command, ["version"], {
env: this.ocrEnv(), timeoutMs: 60000,
});
if (version.code !== 0) {
throw new OcrError(`cannot run '${this.command} version'`, {
exitCode: version.code, stderr: version.stderr, stdout: version.stdout,
});
}
const test = await run(this.command, ["llm", "test"], {
env: this.ocrEnv(), timeoutMs: 120000,
});
return {
version: version.stdout.trim(),
llmOk: test.code === 0,
llmOutput: `${test.stdout}\n${test.stderr}`.trim(),
};
}
async gitClone({ cloneUrl, token, dir, extraHeader = true }) {
const args = ["clone", "--no-tags", "--filter=blob:none"];
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (token) {
if (extraHeader) {
env.GIT_CONFIG_COUNT = "1";
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
} else {
const url = new URL(cloneUrl);
url.username = "oauth2";
url.password = token;
cloneUrl = url.toString();
}
}
args.push(cloneUrl, dir);
const res = await run("git", args, { env, timeoutMs: this.gitTimeoutMs });
if (res.code !== 0) {
throw new OcrError(`git clone failed for ${cloneUrl}`, {
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
});
}
return dir;
}
async fetch(dir, { refs = [], token } = {}) {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (token) {
env.GIT_CONFIG_COUNT = "1";
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
}
const args = ["fetch", "--prune", "--no-tags", "origin"];
for (const ref of refs) args.push(ref);
const res = await run("git", args, { cwd: dir, env, timeoutMs: this.gitTimeoutMs });
if (res.code !== 0) {
throw new OcrError(`git fetch failed in ${dir}`, {
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
});
}
return res;
}
async revParse(dir, ref) {
const res = await git(["rev-parse", "--verify", `${ref}^{commit}`], { cwd: dir, timeoutMs: 60000 });
if (res.code !== 0) return null;
return res.stdout.trim().split("\n")[0];
}
async mergeBase(dir, a, b) {
const res = await git(["merge-base", a, b], { cwd: dir, timeoutMs: 120000 });
if (res.code !== 0) return null;
return res.stdout.trim().split("\n")[0];
}
async changedFiles(dir, fromSha, toSha) {
const res = await git(
["diff", "--name-only", "--diff-filter=ACMRTUXB", fromSha, toSha],
{ cwd: dir, timeoutMs: this.gitTimeoutMs },
);
if (res.code !== 0) return [];
return res.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
}
/**
* Run a diff review.
* @returns {{ comments: object[], summary: object|null, raw: object, stderr: string }}
*/
async review({
dir, fromSha, toSha, excludes = [], background, concurrency = 4,
maxComments = 30, maxTokensBudget = 0, rulePath, onOutput,
}) {
const outFile = join(await mkdtemp(join(tmpdir(), "ocr-out-")), "result.json");
const args = [
"review",
"--repo", dir,
"--from", fromSha,
"--to", toSha,
"--format", "json",
"--audience", "agent",
"--concurrency", String(concurrency),
"--output", outFile,
];
if (excludes.length) args.push("--exclude", excludes.join(","));
if (background) args.push("--background", background);
if (rulePath && existsSync(rulePath)) args.push("--rule", rulePath);
if (maxTokensBudget > 0) args.push("--max-tokens-budget", String(maxTokensBudget));
this.logger(`ocr review --from ${fromSha} --to ${toSha} (cwd=${dir})`);
const res = await run(this.command, args, {
cwd: dir, env: this.ocrEnv(), timeoutMs: this.timeoutMs, onOutput,
});
let raw = null;
try {
const { readFile } = await import("node:fs/promises");
raw = JSON.parse(await readFile(outFile, "utf8"));
} catch (err) {
if (res.code !== 0) {
throw new OcrError(`ocr review failed (exit ${res.code})`, {
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
});
}
throw new OcrError(`cannot parse ocr JSON output: ${err.message}`, {
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
});
} finally {
await rm(outFile, { force: true }).catch(() => {});
}
const comments = Array.isArray(raw?.comments) ? raw.comments : [];
const selected = comments
.filter((c) => c && typeof c.path === "string" && c.path.length > 0)
.slice(0, Math.max(1, maxComments));
return {
comments: selected,
totalComments: comments.length,
summary: raw?.summary ?? null,
status: raw?.status ?? null,
projectSummary: raw?.project_summary ?? "",
warnings: raw?.warnings ?? [],
raw,
stderr: res.stderr,
exitCode: res.code,
};
}
async preview({ dir, fromSha, toSha, excludes = [], onOutput }) {
const args = [
"review", "--repo", dir, "--from", fromSha, "--to", toSha,
"--format", "json", "--audience", "agent", "--preview",
];
if (excludes.length) args.push("--exclude", excludes.join(","));
const res = await run(this.command, args, {
cwd: dir, env: this.ocrEnv(), timeoutMs: 300000, onOutput,
});
if (res.code !== 0) {
throw new OcrError(`ocr review --preview failed (exit ${res.code})`, {
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
});
}
return JSON.parse(res.stdout);
}
}
export function severityAtLeast(severity, threshold) {
const a = SEVERITY_RANK[String(severity || "").toLowerCase()] ?? 0;
const b = SEVERITY_RANK[String(threshold || "").toLowerCase()] ?? 0;
return a > 0 && b > 0 && a >= b;
}
export function parseList(value) {
if (!value) return [];
return String(value).split(",").map((s) => s.trim()).filter(Boolean);
}
+79
View File
@@ -0,0 +1,79 @@
/** Sequential job worker with retry and stale-job recovery. */
import { claimNextJob, updateJob } from "./db.js";
import { SkipJob } from "./review.js";
const STALE_MS = 2 * 60 * 60 * 1000;
export class JobQueue {
constructor({ db, engine, logger = console, pollMs = 3000, maxAttempts = 2 }) {
this.db = db;
this.engine = engine;
this.logger = logger;
this.pollMs = pollMs;
this.maxAttempts = maxAttempts;
this.running = false;
this.busy = false;
this.timer = null;
this.currentJobId = null;
}
start() {
if (this.running) return;
this.running = true;
this.recoverStale();
this.tick();
}
stop() {
this.running = false;
if (this.timer) clearTimeout(this.timer);
}
recoverStale() {
const cutoff = new Date(Date.now() - STALE_MS).toISOString().replace("T", " ").slice(0, 19);
const stale = this.db.prepare(
"SELECT id FROM jobs WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?",
).all(cutoff);
for (const row of stale) {
updateJob(this.db, row.id, {
status: "queued", phase: null,
error: "requeued after worker restart or timeout",
});
this.logger.warn?.(`requeued stale job #${row.id}`);
}
}
async tick() {
if (!this.running) return;
if (this.busy) {
this.timer = setTimeout(() => this.tick(), this.pollMs);
return;
}
const job = claimNextJob(this.db);
if (!job) {
this.timer = setTimeout(() => this.tick(), this.pollMs);
return;
}
this.busy = true;
this.currentJobId = job.id;
try {
await this.engine.execute(job);
} catch (err) {
if (err instanceof SkipJob) {
await this.engine.failJob(job, err);
} else if (job.attempts < this.maxAttempts) {
this.logger.warn?.(`job #${job.id} failed (attempt ${job.attempts}): ${err.message}; retrying`);
updateJob(this.db, job.id, {
status: "queued", phase: null,
error: `${err.name || "Error"}: ${err.message}`,
});
} else {
await this.engine.failJob(job, err);
}
} finally {
this.busy = false;
this.currentJobId = null;
}
this.timer = setTimeout(() => this.tick(), this.pollMs);
}
}
+641
View File
@@ -0,0 +1,641 @@
/**
* Core review pipeline: fetch -> diff -> OCR -> publish -> gate -> merge.
*/
import { mkdir, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { GiteaClient, webBaseUrl, normalizeBaseUrl } from "./gitea.js";
import { OcrRunner, parseList, severityAtLeast } from "./ocr.js";
import { parseUnifiedDiff, pickAnchorLine } from "./diff.js";
import { getBranchState, setBranchState, updateJob } from "./db.js";
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
export const STATUS_CONTEXT = "code-review/ocr";
export class SkipJob extends Error {
constructor(reason) {
super(reason);
this.name = "SkipJob";
this.reason = reason;
}
}
function repoSlug(repo) {
return `${repo.owner}/${repo.name}`;
}
function globToRegExp(pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const body = escaped
.replace(/\*\*/g, "\u0000")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, ".")
.replace(/\u0000/g, ".*");
return new RegExp(`^${body}$`);
}
export function branchMatches(refName, patterns) {
const list = parseList(patterns);
if (list.length === 0 || list.includes("*")) return true;
const short = refName.replace(/^refs\/heads\//, "");
return list.some((p) => globToRegExp(p).test(short) || globToRegExp(p).test(refName));
}
function badge(comment) {
const parts = [comment.category, comment.severity].filter(Boolean);
return parts.length ? `[${parts.join(" · ")}] ` : "";
}
function renderCommentBody(comment) {
const lines = [`${badge(comment)}${String(comment.content || "").trim()}`];
if (comment.suggestion_code) {
lines.push("", "```suggestion", String(comment.suggestion_code).replace(/\n+$/, ""), "```");
}
lines.push("", COMMENT_MARKER);
return lines.join("\n");
}
function renderSummaryBody({ repo, job, review, published, failed, blocking, note }) {
const lines = [SUMMARY_MARKER, "## OCR 代码审查", ""];
lines.push(`- 仓库:\`${repoSlug(repo)}\``);
lines.push(`- 分支:\`${job.ref_name}\``);
if (job.pr_number) lines.push(`- Pull Request:#${job.pr_number}`);
lines.push(`- 提交:\`${String(job.to_sha).slice(0, 10)}\``);
if (review.summary) {
const s = review.summary;
lines.push(
`- 审查:${s.files_reviewed ?? "?"} 个文件 / ${s.comments ?? 0} 条意见` +
`${s.total_tokens ? ` / ${s.total_tokens} tokens` : ""}` +
`${s.elapsed ? ` / ${s.elapsed}` : ""}`,
);
}
if (note) lines.push("", note);
lines.push("");
if (published.length === 0) {
lines.push("未发现需要处理的问题。");
} else {
lines.push(`### 审查意见(${published.length} 条)`, "");
const grouped = new Map();
for (const item of published) {
const key = item.comment.path;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
}
for (const [path, items] of grouped) {
lines.push(`**\`${path}\`**`, "");
for (const item of items) {
const where = item.comment.start_line
? `L${item.comment.start_line}${item.comment.end_line && item.comment.end_line !== item.comment.start_line ? `-${item.comment.end_line}` : ""}`
: "位置未知";
const flag = item.inline ? "" : "(无法内联定位)";
lines.push(`- ${badge(item.comment)}${where}${flag} — ${String(item.comment.content || "").replace(/\s*\n\s*/g, " ").trim()}`);
}
lines.push("");
}
}
if (failed.length > 0) {
lines.push(`### 发布失败(${failed.length} 条)`, "");
for (const item of failed) {
lines.push(`- \`${item.comment.path}\` — ${item.error}`);
}
lines.push("");
}
if (blocking.length > 0) {
lines.push(
"### 结论",
"",
`存在 ${blocking.length} 条达到阻断阈值的问题,已创建/更新 Issue,且不会自动合并。`,
);
} else if (published.length > 0) {
lines.push("### 结论", "", "未发现达到阻断阈值的问题。");
}
lines.push(
"",
`<sub>由 gitea-codereview 基于 [OpenCodeReview](https://github.com/alibaba/open-code-review) 生成 · job #${job.id}</sub>`,
);
return lines.join("\n");
}
function renderIssueBody({ repo, job, blocking, summaryUrl }) {
const lines = [
SUMMARY_MARKER,
`自动代码审查在 \`${job.ref_name}\` @ \`${String(job.to_sha).slice(0, 10)}\` 上发现阻断级问题。`,
"",
`- 仓库:\`${repoSlug(repo)}\``,
`- 分支:\`${job.ref_name}\``,
`- 提交:\`${job.to_sha}\``,
];
if (job.pr_number) lines.push(`- Pull Request:#${job.pr_number}`);
if (summaryUrl) lines.push(`- 审查详情:${summaryUrl}`);
lines.push("", `### 阻断问题(${blocking.length} 条)`, "");
for (const item of blocking) {
const c = item.comment;
const where = c.start_line ? `${c.path}:${c.start_line}` : c.path;
lines.push(`- ${badge(c)}\`${where}\` — ${String(c.content || "").replace(/\s*\n\s*/g, " ").trim()}`);
}
lines.push("", `<sub>job #${job.id} · 由 gitea-codereview 生成</sub>`);
return lines.join("\n");
}
/** Resolve the fetch/review range for a job against the workspace clone. */
async function resolveRange(runner, { repo, job, workspace, token }) {
const headRef = `refs/heads/${job.ref_name}`;
const refs = [headRef, `+${headRef}:refs/remotes/origin/${job.ref_name}`];
if (job.base_ref) refs.push(`+refs/heads/${job.base_ref}:refs/remotes/origin/${job.base_ref}`);
if (job.pr_number) refs.push(`+refs/pull/${job.pr_number}/head:refs/remotes/origin/pr/${job.pr_number}`);
await runner.fetch(workspace, { refs, token });
const toSha = job.to_sha;
const local = await runner.revParse(workspace, toSha);
if (!local) {
throw new Error(`commit ${toSha} not found in workspace after fetch`);
}
let fromSha = null;
if (job.from_sha) {
fromSha = await runner.revParse(workspace, job.from_sha);
}
if (!fromSha && job.base_ref) {
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${job.base_ref}`);
if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
}
if (!fromSha) {
const parent = await runner.revParse(workspace, `${toSha}^`);
if (parent) fromSha = parent;
}
if (!fromSha) {
throw new SkipJob(`no base commit available for ${toSha.slice(0, 10)} (initial commit)`);
}
if (fromSha === toSha) {
throw new SkipJob("base and head resolve to the same commit (empty change set)");
}
return { fromSha, toSha };
}
export class ReviewEngine {
constructor({ db, config, logger = console }) {
this.db = db;
this.config = config;
this.logger = logger;
this.log = (msg) => logger.info?.(msg) ?? console.log(msg);
}
globalLlm() {
return {
url: this.config.llmUrl,
token: this.config.llmToken,
model: this.config.llmModel,
protocol: this.config.llmProtocol,
authHeader: this.config.llmAuthHeader,
extraHeaders: this.config.llmExtraHeaders,
timeoutSeconds: this.config.llmTimeoutSeconds,
};
}
repoLlm(repo) {
const base = this.globalLlm();
return {
...base,
url: repo.llm_base_url || base.url,
token: repo.llm_token || base.token,
model: repo.llm_model || base.model,
protocol: repo.llm_provider || base.protocol,
};
}
clientFor(repo) {
return new GiteaClient({
baseUrl: normalizeBaseUrl(this.config.giteaUrl),
token: repo.gitea_token || this.config.giteaToken,
timeoutMs: this.config.httpTimeoutMs,
});
}
async ensureWorkspace(repo) {
const root = join(this.config.dataDir, "workspaces");
await mkdir(root, { recursive: true });
const dir = join(root, `${repo.owner}__${repo.name}`);
if (existsSync(join(dir, ".git"))) return dir;
await rm(dir, { recursive: true, force: true });
const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`;
const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log });
await runner.gitClone({ cloneUrl, token: repo.gitea_token || this.config.giteaToken, dir });
return dir;
}
async execute(job) {
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id);
if (!repo) throw new Error(`repository ${job.repo_id} no longer exists`);
if (!repo.enabled) throw new SkipJob("repository disabled");
const client = this.clientFor(repo);
const runner = new OcrRunner({
command: this.config.ocrCommand,
llm: this.repoLlm(repo),
timeoutMs: this.config.reviewTimeoutMs,
logger: this.log,
});
const logs = [];
const appendLog = (line) => {
logs.push(line);
if (logs.length > 400) logs.shift();
};
const setPhase = (phase, patch = {}) => {
updateJob(this.db, job.id, { phase, log: logs.join("\n"), ...patch });
};
setPhase("preparing");
const workspace = await this.ensureWorkspace(repo);
const { fromSha, toSha } = await resolveRange(runner, {
repo, job, workspace, token: repo.gitea_token || this.config.giteaToken,
});
appendLog(`range ${fromSha}..${toSha}`);
const excludes = parseList(repo.excludes);
setPhase("reviewing");
const review = await runner.review({
dir: workspace,
fromSha,
toSha,
excludes,
background: (repo.background_template || "").trim() || undefined,
concurrency: repo.concurrency || this.config.defaultConcurrency,
maxComments: repo.max_comments || 30,
maxTokensBudget: this.config.maxTokensBudget || 0,
rulePath: repo.rule_path || this.config.rulePath || undefined,
onOutput: (stream, text) => {
const trimmed = text.trim();
if (trimmed) appendLog(`${stream === "stderr" ? "[stderr] " : ""}${trimmed}`);
},
});
updateJob(this.db, job.id, { log: logs.join("\n") });
// Build the diff map so findings can be anchored to real diff lines.
let diffText = "";
try {
diffText = await this.gitDiff(workspace, fromSha, toSha);
} catch (err) {
appendLog(`diff fetch failed: ${err.message}`);
}
const diffFiles = parseUnifiedDiff(diffText);
const published = [];
const failed = [];
for (const comment of review.comments) {
const entry = diffFiles.get(comment.path);
if (!entry) {
failed.push({ comment, error: "文件不在本次 diff 中,已跳过" });
continue;
}
const anchor = pickAnchorLine(entry, comment.start_line, comment.end_line);
published.push({ comment, anchor, inline: anchor.inDiff });
}
// A finding blocks auto-merge (and turns the commit status red) when it
// meets the severity threshold, matches a blocked category, or when the
// repository opts into failing on any finding at all.
const blockSeverities = parseList(repo.block_severity);
const blockCategories = parseList(repo.block_categories);
const failOnFindings = Boolean(repo.fail_on_findings);
const blocking = published.filter(({ comment }) => {
if (failOnFindings) return true;
const sevHit = blockSeverities.some((s) => severityAtLeast(comment.severity, s));
const catHit = blockCategories.includes(String(comment.category || "").toLowerCase());
return sevHit || catHit;
});
// A partial or degraded review must never gate a merge: OCR reports
// warnings when whole files could not be reviewed, and merging on an
// incomplete result is exactly the failure mode this service must avoid.
// OCR terminal states: complete | partial | failed | skipped, plus the
// legacy "success" / "completed_with_warnings" spellings.
// "skipped" means the diff contained no reviewable file at all, which is a
// clean outcome rather than partial coverage; "partial" / "failed" mean
// some selected files were never reviewed and must not gate a merge.
const CLEAN_STATUSES = new Set(["complete", "success", "skipped"]);
const reviewIncomplete = Boolean(review.summary?.budget_exceeded)
|| (Array.isArray(review.warnings) && review.warnings.length > 0)
|| !CLEAN_STATUSES.has(review.status ?? "complete");
if (reviewIncomplete) {
appendLog(`review reported incomplete coverage (status=${review.status ?? "?"}, warnings=${review.warnings?.length ?? 0})`);
}
setPhase("publishing");
const prNumber = job.pr_number ?? (await this.findPullRequestForSha(client, repo, toSha, job.ref_name));
if (prNumber && !job.pr_number) {
updateJob(this.db, job.id, { pr_number: prNumber });
job.pr_number = prNumber;
}
let inlinePosted = 0;
let reviewBody = "";
// Publishing to a merged or closed PR would be noise; keep the findings in
// the job record instead.
let prIsOpen = Boolean(prNumber);
if (prIsOpen) {
try {
const pr = await client.getPullRequest(repo.owner, repo.name, prNumber);
prIsOpen = pr?.state === "open" && !pr?.merged;
} catch (err) {
appendLog(`cannot load PR #${prNumber}: ${err.message}`);
prIsOpen = false;
}
}
if (prIsOpen && repo.publish_mode !== "issue-only") {
const inline = published.filter((p) => p.inline);
reviewBody = renderSummaryBody({
repo, job, review, published, failed, blocking,
note: inline.length < published.length
? `${published.length - inline.length} 条意见无法定位到本次 diff 的行,已汇总在本评论中。`
: "",
});
try {
await client.createPullReview(repo.owner, repo.name, prNumber, {
event: "COMMENT",
body: reviewBody,
commitId: toSha,
comments: inline.map((p) => ({
path: p.comment.path,
newPosition: p.anchor.line,
body: renderCommentBody(p.comment),
})),
});
inlinePosted = inline.length;
appendLog(`posted pull review with ${inlinePosted} inline comment(s)`);
} catch (err) {
appendLog(`pull review failed: ${err.message}`);
// Fall back to an issue comment so the findings are not lost.
try {
await client.createIssueComment(repo.owner, repo.name, prNumber, reviewBody);
appendLog("posted summary as issue comment instead");
} catch (fallbackErr) {
appendLog(`issue comment fallback failed: ${fallbackErr.message}`);
}
}
}
// Issue lifecycle: one open issue per repository+ref, updated in place.
// When issue creation is disabled the service still closes any issue it
// previously opened once the ref is clean, so stale issues do not linger.
let issueNumber = null;
const labels = parseList(repo.issue_labels);
const issueTitle = `[OCR] ${repoSlug(repo)} · ${job.ref_name} 存在阻断级代码问题`;
try {
issueNumber = await this.upsertIssue({
client, repo, job, blocking, labels, title: issueTitle,
createEnabled: Boolean(repo.create_issue),
summaryUrl: prNumber
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
: `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/commit/${toSha}`,
body: renderIssueBody({
repo, job, blocking,
summaryUrl: prNumber ? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}` : "",
}),
});
} catch (err) {
appendLog(`issue upsert failed: ${err.message}`);
}
// Commit status so branch protection can require this context.
const state = blocking.length > 0 ? "failure" : "success";
try {
await client.createCommitStatus(repo.owner, repo.name, toSha, {
state,
context: STATUS_CONTEXT,
description: blocking.length > 0
? `${blocking.length} blocking issue(s), ${published.length} total`
: published.length > 0
? `${published.length} comment(s), none blocking`
: "no issues found",
targetUrl: prNumber
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
: "",
});
appendLog(`commit status ${state} (${STATUS_CONTEXT})`);
} catch (err) {
appendLog(`commit status failed: ${err.message}`);
}
setPhase("finalizing");
const merged = await this.maybeAutoMerge({
client, repo, job, prNumber, blocking, toSha, appendLog,
reviewIncomplete, statusState: state,
});
const result = {
fromSha, toSha, prNumber, issueNumber,
comments: published.length,
inlineComments: inlinePosted,
blocking: blocking.length,
failed: failed.length,
merged,
reviewStatus: review.status,
summary: review.summary,
warnings: review.warnings,
};
updateJob(this.db, job.id, {
status: "succeeded",
phase: "done",
findings: published.length,
blocking: blocking.length,
comment_count: inlinePosted,
issue_number: issueNumber,
merged: merged ? 1 : 0,
result_json: JSON.stringify(result),
log: logs.join("\n"),
});
setBranchState(this.db, repo.id, job.ref_name, toSha);
return result;
}
async gitDiff(dir, fromSha, toSha) {
const { spawn } = await import("node:child_process");
return new Promise((resolve, reject) => {
const child = spawn("git", ["diff", "--no-color", "--find-renames", fromSha, toSha], {
cwd: dir, windowsHide: true,
});
let out = "";
let err = "";
child.stdout.on("data", (c) => { out += c.toString(); });
child.stderr.on("data", (c) => { err += c.toString(); });
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve(out);
else reject(new Error(`git diff failed (${code}): ${err.trim()}`));
});
});
}
/**
* Find the OPEN pull request that a push belongs to.
* Merged/closed pull requests are ignored: a push to the base branch after a
* merge must not attach new review comments to the finished PR.
*/
async findPullRequestForSha(client, repo, sha, refName) {
try {
const list = await client.listPullRequests(repo.owner, repo.name, {
state: "open", limit: 50,
});
const match = (list || []).find(
(p) => p.head?.sha === sha || (refName && p.head?.ref === refName),
);
if (match) return match.number;
} catch { /* ignore */ }
return null;
}
async upsertIssue({ client, repo, job, blocking, labels, title, body, createEnabled = true }) {
// Look up any open issue previously opened by this service for this
// repository + ref, so reruns update it instead of stacking new issues.
const openIssues = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, {
query: { state: "open", type: "issues", limit: 100 },
}).catch(() => []);
const existing = (openIssues || []).find((i) => i.title === title)
?? (openIssues || []).find(
(i) => String(i.body || "").includes(SUMMARY_MARKER)
&& String(i.title || "").includes(`${repoSlug(repo)} · ${job.ref_name}`),
);
if (blocking.length === 0) {
if (existing) {
await client.createIssueComment(repo.owner, repo.name, existing.number,
`已在 \`${String(job.to_sha).slice(0, 10)}\` 上复查通过,关闭该 Issue。`);
await client.updateIssue(repo.owner, repo.name, existing.number, { state: "closed" });
return existing.number;
}
return null;
}
if (!createEnabled) {
// Issue creation is disabled for this repository; leave any existing
// issue untouched rather than opening a new one.
return existing?.number ?? null;
}
// Gitea's issue API expects label IDs, so resolve names first.
const labelIds = labels.length
? await client.ensureLabels(repo.owner, repo.name, labels)
: [];
if (existing) {
await client.updateIssue(repo.owner, repo.name, existing.number, { body, title });
if (labelIds.length) {
await client.put(`/api/v1/repos/${repo.owner}/${repo.name}/issues/${existing.number}/labels`,
{ labels: labelIds }).catch(() => {});
}
await client.createIssueComment(repo.owner, repo.name, existing.number,
`已用 \`${String(job.to_sha).slice(0, 10)}\` 的最新审查结果更新该 Issue。`);
return existing.number;
}
const created = await client.createIssue(repo.owner, repo.name, {
title, body, labels: labelIds.length ? labelIds : undefined,
});
return created?.number ?? null;
}
async maybeAutoMerge({
client, repo, job, prNumber, blocking, toSha, appendLog,
reviewIncomplete = false, statusState = "success",
}) {
if (!repo.auto_merge) return false;
if (!prNumber) {
appendLog("auto-merge skipped: no pull request for this branch");
return false;
}
if (reviewIncomplete) {
appendLog("auto-merge skipped: review coverage was incomplete");
return false;
}
if (statusState !== "success") {
appendLog(`auto-merge skipped: commit status is ${statusState}`);
return false;
}
if (blocking.length > 0) {
appendLog(`auto-merge skipped: ${blocking.length} blocking finding(s)`);
return false;
}
let pr;
try {
pr = await client.getPullRequest(repo.owner, repo.name, prNumber);
} catch (err) {
appendLog(`auto-merge skipped: cannot load PR: ${err.message}`);
return false;
}
if (!pr || pr.merged) return false;
if (pr.state !== "open") {
appendLog("auto-merge skipped: PR is not open");
return false;
}
if (pr.head?.sha && pr.head.sha !== toSha) {
appendLog(`auto-merge skipped: PR head moved to ${String(pr.head.sha).slice(0, 10)}`);
return false;
}
if (pr.mergeable === false) {
appendLog("auto-merge skipped: PR is not mergeable");
return false;
}
if (repo.auto_merge_mode === "immediate" && pr.mergeable === undefined) {
appendLog("auto-merge skipped: mergeability unknown");
return false;
}
try {
await client.mergePullRequest(repo.owner, repo.name, prNumber, {
style: repo.merge_method || "squash",
title: pr.title,
deleteBranch: Boolean(repo.delete_branch),
headCommitId: toSha,
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
});
appendLog("auto-merge requested");
return true;
} catch (err) {
appendLog(`auto-merge failed: ${err.message}`);
return false;
}
}
async failJob(job, err) {
const message = err instanceof SkipJob
? `skipped: ${err.reason}`
: `${err.name || "Error"}: ${err.message}`;
this.log(`job #${job.id} ${message}`);
updateJob(this.db, job.id, {
status: err instanceof SkipJob ? "skipped" : "failed",
phase: err instanceof SkipJob ? "skipped" : "failed",
error: message,
});
if (!(err instanceof SkipJob) && job.pr_number) {
try {
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id);
if (repo) {
const client = this.clientFor(repo);
await client.createCommitStatus(repo.owner, repo.name, job.to_sha, {
state: "error",
context: STATUS_CONTEXT,
description: "review failed to run",
});
await client.createIssueComment(repo.owner, repo.name, job.pr_number,
`${SUMMARY_MARKER}\n代码审查执行失败:\n\n\`\`\`\n${err.message}\n\`\`\`\n\n<sub>job #${job.id}</sub>`);
}
} catch (postErr) {
this.log(`failed to report job error: ${postErr.message}`);
}
}
}
}
export async function writeWorkspaceFile(dir, name, content) {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, name), content, "utf8");
}