直接推送也能晋级 往受保护分支直接推代码(没有 PR)时,审查通过后自动开一个 <分支> → <管理分支> 的 PR 并合并它,而不是放弃自动合并。 已存在同类 open PR 时复用它,不重复创建。 晋级 PR 不再被重复审查 自动创建的 PR 带 PROMOTION_MARKER,其 pull_request 事件直接跳过。 同一批提交在 push 时已经审过,重复审会在合并完成后凭空造出 Issue (此前确实产生了这样一个幽灵 Issue)。 合并即关闭该仓库全部审查 Issue 两条触发路径: - 服务自己完成合并后立即关闭 - 人在 Gitea 手动合并时,pull_request closed + merged=true 事件触发关闭 手动合并是「代码已落地」最可靠的信号,不再依赖 push 侧的 merge 提交探测。 晋级使用 merge 而非 squash squash 会把晋级提交重写成全新提交,两个长期分支每晋级一次就多分叉 一点,最终必然冲突(已在 offerpai_h5 上复现 add/add 冲突)。 merge 保留共同祖先,下次晋级只携带新提交。 合并可等待性 Gitea 异步计算 mergeable,原先只轮询 5 秒就放弃。改为指数退避约 30 秒, 并区分「尚未算出」(继续等)与「确实冲突」(放弃)。失败时往 PR 留评论 说明原因,不再只写服务日志。 验证(真实 webhook): 推送 test → 审查通过 → 自动开 PR #9 (test→prd) → merge 合并 → pull_request merged 事件 → 关闭 0 个待处理 Issue prd 顶端前进为合并提交,test/prd 保持一致,无幽灵 Issue 产生
946 lines
36 KiB
JavaScript
946 lines
36 KiB
JavaScript
/**
|
||
* 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 {
|
||
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 };
|
||
}
|
||
|
||
/** Whether `refName` is one of the branches this repository reviews. */
|
||
export function branchMatches(refName, checkBranches) {
|
||
const list = parseList(checkBranches);
|
||
if (list.length === 0) return true;
|
||
const short = String(refName || "").replace(/^refs\/heads\//, "");
|
||
return list.some((b) => b === short || b === refName);
|
||
}
|
||
|
||
/**
|
||
* Whether a pull request should be reviewed.
|
||
*
|
||
* The check list names the *target* branches worth protecting, so a PR counts
|
||
* when it merges INTO a checked branch. That keeps "review everything that
|
||
* lands on prd/test" working even though feature branches are never listed.
|
||
* As a fallback the head branch is also matched, so explicitly listing a
|
||
* long-lived branch still reviews pushes and PRs originating from it.
|
||
*/
|
||
export function pullRequestMatches(headRef, baseRef, checkBranches) {
|
||
if (branchMatches(baseRef, checkBranches)) return true;
|
||
return branchMatches(headRef, checkBranches);
|
||
}
|
||
|
||
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.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.
|
||
// 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,
|
||
};
|
||
|
||
// 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, 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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
if (!prNumber) {
|
||
return this.promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState });
|
||
}
|
||
// 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 && !branchMatches(job.base_ref, repo.check_branches)) {
|
||
appendLog(`auto-merge skipped: PR targets ${job.base_ref}, which is not 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 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 for PR #${prNumber}`);
|
||
await this.closeRepoIssues({ client, repo, appendLog, reason: `PR #${prNumber} 已合并` });
|
||
return true;
|
||
} catch (err) {
|
||
appendLog(`auto-merge failed: ${err.message}`);
|
||
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;
|
||
}
|
||
if (!branchMatches(job.ref_name, repo.check_branches)) {
|
||
appendLog(`auto-merge skipped: ${job.ref_name} is not a checked branch`);
|
||
return false;
|
||
}
|
||
|
||
// Walk up the chain: test -> prd. The managed branch is the top, so a push
|
||
// to it has nowhere to go.
|
||
const managed = repo.managed_branch;
|
||
if (job.ref_name === managed) {
|
||
appendLog(`auto-merge skipped: ${job.ref_name} is the managed branch`);
|
||
return false;
|
||
}
|
||
const target = managed;
|
||
if (!branchMatches(target, repo.check_branches) && target !== managed) {
|
||
appendLog(`auto-merge skipped: ${target} is not a checked branch`);
|
||
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 client.mergePullRequest(repo.owner, repo.name, index, {
|
||
style: "merge",
|
||
title: pr.title,
|
||
deleteBranch: false,
|
||
headCommitId: toSha,
|
||
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
|
||
});
|
||
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) {
|
||
appendLog(`auto-merge failed: promotion PR #${index}: ${err.message}`);
|
||
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");
|
||
} |