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:
+133
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user