Files
git-codereview/app/lib/review.js
T
kgod 6dc77ce907 feat: 仓库导入、分支模型重构与审查历史摘要
仓库接入
- 新增「从 Gitea 导入」:用全局 Token 列出可见仓库,一键建配置
- 新增仓库只填 Git 地址,自动解析 owner/name,支持 https/ssh/scp 写法
- 新增「测试连接」按钮,保存前即可校验地址并拉取分支

分支模型
- 由单一 base_branch + glob 改为「一个管理分支 + 多个检查分支」
- 管理分支是唯一合并目标;检查分支全部纳入监控
- 支持从任意分支克隆创建管理分支
- 审查基准改为管理分支的 merge-base;仅目标为管理分支的 PR 才自动合并
- 旧库自动迁移:base_branch 播种 managed_branch,branch_patterns 展开为检查分支

审查历史
- 新增 review_records 表与「审查历史」页
- 记录触发来源、PR 链接、审查范围、阻断阈值、发布方式、自动合并设置、
  排除路径、LLM 模型、token 消耗、耗时、需求背景与全部审查意见
- 详情弹窗一览,支持按仓库过滤

AI 摘要
- 新增独立摘要模块(app/lib/summary.js),与代码审查提示词分离
- 专用提示词输出固定四节、500 字内的中文记录:结论/范围/问题/要点
- 与代码审查共用全局 LLM 设置;摘要失败不影响审查与合并,可单条重跑

修复
- 摘要改用内置 fetch:运行镜像没有 curl,原先 spawn curl 必然 ENOENT
- 去掉 blob:none 部分克隆并把凭据写入 .git/config:
  惰性取 blob 不会带上 per-command extraHeader,私有库会报 could not read Username

UI
- 审查背景改为多行文本域(可滚动)
- 分支改为可点选列表,管理分支高亮
- 仓库表格展示管理分支与检查分支
2026-09-20 12:38:23 +08:00

734 lines
27 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);
}
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 the managed branch: every checked branch is compared
// against what it would merge into, not against its own previous commit.
const baseBranch = job.managed_branch || job.base_ref;
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;
}
// Only merges into the managed branch are performed; a PR targeting any
// other branch is reviewed but never auto-merged.
const managed = repo.managed_branch || repo.base_branch;
if (job.base_ref && managed && job.base_ref !== managed) {
appendLog(`auto-merge skipped: PR targets ${job.base_ref}, managed branch is ${managed}`);
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");
}