/** * 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 = ""; 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; } async maybeAutoMerge({ client, repo, job, prNumber, blocking, toSha, appendLog, reviewIncomplete = false, statusState = "success", }) { if (!repo.auto_merge) return false; if (!prNumber) { appendLog("auto-merge skipped: no pull request for this branch"); return false; } // Only merges into the managed branch are performed; a PR targeting any // other branch is reviewed but never auto-merged. const managed = repo.managed_branch || repo.base_branch; if (job.base_ref && managed && job.base_ref !== managed) { appendLog(`auto-merge skipped: PR targets ${job.base_ref}, managed branch is ${managed}`); return false; } if (reviewIncomplete) { appendLog("auto-merge skipped: review coverage was incomplete"); return false; } if (statusState !== "success") { appendLog(`auto-merge skipped: commit status is ${statusState}`); return false; } if (blocking.length > 0) { appendLog(`auto-merge skipped: ${blocking.length} blocking finding(s)`); return false; } let pr; try { pr = await client.getPullRequest(repo.owner, repo.name, prNumber); } catch (err) { appendLog(`auto-merge skipped: cannot load PR: ${err.message}`); return false; } if (!pr || pr.merged) return false; if (pr.state !== "open") { appendLog("auto-merge skipped: PR is not open"); return false; } if (pr.head?.sha && pr.head.sha !== toSha) { appendLog(`auto-merge skipped: PR head moved to ${String(pr.head.sha).slice(0, 10)}`); return false; } if (pr.mergeable === false) { appendLog("auto-merge skipped: PR is not mergeable"); return false; } if (repo.auto_merge_mode === "immediate" && pr.mergeable === undefined) { appendLog("auto-merge skipped: mergeability unknown"); return false; } try { await client.mergePullRequest(repo.owner, repo.name, prNumber, { style: repo.merge_method || "squash", title: pr.title, deleteBranch: Boolean(repo.delete_branch), headCommitId: toSha, mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed", }); appendLog("auto-merge requested"); return true; } catch (err) { appendLog(`auto-merge failed: ${err.message}`); return false; } } async failJob(job, err) { const message = err instanceof SkipJob ? `skipped: ${err.reason}` : `${err.name || "Error"}: ${err.message}`; this.log(`job #${job.id} ${message}`); updateJob(this.db, job.id, { status: err instanceof SkipJob ? "skipped" : "failed", phase: err instanceof SkipJob ? "skipped" : "failed", error: message, }); if (!(err instanceof SkipJob) && job.pr_number) { try { const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id); if (repo) { const client = this.clientFor(repo); await client.createCommitStatus(repo.owner, repo.name, job.to_sha, { state: "error", context: STATUS_CONTEXT, description: "review failed to run", }); await client.createIssueComment(repo.owner, repo.name, job.pr_number, `${SUMMARY_MARKER}\n代码审查执行失败:\n\n\`\`\`\n${err.message}\n\`\`\`\n\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"); }