feat: Gitea 自动代码审查服务
基于 OpenCodeReview 的 webhook 服务:监听 Gitea 的 push 与 Pull Request 事件, 调用 OCR 审查 diff,把结果发布回 Gitea,并按阻断阈值决定是否自动合并。 主要能力: - Push / PR 事件触发,支持分支 glob 过滤与 PR-only / push-only 范围 - PR 内联评论(按 diff 行号定位)、汇总评论、Issue 生命周期、提交状态 - 可配置阻断阈值(严重级别 / 类别 / 任意意见) - 无阻断问题时自动合并,审查覆盖不完整时拒绝合并 - 内置 Web 后台:仓库配置、任务日志、失败重跑、连通性自检 - SQLite 持久化,worker 重启回收卡死任务,失败自动重试 实现为独立服务而非 Gitea Action:本机 act_runner 指向的实例不可达, 且后台配置与任务历史需要独立进程承载。
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* 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 { getBranchState, setBranchState, updateJob } 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}`;
|
||||
}
|
||||
|
||||
function globToRegExp(pattern) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
const body = escaped
|
||||
.replace(/\*\*/g, "\u0000")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\?/g, ".")
|
||||
.replace(/\u0000/g, ".*");
|
||||
return new RegExp(`^${body}$`);
|
||||
}
|
||||
|
||||
export function branchMatches(refName, patterns) {
|
||||
const list = parseList(patterns);
|
||||
if (list.length === 0 || list.includes("*")) return true;
|
||||
const short = refName.replace(/^refs\/heads\//, "");
|
||||
return list.some((p) => globToRegExp(p).test(short) || globToRegExp(p).test(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.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);
|
||||
}
|
||||
if (!fromSha && job.base_ref) {
|
||||
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${job.base_ref}`);
|
||||
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;
|
||||
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);
|
||||
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,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user