Files
git-codereview/app/lib/review.js
T
kgod ab509eee0d fix: 支持 test→prd 两级流转的自动合并与 synchronize 事件
1. 自动合并门槛过严
   上一版要求 PR 目标必须等于 managed_branch 才自动合并,
   这会让两级流转(feature -> test -> prd)的第一级永远合不进去。
   改为:目标命中任一检查分支即可自动合并。
   managed_branch 退化为「push 无 PR 时的对比基准」与默认值。

2. Gitea 的 PR 更新事件名是 synchronized
   Gitea webhook 的 payload.action 用 synchronized,
   只有它的 Actions runner 才会改写成 GitHub 风格的 synchronize。
   原先只认 synchronize,导致「PR 有新提交」永远不触发。
   现在两种拼写都接受。

验证(真实 webhook,非手工触发):
- feature -> test 开 PR:pull_request.opened 自动入队并审查
- 再推一个 commit:pull_request.synchronized 自动入队
- 无阻断问题 -> commit status success -> 自动 squash 合并,PR #4 已 merged
- 向未受保护分支推送会被正确过滤
2026-09-20 14:54:33 +08:00

750 lines
28 KiB
JavaScript
Raw 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 } 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 -->";
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;
}
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;
}
// 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");
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");
}