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
+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 };
}