Files
kgod 216bb51c92 feat: 重构被审查分支的选择逻辑
push 审查
  check_branches 现在只表示「要做 push 审查的分支」,不再兼作 PR 目标分支:
  - 勾选具体分支:这些分支的 push 会审查,通过后晋级到管理分支
  - 新增「任意分支」(*):所有分支的 push 都审查
  - 留空:只审 PR,不审 push

PR 审查
  始终覆盖「勾选的分支 + 管理分支」作为合并目标。
  管理分支是最终目标,不需要出现在检查分支里也能被保护。

管理分支从候选中移除
  分支选择器不再列出管理分支——它是终点,不需要再检查自己。

安全边界
  「任意分支」只扩大审查范围,不会把任意分支自动合并进管理分支;
  晋级仍要求分支被显式勾选(promotesToManaged)。

同时修正一处语义错误
  早先 branchMatches 把空列表当成「审所有分支」,导致新建仓库在用户
  还没选分支时就审查全部 push。现在空列表明确表示「只审 PR」。

测试:分支匹配矩阵重写,覆盖 push/PR/晋级/保护目标四类判定,共 20 项通过。
2026-09-21 17:12:57 +08:00

1083 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, retryTransient, isAlreadyMerged,
} from "./gitea.js";
import { OcrRunner, parseList, severityAtLeast } from "./ocr.js";
import { parseUnifiedDiff, pickAnchorLine } from "./diff.js";
import {
createReviewRecord, getBranchState, setBranchState, updateJob, updateReviewRecord,
} from "./db.js";
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
// Marks a pull request this service opened itself to promote a branch. Its
// webhook event is ignored because the same commits were already reviewed.
export const PROMOTION_MARKER = "<!-- gitea-codereview:promotion -->";
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}`;
}
/**
* Parse a git remote URL into { owner, name, host }.
* Accepts https://host/owner/repo.git, http://host/owner/repo,
* ssh://git@host:2222/owner/repo.git and git@host:owner/repo.git.
*/
export function parseRepoUrl(input) {
const raw = String(input || "").trim();
if (!raw) throw new Error("请填写 Git 地址");
let host = "";
let path = "";
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\d+\/)(.+)$/.exec(raw);
if (scp && !raw.includes("://")) {
host = scp[1];
path = scp[2];
} else {
let url;
try {
url = new URL(raw);
} catch {
throw new Error(`无法识别的 Git 地址:${raw}`);
}
host = url.host;
path = url.pathname;
}
const parts = path.replace(/^\/+/, "").replace(/\.git$/, "").split("/").filter(Boolean);
if (parts.length < 2) throw new Error(`Git 地址缺少 所有者/仓库名:${raw}`);
const name = parts.pop();
const owner = parts.join("/");
return { owner, name, host };
}
/** Branch names are stored with or without the refs/heads/ prefix. */
function shortRef(refName) {
return String(refName || "").replace(/^refs\/heads\//, "");
}
/** The "any branch" token that widens push review to every branch. */
export const ANY_BRANCH = "*";
/**
* Branches explicitly picked for checking.
* The "any branch" token is not a branch name, so it is filtered out.
*/
export function selectedBranches(checkBranches) {
return parseList(checkBranches).filter((b) => b !== ANY_BRANCH);
}
/**
* True when pushes on every branch are reviewed.
* An empty list means "pull requests only", not "every push" — newly added
* repositories must not start reviewing pushes until branches are picked.
*/
export function reviewsAnyPush(checkBranches) {
return parseList(checkBranches).includes(ANY_BRANCH);
}
/**
* Whether a push to `refName` should be reviewed.
* Selecting "any branch" reviews everything; otherwise only the listed ones.
*/
export function branchMatches(refName, checkBranches) {
if (reviewsAnyPush(checkBranches)) return true;
const name = shortRef(refName);
return selectedBranches(checkBranches).includes(name);
}
/**
* Whether a pull request should be reviewed.
*
* A pull request counts when it merges INTO a checked branch, or into the
* managed branch — the managed branch is the final target, so it is protected
* without needing to be listed as a checked branch. Matching the head branch
* is kept as a fallback so explicitly listing a long-lived branch still covers
* pull requests originating from it.
*/
export function pullRequestMatches(headRef, baseRef, checkBranches, managedBranch) {
const target = shortRef(baseRef);
const head = shortRef(headRef);
const list = selectedBranches(checkBranches);
if (managedBranch) {
// The managed branch is the final target: always protected for pull
// requests, even though it is deliberately absent from the checked list.
if (target === shortRef(managedBranch)) return true;
return list.includes(target) || list.includes(head);
}
// No managed branch configured: an empty list falls back to reviewing all
// pull requests rather than silently reviewing none.
if (list.length === 0) return true;
return list.includes(target) || list.includes(head);
}
/**
* Whether a push to this branch should be promoted into the managed branch.
*
* Only explicitly listed branches promote. Selecting "any branch" widens
* *review* to every branch but must not auto-merge arbitrary branches into the
* managed branch, so it never promotes on its own.
*/
export function promotesToManaged(refName, checkBranches, managedBranch) {
const name = shortRef(refName);
if (!name || name === shortRef(managedBranch)) return false;
return selectedBranches(checkBranches).includes(name);
}
/** Whether `baseRef` is a branch that pull requests may be merged into. */
export function isProtectedTarget(baseRef, checkBranches, managedBranch) {
const target = shortRef(baseRef);
if (!target) return false;
if (managedBranch && target === shortRef(managedBranch)) return true;
return selectedBranches(checkBranches).includes(target);
}
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");
}
const SEVERITY_ORDER = ["critical", "high", "medium", "low"];
/** Highest severity present in a finding list, or null when empty. */
export function topSeverity(findings) {
for (const level of SEVERITY_ORDER) {
if (findings.some((f) => String(f.comment?.severity || "").toLowerCase() === level)) return level;
}
return null;
}
/** Count findings per severity, highest first, skipping empty levels. */
function severityCounts(findings) {
const counts = new Map();
for (const f of findings) {
const level = String(f.comment?.severity || "unknown").toLowerCase();
counts.set(level, (counts.get(level) || 0) + 1);
}
return [...counts.entries()].sort(
(a, b) => SEVERITY_ORDER.indexOf(a[0]) - SEVERITY_ORDER.indexOf(b[0]),
);
}
function renderIssueBody({ repo, job, findings, blocking, summaryUrl }) {
const counts = severityCounts(findings);
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(
`- 问题分布:${counts.map(([level, n]) => `${level} ${n}`).join(" · ")}`,
`- 其中阻断合并:${blocking.length} 条`,
);
lines.push("", `### 全部问题(${findings.length} 条)`, "");
for (const item of findings) {
const c = item.comment;
const where = c.start_line ? `${c.path}:${c.start_line}` : c.path;
const blocked = blocking.includes(item) ? " **(阻断合并)**" : "";
lines.push(`- ${badge(c)}\`${where}\`${blocked} — ${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.managed_branch && job.managed_branch !== job.base_ref) {
refs.push(`+refs/heads/${job.managed_branch}:refs/remotes/origin/${job.managed_branch}`);
}
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);
}
// The review base is whatever this change would merge into: for a pull
// request that is the PR's own target branch, and only a bare push falls
// back to the managed branch. Preferring the managed branch here would
// compare a PR against an unrelated branch.
const baseBranch = job.base_ref || job.managed_branch;
if (!fromSha && baseBranch) {
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${baseBranch}`);
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;
// Reaching here means the workspace is missing or was removed; a fresh
// clone always persists the credential for later fetches.
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);
// Jobs carry the managed branch so the fetch/merge-base logic does not need
// to re-read the repository row.
job.managed_branch = repo.managed_branch || repo.base_branch;
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.
// Every finding gets an issue regardless of severity — severity is carried
// in the title and body so triage stays possible without losing low-level
// findings. Blocking only decides whether the merge is held back.
let issueNumber = null;
const labels = parseList(repo.issue_labels);
const top = topSeverity(published);
const issueTitle = top
? `[OCR][${top}] ${repoSlug(repo)} · ${job.ref_name} 有 ${published.length} 条待处理问题`
: `[OCR] ${repoSlug(repo)} · ${job.ref_name} 代码审查`;
const detailUrl = prNumber
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
: `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/commit/${toSha}`;
try {
issueNumber = await this.upsertIssue({
client, repo, job, findings: published, blocking, labels, title: issueTitle,
createEnabled: Boolean(repo.create_issue),
summaryUrl: detailUrl,
body: renderIssueBody({ repo, job, findings: published, blocking, summaryUrl: detailUrl }),
});
} 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,
};
// Persist the review record that the history list and summariser consume.
let recordId = null;
try {
recordId = createReviewRecord(this.db, {
jobId: job.id,
repoId: repo.id,
refName: job.ref_name,
fromSha,
toSha,
prNumber,
prUrl: prNumber
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
: null,
trigger: job.trigger,
source: job.trigger?.startsWith("pull_request") ? "pull_request" : "push",
requirement: (repo.background_template || "").trim() || null,
paramsJson: JSON.stringify({
managed_branch: job.managed_branch || null,
base_ref: job.base_ref ?? null,
review_scope: repo.review_scope,
block_severity: repo.block_severity,
block_categories: repo.block_categories,
publish_mode: repo.publish_mode,
auto_merge: Boolean(repo.auto_merge),
auto_merge_mode: repo.auto_merge_mode,
merge_method: repo.merge_method,
concurrency: repo.concurrency,
excludes: repo.excludes || null,
max_comments: repo.max_comments,
review_incomplete: reviewIncomplete,
}),
findingsJson: JSON.stringify(published.map((p) => ({
path: p.comment.path,
start_line: p.comment.start_line,
end_line: p.comment.end_line,
severity: p.comment.severity,
category: p.comment.category,
content: p.comment.content,
inline: p.inline,
blocking: blocking.includes(p),
}))),
llmModel: review.summary?.model ?? repo.llm_model ?? this.config.llmModel,
llmStatus: review.status,
tokensTotal: review.summary?.total_tokens ?? 0,
elapsed: review.summary?.elapsed ?? null,
});
result.recordId = recordId;
} catch (err) {
appendLog(`review record failed: ${err.message}`);
}
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, findings, 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}`),
);
// No findings at all: the ref is clean, so retire any open issue.
if (findings.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;
}
// Tag every issue with its severity so the list can be filtered by level,
// on top of whatever labels the repository configured.
const top = topSeverity(findings);
const labelNames = [...labels];
if (top && !labelNames.includes(top)) labelNames.push(top);
// Gitea's issue API expects label IDs, so resolve names first.
const labelIds = labelNames.length
? await client.ensureLabels(repo.owner, repo.name, labelNames)
: [];
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;
}
/**
* Get the change into the next branch up the chain.
*
* When the reviewed ref has an open pull request we merge it. When it does
* not (a direct push to a checked branch) we open one automatically, so a
* push to `test` still flows into `prd` with a reviewable record.
*/
async maybeAutoMerge({
client, repo, job, prNumber, blocking, toSha, appendLog,
reviewIncomplete = false, statusState = "success",
}) {
if (!repo.auto_merge) return false;
// The merge path follows the event that produced this job, not whether a
// pull request happens to be linked. A push to `test` must promote the
// branch even when an open `test -> prd` pull request already exists;
// otherwise that leftover request hijacks the branch's own promotion.
const fromPullRequest = String(job.trigger || "").startsWith("pull_request");
if (!fromPullRequest) {
return this.promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState });
}
if (!prNumber) {
appendLog("auto-merge skipped: pull request event without a pull request number");
return false;
}
// A two-stage flow (feature -> test -> prd) legitimately merges into a
// checked branch that is not the managed branch, so the gate is "is the
// target one of the branches we protect", not "is it the managed branch".
if (job.base_ref && !isProtectedTarget(job.base_ref, repo.check_branches, repo.managed_branch)) {
appendLog(`auto-merge skipped: PR targets ${job.base_ref}, which is neither the managed branch nor a checked 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 retryTransient(() => 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",
}), {
attempts: 4,
baseDelayMs: 2000,
onRetry: (attempt, err) => appendLog(
`PR #${prNumber} merge deferred (attempt ${attempt}): ${err.message}`,
),
});
appendLog(`auto-merge requested for PR #${prNumber}`);
await this.closeRepoIssues({ client, repo, appendLog, reason: `PR #${prNumber} 已合并` });
return true;
} catch (err) {
if (isAlreadyMerged(err)) {
appendLog(`PR #${prNumber} was already merged; treating as success`);
await this.closeRepoIssues({ client, repo, appendLog, reason: `PR #${prNumber} 已合并` });
return true;
}
appendLog(`auto-merge failed: ${err.message}`);
await client.createIssueComment(repo.owner, repo.name, prNumber,
`自动合并失败:${err.message}\n\n审查提交:\`${toSha}\`。可稍后重新推送,或在界面手动合并。`).catch(() => {});
return false;
}
}
/**
* Open a pull request from the pushed branch into the next branch in the
* chain and merge it, then close the repository's review issues.
*/
async promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState }) {
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;
}
const managed = repo.managed_branch;
// Only explicitly listed branches promote. "任意分支" widens review to every
// branch but must not auto-merge arbitrary branches into the managed one.
if (!promotesToManaged(job.ref_name, repo.check_branches, managed)) {
if (reviewsAnyPush(repo.check_branches)) {
appendLog(`auto-merge skipped: ${job.ref_name} is not an explicitly checked branch`);
} else {
appendLog(`auto-merge skipped: ${job.ref_name} is not a checked branch`);
}
return false;
}
// The managed branch is the top of the chain and has nowhere to promote to.
const target = managed;
if (!target) {
appendLog("auto-merge skipped: no managed branch configured");
return false;
}
let head;
let base;
try {
head = await client.getBranch(repo.owner, repo.name, job.ref_name);
base = await client.getBranch(repo.owner, repo.name, target);
} catch (err) {
appendLog(`auto-merge skipped: cannot resolve branches: ${err.message}`);
return false;
}
const headSha = head?.commit?.id;
const baseSha = base?.commit?.id;
if (!headSha || !baseSha) {
appendLog("auto-merge skipped: branch head unknown");
return false;
}
if (headSha === baseSha) {
appendLog(`auto-merge skipped: ${job.ref_name} already matches ${target}`);
return false;
}
// Only promote the exact commit that was reviewed.
if (headSha !== toSha) {
appendLog(`auto-merge skipped: ${job.ref_name} moved to ${headSha.slice(0, 10)} after the review`);
return false;
}
// Is there already an open PR for this promotion? Reuse it instead of
// opening a duplicate on every push.
let promotionPr = null;
try {
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50 });
promotionPr = (open || []).find(
(p) => p.head?.ref === job.ref_name && p.base?.ref === target,
) ?? null;
} catch { /* fall through to creating one */ }
if (!promotionPr) {
try {
promotionPr = await client.createPullRequest(repo.owner, repo.name, {
title: `[OCR] ${job.ref_name} → ${target}`,
head: job.ref_name,
base: target,
body: [
PROMOTION_MARKER,
`由 gitea-codereview 自动创建。`,
"",
`- 来源分支:\`${job.ref_name}\``,
`- 目标分支:\`${target}\``,
`- 审查提交:\`${toSha}\``,
`- 代码审查已通过(job #${job.id})`,
].join("\n"),
});
appendLog(`opened promotion PR #${promotionPr?.number} (${job.ref_name} -> ${target})`);
} catch (err) {
appendLog(`auto-merge failed: cannot open promotion PR: ${err.message}`);
return false;
}
} else {
appendLog(`reusing open promotion PR #${promotionPr.number}`);
}
const index = promotionPr?.number;
if (!index) {
appendLog("auto-merge failed: promotion PR has no number");
return false;
}
// Gitea computes mergeability asynchronously and a large divergence can
// take a while, so poll with backoff instead of giving up after a few
// seconds. `null`/`undefined` means "not computed yet"; only an explicit
// false is a real conflict.
let pr = promotionPr;
let delayMs = 1000;
for (let attempt = 0; attempt < 8; attempt += 1) {
if (pr.mergeable === true || pr.mergeable === false) break;
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(delayMs * 1.6, 8000);
pr = await client.getPullRequest(repo.owner, repo.name, index).catch(() => pr);
}
if (pr.mergeable === false) {
appendLog(`auto-merge skipped: promotion PR #${index} conflicts with ${target}`);
await client.createIssueComment(repo.owner, repo.name, index,
`自动合并失败:\`${job.ref_name}\` 与 \`${target}\` 存在冲突,需要人工解决后重新推送。\n\n` +
`审查提交:\`${toSha}\``).catch(() => {});
return false;
}
if (pr.mergeable === undefined || pr.mergeable === null) {
appendLog(`auto-merge skipped: promotion PR #${index} mergeability still unknown after polling`);
await client.createIssueComment(repo.owner, repo.name, index,
`自动合并暂缓:Gitea 尚未算出该 PR 是否可合并。稍后重新推送或在界面手动合并。\n\n` +
`审查提交:\`${toSha}\``).catch(() => {});
return false;
}
if (pr.head?.sha && pr.head.sha !== toSha) {
appendLog(`auto-merge skipped: promotion PR #${index} head moved`);
return false;
}
try {
// Promotion always merges (never squashes): squashing rewrites the
// promoted commits into a brand-new commit, so the two long-lived
// branches diverge a little more on every promotion and eventually
// conflict. A real merge keeps the shared ancestry, so the next
// promotion only carries the new commits.
await retryTransient(() => client.mergePullRequest(repo.owner, repo.name, index, {
style: "merge",
title: pr.title,
deleteBranch: false,
headCommitId: toSha,
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
}), {
attempts: 4,
baseDelayMs: 2000,
onRetry: (attempt, err) => appendLog(
`promotion PR #${index} merge deferred (attempt ${attempt}): ${err.message}`,
),
});
appendLog(`auto-merge requested for promotion PR #${index}`);
await this.closeRepoIssues({
client, repo, appendLog,
reason: `${job.ref_name} 已合并到 ${target}(PR #${index})`,
});
return true;
} catch (err) {
if (isAlreadyMerged(err)) {
appendLog(`promotion PR #${index} was already merged; treating as success`);
await this.closeRepoIssues({
client, repo, appendLog,
reason: `${job.ref_name} 已合并到 ${target}(PR #${index})`,
});
return true;
}
appendLog(`auto-merge failed: promotion PR #${index}: ${err.message}`);
await client.createIssueComment(repo.owner, repo.name, index,
`自动合并失败:${err.message}\n\n审查提交:\`${toSha}\`。可稍后重新推送,或在界面手动合并。`).catch(() => {});
return false;
}
}
/**
* Close every open review issue for this repository.
*
* Called whenever a merge lands: once code is on its way into the managed
* branch the previous round is over, and any remaining problem will be
* reported again by the next review.
*/
async closeRepoIssues({ client, repo, appendLog, reason }) {
let open = [];
try {
open = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, {
query: { state: "open", type: "issues", limit: 100 },
}) ?? [];
} catch (err) {
appendLog(`cannot list issues to close: ${err.message}`);
return 0;
}
const mine = open.filter((i) => String(i.body || "").includes(SUMMARY_MARKER));
let closed = 0;
for (const issue of mine) {
try {
await client.createIssueComment(repo.owner, repo.name, issue.number,
`已合并:${reason}。本轮问题视为结束,关闭该 Issue;如后续审查再发现问题会重新创建。`);
await client.updateIssue(repo.owner, repo.name, issue.number, { state: "closed" });
closed += 1;
} catch (err) {
appendLog(`cannot close issue #${issue.number}: ${err.message}`);
}
}
if (closed) appendLog(`closed ${closed} review issue(s) after merge`);
return closed;
}
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");
}