push 审查 check_branches 现在只表示「要做 push 审查的分支」,不再兼作 PR 目标分支: - 勾选具体分支:这些分支的 push 会审查,通过后晋级到管理分支 - 新增「任意分支」(*):所有分支的 push 都审查 - 留空:只审 PR,不审 push PR 审查 始终覆盖「勾选的分支 + 管理分支」作为合并目标。 管理分支是最终目标,不需要出现在检查分支里也能被保护。 管理分支从候选中移除 分支选择器不再列出管理分支——它是终点,不需要再检查自己。 安全边界 「任意分支」只扩大审查范围,不会把任意分支自动合并进管理分支; 晋级仍要求分支被显式勾选(promotesToManaged)。 同时修正一处语义错误 早先 branchMatches 把空列表当成「审所有分支」,导致新建仓库在用户 还没选分支时就审查全部 push。现在空列表明确表示「只审 PR」。 测试:分支匹配矩阵重写,覆盖 push/PR/晋级/保护目标四类判定,共 20 项通过。
1083 lines
42 KiB
JavaScript
1083 lines
42 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, 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");
|
||
} |