/** * 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 = ""; export const COMMENT_MARKER = ""; // 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 = ""; export const STATUS_CONTEXT = "code-review/ocr"; export class SkipJob extends Error { constructor(reason) { super(reason); this.name = "SkipJob"; this.reason = reason; } } function repoSlug(repo) { return `${repo.owner}/${repo.name}`; } /** * Parse a git remote URL into { owner, name, host }. * Accepts https://host/owner/repo.git, http://host/owner/repo, * ssh://git@host:2222/owner/repo.git and git@host:owner/repo.git. */ export function parseRepoUrl(input) { const raw = String(input || "").trim(); if (!raw) throw new Error("请填写 Git 地址"); let host = ""; let path = ""; const scp = /^(?:[^@/]+@)?([^:/]+):(?!\d+\/)(.+)$/.exec(raw); if (scp && !raw.includes("://")) { host = scp[1]; path = scp[2]; } else { let url; try { url = new URL(raw); } catch { throw new Error(`无法识别的 Git 地址:${raw}`); } host = url.host; path = url.pathname; } const parts = path.replace(/^\/+/, "").replace(/\.git$/, "").split("/").filter(Boolean); if (parts.length < 2) throw new Error(`Git 地址缺少 所有者/仓库名:${raw}`); const name = parts.pop(); const owner = parts.join("/"); return { owner, name, host }; } /** Whether `refName` is one of the branches this repository reviews. */ export function branchMatches(refName, checkBranches) { const list = parseList(checkBranches); if (list.length === 0) return true; const short = String(refName || "").replace(/^refs\/heads\//, ""); return list.some((b) => b === short || b === refName); } /** * Whether a pull request should be reviewed. * * The check list names the *target* branches worth protecting, so a PR counts * when it merges INTO a checked branch. That keeps "review everything that * lands on prd/test" working even though feature branches are never listed. * As a fallback the head branch is also matched, so explicitly listing a * long-lived branch still reviews pushes and PRs originating from it. */ export function pullRequestMatches(headRef, baseRef, checkBranches) { if (branchMatches(baseRef, checkBranches)) return true; return branchMatches(headRef, checkBranches); } function badge(comment) { const parts = [comment.category, comment.severity].filter(Boolean); return parts.length ? `[${parts.join(" · ")}] ` : ""; } function renderCommentBody(comment) { const lines = [`${badge(comment)}${String(comment.content || "").trim()}`]; if (comment.suggestion_code) { lines.push("", "```suggestion", String(comment.suggestion_code).replace(/\n+$/, ""), "```"); } lines.push("", COMMENT_MARKER); return lines.join("\n"); } function renderSummaryBody({ repo, job, review, published, failed, blocking, note }) { const lines = [SUMMARY_MARKER, "## OCR 代码审查", ""]; lines.push(`- 仓库:\`${repoSlug(repo)}\``); lines.push(`- 分支:\`${job.ref_name}\``); if (job.pr_number) lines.push(`- Pull Request:#${job.pr_number}`); lines.push(`- 提交:\`${String(job.to_sha).slice(0, 10)}\``); if (review.summary) { const s = review.summary; lines.push( `- 审查:${s.files_reviewed ?? "?"} 个文件 / ${s.comments ?? 0} 条意见` + `${s.total_tokens ? ` / ${s.total_tokens} tokens` : ""}` + `${s.elapsed ? ` / ${s.elapsed}` : ""}`, ); } if (note) lines.push("", note); lines.push(""); if (published.length === 0) { lines.push("未发现需要处理的问题。"); } else { lines.push(`### 审查意见(${published.length} 条)`, ""); const grouped = new Map(); for (const item of published) { const key = item.comment.path; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(item); } for (const [path, items] of grouped) { lines.push(`**\`${path}\`**`, ""); for (const item of items) { const where = item.comment.start_line ? `L${item.comment.start_line}${item.comment.end_line && item.comment.end_line !== item.comment.start_line ? `-${item.comment.end_line}` : ""}` : "位置未知"; const flag = item.inline ? "" : "(无法内联定位)"; lines.push(`- ${badge(item.comment)}${where}${flag} — ${String(item.comment.content || "").replace(/\s*\n\s*/g, " ").trim()}`); } lines.push(""); } } if (failed.length > 0) { lines.push(`### 发布失败(${failed.length} 条)`, ""); for (const item of failed) { lines.push(`- \`${item.comment.path}\` — ${item.error}`); } lines.push(""); } if (blocking.length > 0) { lines.push( "### 结论", "", `存在 ${blocking.length} 条达到阻断阈值的问题,已创建/更新 Issue,且不会自动合并。`, ); } else if (published.length > 0) { lines.push("### 结论", "", "未发现达到阻断阈值的问题。"); } lines.push( "", `由 gitea-codereview 基于 [OpenCodeReview](https://github.com/alibaba/open-code-review) 生成 · job #${job.id}`, ); 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("", `job #${job.id} · 由 gitea-codereview 生成`); return lines.join("\n"); } /** Resolve the fetch/review range for a job against the workspace clone. */ async function resolveRange(runner, { repo, job, workspace, token }) { const headRef = `refs/heads/${job.ref_name}`; const refs = [headRef, `+${headRef}:refs/remotes/origin/${job.ref_name}`]; if (job.base_ref) refs.push(`+refs/heads/${job.base_ref}:refs/remotes/origin/${job.base_ref}`); if (job.managed_branch && job.managed_branch !== job.base_ref) { refs.push(`+refs/heads/${job.managed_branch}:refs/remotes/origin/${job.managed_branch}`); } if (job.pr_number) refs.push(`+refs/pull/${job.pr_number}/head:refs/remotes/origin/pr/${job.pr_number}`); await runner.fetch(workspace, { refs, token }); const toSha = job.to_sha; const local = await runner.revParse(workspace, toSha); if (!local) { throw new Error(`commit ${toSha} not found in workspace after fetch`); } let fromSha = null; if (job.from_sha) { fromSha = await runner.revParse(workspace, job.from_sha); } // The review base is whatever this change would merge into: for a pull // request that is the PR's own target branch, and only a bare push falls // back to the managed branch. Preferring the managed branch here would // compare a PR against an unrelated branch. const baseBranch = job.base_ref || job.managed_branch; if (!fromSha && baseBranch) { const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${baseBranch}`); if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha; } if (!fromSha) { const parent = await runner.revParse(workspace, `${toSha}^`); if (parent) fromSha = parent; } if (!fromSha) { throw new SkipJob(`no base commit available for ${toSha.slice(0, 10)} (initial commit)`); } if (fromSha === toSha) { throw new SkipJob("base and head resolve to the same commit (empty change set)"); } return { fromSha, toSha }; } export class ReviewEngine { constructor({ db, config, logger = console }) { this.db = db; this.config = config; this.logger = logger; this.log = (msg) => logger.info?.(msg) ?? console.log(msg); } globalLlm() { return { url: this.config.llmUrl, token: this.config.llmToken, model: this.config.llmModel, protocol: this.config.llmProtocol, authHeader: this.config.llmAuthHeader, extraHeaders: this.config.llmExtraHeaders, timeoutSeconds: this.config.llmTimeoutSeconds, }; } repoLlm(repo) { const base = this.globalLlm(); return { ...base, url: repo.llm_base_url || base.url, token: repo.llm_token || base.token, model: repo.llm_model || base.model, protocol: repo.llm_provider || base.protocol, }; } clientFor(repo) { return new GiteaClient({ baseUrl: normalizeBaseUrl(this.config.giteaUrl), token: repo.gitea_token || this.config.giteaToken, timeoutMs: this.config.httpTimeoutMs, }); } async ensureWorkspace(repo) { const root = join(this.config.dataDir, "workspaces"); await mkdir(root, { recursive: true }); const dir = join(root, `${repo.owner}__${repo.name}`); if (existsSync(join(dir, ".git"))) return dir; // Reaching here means the workspace is missing or was removed; a fresh // clone always persists the credential for later fetches. await rm(dir, { recursive: true, force: true }); const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`; const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log }); await runner.gitClone({ cloneUrl, token: repo.gitea_token || this.config.giteaToken, dir }); return dir; } async execute(job) { const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id); if (!repo) throw new Error(`repository ${job.repo_id} no longer exists`); if (!repo.enabled) throw new SkipJob("repository disabled"); const client = this.clientFor(repo); const runner = new OcrRunner({ command: this.config.ocrCommand, llm: this.repoLlm(repo), timeoutMs: this.config.reviewTimeoutMs, logger: this.log, }); const logs = []; const appendLog = (line) => { logs.push(line); if (logs.length > 400) logs.shift(); }; const setPhase = (phase, patch = {}) => { updateJob(this.db, job.id, { phase, log: logs.join("\n"), ...patch }); }; setPhase("preparing"); const workspace = await this.ensureWorkspace(repo); // Jobs carry the managed branch so the fetch/merge-base logic does not need // to re-read the repository row. job.managed_branch = repo.managed_branch || repo.base_branch; const { fromSha, toSha } = await resolveRange(runner, { repo, job, workspace, token: repo.gitea_token || this.config.giteaToken, }); appendLog(`range ${fromSha}..${toSha}`); const excludes = parseList(repo.excludes); setPhase("reviewing"); const review = await runner.review({ dir: workspace, fromSha, toSha, excludes, background: (repo.background_template || "").trim() || undefined, concurrency: repo.concurrency || this.config.defaultConcurrency, maxComments: repo.max_comments || 30, maxTokensBudget: this.config.maxTokensBudget || 0, rulePath: repo.rule_path || this.config.rulePath || undefined, onOutput: (stream, text) => { const trimmed = text.trim(); if (trimmed) appendLog(`${stream === "stderr" ? "[stderr] " : ""}${trimmed}`); }, }); updateJob(this.db, job.id, { log: logs.join("\n") }); // Build the diff map so findings can be anchored to real diff lines. let diffText = ""; try { diffText = await this.gitDiff(workspace, fromSha, toSha); } catch (err) { appendLog(`diff fetch failed: ${err.message}`); } const diffFiles = parseUnifiedDiff(diffText); const published = []; const failed = []; for (const comment of review.comments) { const entry = diffFiles.get(comment.path); if (!entry) { failed.push({ comment, error: "文件不在本次 diff 中,已跳过" }); continue; } const anchor = pickAnchorLine(entry, comment.start_line, comment.end_line); published.push({ comment, anchor, inline: anchor.inDiff }); } // A finding blocks auto-merge (and turns the commit status red) when it // meets the severity threshold, matches a blocked category, or when the // repository opts into failing on any finding at all. const blockSeverities = parseList(repo.block_severity); const blockCategories = parseList(repo.block_categories); const failOnFindings = Boolean(repo.fail_on_findings); const blocking = published.filter(({ comment }) => { if (failOnFindings) return true; const sevHit = blockSeverities.some((s) => severityAtLeast(comment.severity, s)); const catHit = blockCategories.includes(String(comment.category || "").toLowerCase()); return sevHit || catHit; }); // A partial or degraded review must never gate a merge: OCR reports // warnings when whole files could not be reviewed, and merging on an // incomplete result is exactly the failure mode this service must avoid. // OCR terminal states: complete | partial | failed | skipped, plus the // legacy "success" / "completed_with_warnings" spellings. // "skipped" means the diff contained no reviewable file at all, which is a // clean outcome rather than partial coverage; "partial" / "failed" mean // some selected files were never reviewed and must not gate a merge. const CLEAN_STATUSES = new Set(["complete", "success", "skipped"]); const reviewIncomplete = Boolean(review.summary?.budget_exceeded) || (Array.isArray(review.warnings) && review.warnings.length > 0) || !CLEAN_STATUSES.has(review.status ?? "complete"); if (reviewIncomplete) { appendLog(`review reported incomplete coverage (status=${review.status ?? "?"}, warnings=${review.warnings?.length ?? 0})`); } setPhase("publishing"); const prNumber = job.pr_number ?? (await this.findPullRequestForSha(client, repo, toSha, job.ref_name)); if (prNumber && !job.pr_number) { updateJob(this.db, job.id, { pr_number: prNumber }); job.pr_number = prNumber; } let inlinePosted = 0; let reviewBody = ""; // Publishing to a merged or closed PR would be noise; keep the findings in // the job record instead. let prIsOpen = Boolean(prNumber); if (prIsOpen) { try { const pr = await client.getPullRequest(repo.owner, repo.name, prNumber); prIsOpen = pr?.state === "open" && !pr?.merged; } catch (err) { appendLog(`cannot load PR #${prNumber}: ${err.message}`); prIsOpen = false; } } if (prIsOpen && repo.publish_mode !== "issue-only") { const inline = published.filter((p) => p.inline); reviewBody = renderSummaryBody({ repo, job, review, published, failed, blocking, note: inline.length < published.length ? `${published.length - inline.length} 条意见无法定位到本次 diff 的行,已汇总在本评论中。` : "", }); try { await client.createPullReview(repo.owner, repo.name, prNumber, { event: "COMMENT", body: reviewBody, commitId: toSha, comments: inline.map((p) => ({ path: p.comment.path, newPosition: p.anchor.line, body: renderCommentBody(p.comment), })), }); inlinePosted = inline.length; appendLog(`posted pull review with ${inlinePosted} inline comment(s)`); } catch (err) { appendLog(`pull review failed: ${err.message}`); // Fall back to an issue comment so the findings are not lost. try { await client.createIssueComment(repo.owner, repo.name, prNumber, reviewBody); appendLog("posted summary as issue comment instead"); } catch (fallbackErr) { appendLog(`issue comment fallback failed: ${fallbackErr.message}`); } } } // Issue lifecycle: one open issue per repository+ref, updated in place. // When issue creation is disabled the service still closes any issue it // previously opened once the ref is clean, so stale issues do not linger. let issueNumber = null; const labels = parseList(repo.issue_labels); const issueTitle = `[OCR] ${repoSlug(repo)} · ${job.ref_name} 存在阻断级代码问题`; try { issueNumber = await this.upsertIssue({ client, repo, job, blocking, labels, title: issueTitle, createEnabled: Boolean(repo.create_issue), summaryUrl: prNumber ? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}` : `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/commit/${toSha}`, body: renderIssueBody({ repo, job, blocking, summaryUrl: prNumber ? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}` : "", }), }); } catch (err) { appendLog(`issue upsert failed: ${err.message}`); } // Commit status so branch protection can require this context. const state = blocking.length > 0 ? "failure" : "success"; try { await client.createCommitStatus(repo.owner, repo.name, toSha, { state, context: STATUS_CONTEXT, description: blocking.length > 0 ? `${blocking.length} blocking issue(s), ${published.length} total` : published.length > 0 ? `${published.length} comment(s), none blocking` : "no issues found", targetUrl: prNumber ? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}` : "", }); appendLog(`commit status ${state} (${STATUS_CONTEXT})`); } catch (err) { appendLog(`commit status failed: ${err.message}`); } setPhase("finalizing"); const merged = await this.maybeAutoMerge({ client, repo, job, prNumber, blocking, toSha, appendLog, reviewIncomplete, statusState: state, }); const result = { fromSha, toSha, prNumber, issueNumber, comments: published.length, inlineComments: inlinePosted, blocking: blocking.length, failed: failed.length, merged, reviewStatus: review.status, summary: review.summary, warnings: review.warnings, }; // Persist the review record that the history list and summariser consume. let recordId = null; try { recordId = createReviewRecord(this.db, { jobId: job.id, repoId: repo.id, refName: job.ref_name, fromSha, toSha, prNumber, prUrl: prNumber ? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}` : null, trigger: job.trigger, source: job.trigger?.startsWith("pull_request") ? "pull_request" : "push", requirement: (repo.background_template || "").trim() || null, paramsJson: JSON.stringify({ managed_branch: job.managed_branch || null, base_ref: job.base_ref ?? null, review_scope: repo.review_scope, block_severity: repo.block_severity, block_categories: repo.block_categories, publish_mode: repo.publish_mode, auto_merge: Boolean(repo.auto_merge), auto_merge_mode: repo.auto_merge_mode, merge_method: repo.merge_method, concurrency: repo.concurrency, excludes: repo.excludes || null, max_comments: repo.max_comments, review_incomplete: reviewIncomplete, }), findingsJson: JSON.stringify(published.map((p) => ({ path: p.comment.path, start_line: p.comment.start_line, end_line: p.comment.end_line, severity: p.comment.severity, category: p.comment.category, content: p.comment.content, inline: p.inline, blocking: blocking.includes(p), }))), llmModel: review.summary?.model ?? repo.llm_model ?? this.config.llmModel, llmStatus: review.status, tokensTotal: review.summary?.total_tokens ?? 0, elapsed: review.summary?.elapsed ?? null, }); result.recordId = recordId; } catch (err) { appendLog(`review record failed: ${err.message}`); } updateJob(this.db, job.id, { status: "succeeded", phase: "done", findings: published.length, blocking: blocking.length, comment_count: inlinePosted, issue_number: issueNumber, merged: merged ? 1 : 0, result_json: JSON.stringify(result), log: logs.join("\n"), }); setBranchState(this.db, repo.id, job.ref_name, toSha); return result; } async gitDiff(dir, fromSha, toSha) { const { spawn } = await import("node:child_process"); return new Promise((resolve, reject) => { const child = spawn("git", ["diff", "--no-color", "--find-renames", fromSha, toSha], { cwd: dir, windowsHide: true, }); let out = ""; let err = ""; child.stdout.on("data", (c) => { out += c.toString(); }); child.stderr.on("data", (c) => { err += c.toString(); }); child.on("error", reject); child.on("close", (code) => { if (code === 0) resolve(out); else reject(new Error(`git diff failed (${code}): ${err.trim()}`)); }); }); } /** * Find the OPEN pull request that a push belongs to. * Merged/closed pull requests are ignored: a push to the base branch after a * merge must not attach new review comments to the finished PR. */ async findPullRequestForSha(client, repo, sha, refName) { try { const list = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50, }); const match = (list || []).find( (p) => p.head?.sha === sha || (refName && p.head?.ref === refName), ); if (match) return match.number; } catch { /* ignore */ } return null; } async upsertIssue({ client, repo, job, blocking, labels, title, body, createEnabled = true }) { // Look up any open issue previously opened by this service for this // repository + ref, so reruns update it instead of stacking new issues. const openIssues = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, { query: { state: "open", type: "issues", limit: 100 }, }).catch(() => []); const existing = (openIssues || []).find((i) => i.title === title) ?? (openIssues || []).find( (i) => String(i.body || "").includes(SUMMARY_MARKER) && String(i.title || "").includes(`${repoSlug(repo)} · ${job.ref_name}`), ); if (blocking.length === 0) { if (existing) { await client.createIssueComment(repo.owner, repo.name, existing.number, `已在 \`${String(job.to_sha).slice(0, 10)}\` 上复查通过,关闭该 Issue。`); await client.updateIssue(repo.owner, repo.name, existing.number, { state: "closed" }); return existing.number; } return null; } if (!createEnabled) { // Issue creation is disabled for this repository; leave any existing // issue untouched rather than opening a new one. return existing?.number ?? null; } // Gitea's issue API expects label IDs, so resolve names first. const labelIds = labels.length ? await client.ensureLabels(repo.owner, repo.name, labels) : []; if (existing) { await client.updateIssue(repo.owner, repo.name, existing.number, { body, title }); if (labelIds.length) { await client.put(`/api/v1/repos/${repo.owner}/${repo.name}/issues/${existing.number}/labels`, { labels: labelIds }).catch(() => {}); } await client.createIssueComment(repo.owner, repo.name, existing.number, `已用 \`${String(job.to_sha).slice(0, 10)}\` 的最新审查结果更新该 Issue。`); return existing.number; } const created = await client.createIssue(repo.owner, repo.name, { title, body, labels: labelIds.length ? labelIds : undefined, }); return created?.number ?? null; } /** * Get the change into the next branch up the chain. * * When the reviewed ref has an open pull request we merge it. When it does * not (a direct push to a checked branch) we open one automatically, so a * push to `test` still flows into `prd` with a reviewable record. */ async maybeAutoMerge({ client, repo, job, prNumber, blocking, toSha, appendLog, reviewIncomplete = false, statusState = "success", }) { if (!repo.auto_merge) return false; if (!prNumber) { return this.promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState }); } // A two-stage flow (feature -> test -> prd) legitimately merges into a // checked branch that is not the managed branch, so the gate is "is the // target one of the branches we protect", not "is it the managed branch". if (job.base_ref && !branchMatches(job.base_ref, repo.check_branches)) { appendLog(`auto-merge skipped: PR targets ${job.base_ref}, which is not a checked branch`); return false; } if (reviewIncomplete) { appendLog("auto-merge skipped: review coverage was incomplete"); return false; } if (statusState !== "success") { appendLog(`auto-merge skipped: commit status is ${statusState}`); return false; } if (blocking.length > 0) { appendLog(`auto-merge skipped: ${blocking.length} blocking finding(s)`); return false; } let pr; try { pr = await client.getPullRequest(repo.owner, repo.name, prNumber); } catch (err) { appendLog(`auto-merge skipped: cannot load PR: ${err.message}`); return false; } if (!pr || pr.merged) return false; if (pr.state !== "open") { appendLog("auto-merge skipped: PR is not open"); return false; } if (pr.head?.sha && pr.head.sha !== toSha) { appendLog(`auto-merge skipped: PR head moved to ${String(pr.head.sha).slice(0, 10)}`); return false; } if (pr.mergeable === false) { appendLog("auto-merge skipped: PR is not mergeable"); return false; } if (repo.auto_merge_mode === "immediate" && pr.mergeable === undefined) { appendLog("auto-merge skipped: mergeability unknown"); return false; } try { await client.mergePullRequest(repo.owner, repo.name, prNumber, { style: repo.merge_method || "squash", title: pr.title, deleteBranch: Boolean(repo.delete_branch), headCommitId: toSha, mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed", }); appendLog(`auto-merge requested for PR #${prNumber}`); await this.closeRepoIssues({ client, repo, appendLog, reason: `PR #${prNumber} 已合并` }); return true; } catch (err) { appendLog(`auto-merge failed: ${err.message}`); return false; } } /** * Open a pull request from the pushed branch into the next branch in the * chain and merge it, then close the repository's review issues. */ async promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState }) { if (reviewIncomplete) { appendLog("auto-merge skipped: review coverage was incomplete"); return false; } if (statusState !== "success") { appendLog(`auto-merge skipped: commit status is ${statusState}`); return false; } if (!branchMatches(job.ref_name, repo.check_branches)) { appendLog(`auto-merge skipped: ${job.ref_name} is not a checked branch`); return false; } // Walk up the chain: test -> prd. The managed branch is the top, so a push // to it has nowhere to go. const managed = repo.managed_branch; if (job.ref_name === managed) { appendLog(`auto-merge skipped: ${job.ref_name} is the managed branch`); return false; } const target = managed; if (!branchMatches(target, repo.check_branches) && target !== managed) { appendLog(`auto-merge skipped: ${target} is not a checked branch`); return false; } let head; let base; try { head = await client.getBranch(repo.owner, repo.name, job.ref_name); base = await client.getBranch(repo.owner, repo.name, target); } catch (err) { appendLog(`auto-merge skipped: cannot resolve branches: ${err.message}`); return false; } const headSha = head?.commit?.id; const baseSha = base?.commit?.id; if (!headSha || !baseSha) { appendLog("auto-merge skipped: branch head unknown"); return false; } if (headSha === baseSha) { appendLog(`auto-merge skipped: ${job.ref_name} already matches ${target}`); return false; } // Only promote the exact commit that was reviewed. if (headSha !== toSha) { appendLog(`auto-merge skipped: ${job.ref_name} moved to ${headSha.slice(0, 10)} after the review`); return false; } // Is there already an open PR for this promotion? Reuse it instead of // opening a duplicate on every push. let promotionPr = null; try { const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50 }); promotionPr = (open || []).find( (p) => p.head?.ref === job.ref_name && p.base?.ref === target, ) ?? null; } catch { /* fall through to creating one */ } if (!promotionPr) { try { promotionPr = await client.createPullRequest(repo.owner, repo.name, { title: `[OCR] ${job.ref_name} → ${target}`, head: job.ref_name, base: target, body: [ PROMOTION_MARKER, `由 gitea-codereview 自动创建。`, "", `- 来源分支:\`${job.ref_name}\``, `- 目标分支:\`${target}\``, `- 审查提交:\`${toSha}\``, `- 代码审查已通过(job #${job.id})`, ].join("\n"), }); appendLog(`opened promotion PR #${promotionPr?.number} (${job.ref_name} -> ${target})`); } catch (err) { appendLog(`auto-merge failed: cannot open promotion PR: ${err.message}`); return false; } } else { appendLog(`reusing open promotion PR #${promotionPr.number}`); } const index = promotionPr?.number; if (!index) { appendLog("auto-merge failed: promotion PR has no number"); return false; } // Gitea computes mergeability asynchronously and a large divergence can // take a while, so poll with backoff instead of giving up after a few // seconds. `null`/`undefined` means "not computed yet"; only an explicit // false is a real conflict. let pr = promotionPr; let delayMs = 1000; for (let attempt = 0; attempt < 8; attempt += 1) { if (pr.mergeable === true || pr.mergeable === false) break; await new Promise((r) => setTimeout(r, delayMs)); delayMs = Math.min(delayMs * 1.6, 8000); pr = await client.getPullRequest(repo.owner, repo.name, index).catch(() => pr); } if (pr.mergeable === false) { appendLog(`auto-merge skipped: promotion PR #${index} conflicts with ${target}`); await client.createIssueComment(repo.owner, repo.name, index, `自动合并失败:\`${job.ref_name}\` 与 \`${target}\` 存在冲突,需要人工解决后重新推送。\n\n` + `审查提交:\`${toSha}\``).catch(() => {}); return false; } if (pr.mergeable === undefined || pr.mergeable === null) { appendLog(`auto-merge skipped: promotion PR #${index} mergeability still unknown after polling`); await client.createIssueComment(repo.owner, repo.name, index, `自动合并暂缓:Gitea 尚未算出该 PR 是否可合并。稍后重新推送或在界面手动合并。\n\n` + `审查提交:\`${toSha}\``).catch(() => {}); return false; } if (pr.head?.sha && pr.head.sha !== toSha) { appendLog(`auto-merge skipped: promotion PR #${index} head moved`); return false; } try { // Promotion always merges (never squashes): squashing rewrites the // promoted commits into a brand-new commit, so the two long-lived // branches diverge a little more on every promotion and eventually // conflict. A real merge keeps the shared ancestry, so the next // promotion only carries the new commits. await client.mergePullRequest(repo.owner, repo.name, index, { style: "merge", title: pr.title, deleteBranch: false, headCommitId: toSha, mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed", }); appendLog(`auto-merge requested for promotion PR #${index}`); await this.closeRepoIssues({ client, repo, appendLog, reason: `${job.ref_name} 已合并到 ${target}(PR #${index})`, }); return true; } catch (err) { appendLog(`auto-merge failed: promotion PR #${index}: ${err.message}`); return false; } } /** * Close every open review issue for this repository. * * Called whenever a merge lands: once code is on its way into the managed * branch the previous round is over, and any remaining problem will be * reported again by the next review. */ async closeRepoIssues({ client, repo, appendLog, reason }) { let open = []; try { open = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, { query: { state: "open", type: "issues", limit: 100 }, }) ?? []; } catch (err) { appendLog(`cannot list issues to close: ${err.message}`); return 0; } const mine = open.filter((i) => String(i.body || "").includes(SUMMARY_MARKER)); let closed = 0; for (const issue of mine) { try { await client.createIssueComment(repo.owner, repo.name, issue.number, `已合并:${reason}。本轮问题视为结束,关闭该 Issue;如后续审查再发现问题会重新创建。`); await client.updateIssue(repo.owner, repo.name, issue.number, { state: "closed" }); closed += 1; } catch (err) { appendLog(`cannot close issue #${issue.number}: ${err.message}`); } } if (closed) appendLog(`closed ${closed} review issue(s) after merge`); return closed; } async failJob(job, err) { const message = err instanceof SkipJob ? `skipped: ${err.reason}` : `${err.name || "Error"}: ${err.message}`; this.log(`job #${job.id} ${message}`); updateJob(this.db, job.id, { status: err instanceof SkipJob ? "skipped" : "failed", phase: err instanceof SkipJob ? "skipped" : "failed", error: message, }); if (!(err instanceof SkipJob) && job.pr_number) { try { const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id); if (repo) { const client = this.clientFor(repo); await client.createCommitStatus(repo.owner, repo.name, job.to_sha, { state: "error", context: STATUS_CONTEXT, description: "review failed to run", }); await client.createIssueComment(repo.owner, repo.name, job.pr_number, `${SUMMARY_MARKER}\n代码审查执行失败:\n\n\`\`\`\n${err.message}\n\`\`\`\n\njob #${job.id}`); } } 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"); }