/** * PR Code Reviewer:拉取 PR 元数据与 diff,经 cursor-agent(ask)审查后发帖评论。 * 在 nerve.yaml 注册 workflows.pr-code-reviewer;触发示例: * nerve workflow trigger pr-code-reviewer --payload '{"prompt":""}' */ import type { ModeratorContext, RoleResult, StartStep, WorkflowDefinition, WorkflowMessage, } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core"; import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; import { unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { z } from "zod"; const HOME = process.env.HOME ?? "/home/azureuser"; const NERVE_ROOT = join(HOME, ".uncaged-nerve"); /** unified diff 写入 meta 前的最大字符数 */ const DIFF_TEXT_MAX_CHARS = 1_500_000; /** 送给 cursor-agent 的 diff 最大字符数(与 pr-summarizer DIFF_LLM_MAX_CHARS 对齐) */ const DIFF_FOR_AGENT_MAX_CHARS = 100_000; /** 原始 agent 输出写入 meta 的上限 */ const RAW_AGENT_OUTPUT_MAX_CHARS = 80_000; /** cursor-agent 超时(毫秒) */ const CURSOR_AGENT_TIMEOUT_MS = 600_000; type WorkflowMeta = { fetcher: { platform: "gitea" | "github" | "gitee" | null; prUrl: string | null; owner: string | null; repo: string | null; number: number | null; baseUrl: string | null; title: string | null; description: string | null; changedFiles: string[]; diffText: string | null; diffTruncated: boolean; ok: boolean; errorCode: string | null; errorMessage: string | null; }; reviewer: { reviewMarkdown: string | null; verdict: "approve" | "request-changes" | "comment" | null; critical: string[]; warnings: string[]; suggestions: string[]; cursorAgentExitOk: boolean; parseOk: boolean; fatal: boolean; skipComment: boolean; rawAgentOutput: string | null; }; commenter: { commentUrl: string | null; httpStatus: number | null; ok: boolean; errorCode: string | null; errorMessage: string | null; }; reporter: { outcome: "success" | "partial" | "failed"; headline: string | null; fetcherOk: boolean | null; reviewerOk: boolean | null; commenterOk: boolean | null; commentUrl: string | null; verdict: "approve" | "request-changes" | "comment" | null; detail: string | null; }; }; const startContentJsonSchema = z.object({ prUrl: z.string().default(""), url: z.string().default(""), skipComment: z.boolean().default(false), dryRun: z.boolean().default(false), owner: z.string().nullable().default(null), repo: z.string().nullable().default(null), number: z.number().int().positive().nullable().default(null), index: z.number().int().positive().nullable().default(null), baseUrl: z.string().nullable().default(null), }); const reviewPayloadSchema = z.object({ verdict: z.enum(["approve", "request-changes", "comment"]).nullable().default(null), critical: z.array(z.string()).default([]), warnings: z.array(z.string()).default([]), suggestions: z.array(z.string()).default([]), reviewMarkdown: z.string().nullable().default(null), }); type StartFlags = { skipComment: boolean; /** JSON 里的 dryRun 标志(与 start.meta.dryRun 并存,用于说明) */ jsonDryRun: boolean; }; type ParsedPr = { platform: "gitea" | "github" | "gitee"; owner: string; repo: string; number: number; prUrl: string; /** API 根:Gitea `…/api/v1`,Gitee `https://gitee.com/api/v5`,GitHub 为 null */ apiBaseUrl: string | null; }; function emptyFetcherMeta(partial?: Partial): WorkflowMeta["fetcher"] { return { platform: null, prUrl: null, owner: null, repo: null, number: null, baseUrl: null, title: null, description: null, changedFiles: [], diffText: null, diffTruncated: false, ok: false, errorCode: null, errorMessage: null, ...partial, }; } function emptyReviewerMeta(partial?: Partial): WorkflowMeta["reviewer"] { return { reviewMarkdown: null, verdict: null, critical: [], warnings: [], suggestions: [], cursorAgentExitOk: false, parseOk: false, fatal: true, skipComment: false, rawAgentOutput: null, ...partial, }; } async function cfgGet(key: string): Promise { const result = await spawnSafe("cfg", ["get", key], { cwd: NERVE_ROOT, env: null, timeoutMs: 10_000, }); if (!result.ok) { return null; } return result.value.stdout.trim() || null; } function formatSpawnFailure(error: SpawnError): string { if (error.kind === "spawn_failed") { return error.message; } if (error.kind === "timeout") { return `timeout (stdout=${error.stdout.slice(0, 200)})`; } return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`; } function normalizeHost(host: string): string { return host.toLowerCase().replace(/^www\./, ""); } function detectPlatform(host: string): "github" | "gitee" | "gitea" { const h = normalizeHost(host); if (h === "github.com") { return "github"; } if (h === "gitee.com") { return "gitee"; } return "gitea"; } function parsePathForPlatform( pathname: string, platform: "github" | "gitea" | "gitee", ): { owner: string; repo: string; number: number } | null { const path = pathname.replace(/\/+$/, "") || "/"; if (platform === "github") { const m = path.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/|$)/); if (!m?.[1] || !m?.[2] || !m?.[3]) { return null; } return { owner: m[1], repo: m[2], number: Number.parseInt(m[3], 10) }; } const m = path.match(/^\/([^/]+)\/([^/]+)\/pulls\/(\d+)(?:\/|$)/); if (!m?.[1] || !m?.[2] || !m?.[3]) { return null; } return { owner: m[1], repo: m[2], number: Number.parseInt(m[3], 10) }; } function toParsedPr(u: URL): ParsedPr | null { if (u.protocol !== "http:" && u.protocol !== "https:") { return null; } const platform = detectPlatform(u.hostname); const parsed = parsePathForPlatform(u.pathname, platform); if (!parsed) { return null; } const origin = `${u.protocol}//${u.host}`; const prUrl = platform === "github" ? `${origin}/${parsed.owner}/${parsed.repo}/pull/${parsed.number}` : `${origin}/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}`; let apiBaseUrl: string | null; if (platform === "github") { apiBaseUrl = null; } else if (platform === "gitee") { apiBaseUrl = "https://gitee.com/api/v5"; } else { apiBaseUrl = `${origin.replace(/\/+$/, "")}/api/v1`; } return { platform, owner: parsed.owner, repo: parsed.repo, number: parsed.number, prUrl, apiBaseUrl, }; } function parsePrUrlString(raw: string): ParsedPr | null { const trimmed = raw.trim(); if (!trimmed) { return null; } try { const u = new URL(trimmed); return toParsedPr(u); } catch { return null; } } function buildGiteaStylePrUrl(baseWeb: string, owner: string, repo: string, index: number): string { const base = baseWeb.replace(/\/+$/, ""); return `${base}/${owner}/${repo}/pulls/${index}`; } function parseStartContent(content: string): { flags: StartFlags; pr: ParsedPr | null; error: { code: string; message: string } | null; } { const defaultFlags: StartFlags = { skipComment: false, jsonDryRun: false }; const trimmed = content.trim(); if (!trimmed) { return { flags: defaultFlags, pr: null, error: { code: "bad_url", message: "Empty prompt" }, }; } if (trimmed.startsWith("{")) { let jsonUnknown: unknown; try { jsonUnknown = JSON.parse(trimmed) as unknown; } catch { return { flags: defaultFlags, pr: null, error: { code: "parse", message: "Invalid JSON in start.content" }, }; } const row = startContentJsonSchema.safeParse(jsonUnknown); if (!row.success) { return { flags: defaultFlags, pr: null, error: { code: "parse", message: `JSON validation failed: ${row.error.message}` }, }; } const j = row.data; const flags: StartFlags = { skipComment: j.skipComment, jsonDryRun: j.dryRun, }; const urlCandidate = (j.prUrl || j.url || "").trim(); if (urlCandidate) { const pr = parsePrUrlString(urlCandidate); if (!pr) { return { flags, pr: null, error: { code: "bad_url", message: "Could not parse PR URL from JSON" } }; } return { flags, pr, error: null }; } const owner = j.owner ?? null; const repo = j.repo ?? null; const num = j.number ?? j.index ?? null; const baseWeb = (j.baseUrl ?? "").trim().replace(/\/+$/, ""); if (owner && repo && num !== null && baseWeb) { const built = buildGiteaStylePrUrl(baseWeb, owner, repo, num); const pr = parsePrUrlString(built); if (!pr) { return { flags, pr: null, error: { code: "bad_url", message: "Could not resolve PR from owner/repo/baseUrl" } }; } return { flags, pr, error: null }; } return { flags, pr: null, error: { code: "bad_url", message: "JSON must include prUrl/url or owner, repo, number (or index), and baseUrl", }, }; } const pr = parsePrUrlString(trimmed); if (!pr) { return { flags: defaultFlags, pr: null, error: { code: "bad_url", message: "Not a valid PR URL (GitHub: …/owner/repo/pull/N;Gitea/Gitee: …/owner/repo/pulls/N)", }, }; } return { flags: defaultFlags, pr, error: null }; } /** 不发帖、且不调用 cursor-agent(与 pr-summarizer dryRun 语义对齐) */ function skipAgentAndComment(start: StartStep, flags: StartFlags): boolean { return isDryRun(start) || flags.jsonDryRun; } async function resolveGiteaToken(): Promise { const t = process.env.GITEA_TOKEN ?? (await cfgGet("GITEA_TOKEN")); return t && t.trim() !== "" ? t.trim() : null; } async function resolveGiteeToken(): Promise { const t = process.env.GITEE_TOKEN ?? (await cfgGet("GITEE_TOKEN")); return t && t.trim() !== "" ? t.trim() : null; } async function ghAuthOk(): Promise { const r = await spawnSafe("gh", ["auth", "status"], { cwd: NERVE_ROOT, env: null, timeoutMs: 15_000, }); return r.ok; } async function ghApiJson(path: string): Promise<{ ok: true; data: unknown } | { ok: false; err: string }> { const r = await spawnSafe("gh", ["api", path], { cwd: NERVE_ROOT, env: null, timeoutMs: 120_000, }); if (!r.ok) { return { ok: false, err: formatSpawnFailure(r.error) }; } try { return { ok: true, data: JSON.parse(r.value.stdout) as unknown }; } catch { return { ok: false, err: "Failed to parse gh api JSON output" }; } } async function ghApiDiff(path: string): Promise<{ ok: true; text: string } | { ok: false; err: string }> { const r = await spawnSafe( "gh", ["api", "-H", "Accept: application/vnd.github.diff", path], { cwd: NERVE_ROOT, env: null, timeoutMs: 300_000, }, ); if (!r.ok) { return { ok: false, err: formatSpawnFailure(r.error) }; } return { ok: true, text: r.value.stdout }; } function truncateDiff(raw: string): { text: string | null; truncated: boolean } { if (raw.length <= DIFF_TEXT_MAX_CHARS) { return { text: raw, truncated: false }; } return { text: raw.slice(0, DIFF_TEXT_MAX_CHARS), truncated: true }; } function asString(v: unknown): string | null { return typeof v === "string" ? v : null; } function extractJsonObjectFromAgentOutput(text: string): string | null { const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidate = fence?.[1]?.trim() ?? text.trim(); const start = candidate.indexOf("{"); if (start < 0) { return null; } let depth = 0; for (let i = start; i < candidate.length; i++) { const c = candidate[i]; if (c === "{") { depth++; } else if (c === "}") { depth--; if (depth === 0) { return candidate.slice(start, i + 1); } } } return null; } function lastMetaForRole( messages: WorkflowMessage[], role: "fetcher" | "reviewer" | "commenter", ): unknown | undefined { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === role) { return messages[i].meta; } } return undefined; } function isFetcherMeta(m: unknown): m is WorkflowMeta["fetcher"] { return ( typeof m === "object" && m !== null && "ok" in m && typeof (m as WorkflowMeta["fetcher"]).ok === "boolean" ); } function isReviewerMeta(m: unknown): m is WorkflowMeta["reviewer"] { return typeof m === "object" && m !== null && "parseOk" in m; } function buildCommentBody(reviewMarkdown: string | null, verdict: WorkflowMeta["reviewer"]["verdict"]): string { const md = (reviewMarkdown ?? "").trim() || "_(无 reviewMarkdown)_"; const v = verdict ?? "comment"; return `${md}\n\n---\n**Verdict:** ${v}\n`; } function mapHttpErrorToCommenterCode(status: number): string { if (status === 401) { return "auth"; } if (status === 403) { return "forbidden"; } if (status === 422) { return "validation"; } return "http"; } async function postGiteaComment( apiRoot: string, token: string, owner: string, repo: string, index: number, body: string, ): Promise<{ ok: true; url: string; status: number } | { ok: false; code: string; message: string; status: number | null }> { const url = `${apiRoot.replace(/\/+$/, "")}/repos/${owner}/${repo}/issues/${index}/comments`; try { const res = await fetch(url, { method: "POST", headers: { Authorization: `token ${token}`, Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ body }), }); const status = res.status; const txt = await res.text(); if (!res.ok) { return { ok: false, code: mapHttpErrorToCommenterCode(status), message: `HTTP ${status} ${txt.slice(0, 400)}`, status, }; } let commentUrl: string | null = null; try { const j = JSON.parse(txt) as Record; commentUrl = asString(j.html_url) ?? asString(j.url); } catch { /* ignore */ } return { ok: true, url: commentUrl ?? url, status }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { ok: false, code: "network", message: msg, status: null }; } } async function postGiteeComment( token: string, owner: string, repo: string, number: number, body: string, ): Promise<{ ok: true; url: string; status: number } | { ok: false; code: string; message: string; status: number | null }> { const base = "https://gitee.com/api/v5"; const q = new URLSearchParams({ access_token: token }); const url = `${base}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments?${q.toString()}`; try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ body }), }); const status = res.status; const txt = await res.text(); if (!res.ok) { return { ok: false, code: mapHttpErrorToCommenterCode(status), message: `HTTP ${status} ${txt.slice(0, 400)}`, status, }; } let commentUrl: string | null = null; try { const j = JSON.parse(txt) as Record; commentUrl = asString(j.html_url) ?? asString(j.url); } catch { /* ignore */ } return { ok: true, url: commentUrl ?? `https://gitee.com/${owner}/${repo}/pulls/${number}`, status }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { ok: false, code: "network", message: msg, status: null }; } } async function postGitHubComment(owner: string, repo: string, number: number, body: string): Promise< | { ok: true; url: string; status: number } | { ok: false; code: string; message: string; status: number | null } > { const tmp = join(tmpdir(), `nerve-pr-comment-${process.pid}-${Date.now()}.json`); try { writeFileSync(tmp, JSON.stringify({ body }), "utf-8"); const path = `repos/${owner}/${repo}/issues/${number}/comments`; const r = await spawnSafe( "gh", ["api", "--method", "POST", path, "--input", tmp], { cwd: NERVE_ROOT, env: null, timeoutMs: 120_000, }, ); if (!r.ok) { return { ok: false, code: "http", message: formatSpawnFailure(r.error), status: null, }; } try { const j = JSON.parse(r.value.stdout) as Record; const url = asString(j.html_url) ?? asString(j.url) ?? ""; return { ok: true, url: url || `https://github.com/${owner}/${repo}/pull/${number}`, status: 201 }; } catch { return { ok: true, url: `https://github.com/${owner}/${repo}/pull/${number}`, status: 201 }; } } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { ok: false, code: "network", message: msg, status: null }; } finally { try { unlinkSync(tmp); } catch { /* ignore */ } } } const workflow: WorkflowDefinition = { name: "pr-code-reviewer", roles: { async fetcher(start: StartStep): Promise> { const { pr, error } = parseStartContent(start.content); if (error !== null || pr === null) { const meta = emptyFetcherMeta({ ok: false, errorCode: error?.code ?? "bad_url", errorMessage: error?.message ?? "Unknown parse error", diffText: null, }); return { content: `Fetcher: ${meta.errorMessage}`, meta, }; } const baseUrl = pr.apiBaseUrl; const metaBase: WorkflowMeta["fetcher"] = emptyFetcherMeta({ platform: pr.platform, prUrl: pr.prUrl, owner: pr.owner, repo: pr.repo, number: pr.number, baseUrl, ok: false, errorCode: null, errorMessage: null, }); if (pr.platform === "github") { const auth = await ghAuthOk(); if (!auth) { const meta = emptyFetcherMeta({ ...metaBase, ok: false, errorCode: "auth", errorMessage: "GitHub: `gh auth status` failed; run `gh auth login` on the host.", diffText: null, }); return { content: `Fetcher: ${meta.errorMessage}`, meta }; } const repoPath = `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`; const titleRes = await ghApiJson(repoPath); if (!titleRes.ok) { const meta = emptyFetcherMeta({ ...metaBase, ok: false, errorCode: "http", errorMessage: titleRes.err, diffText: null, }); return { content: `Fetcher: ${titleRes.err}`, meta }; } const prJson = titleRes.data as Record; const title = asString(prJson.title); const description = asString(prJson.body); const filesRes = await ghApiJson(`${repoPath}/files`); let changedFiles: string[] = []; if (filesRes.ok && Array.isArray(filesRes.data)) { changedFiles = filesRes.data .map((row) => (typeof row === "object" && row !== null ? asString((row as Record).filename) : null)) .filter((x): x is string => typeof x === "string" && x.length > 0); } const diffRes = await ghApiDiff(repoPath); if (!diffRes.ok) { const meta = emptyFetcherMeta({ ...metaBase, title, description, changedFiles, ok: false, errorCode: "http", errorMessage: diffRes.err, diffText: null, }); return { content: `Fetcher: ${diffRes.err}`, meta }; } const { text: diffFull, truncated } = truncateDiff(diffRes.text); const meta: WorkflowMeta["fetcher"] = { ...metaBase, title, description, changedFiles, diffText: diffFull, diffTruncated: truncated, ok: true, errorCode: null, errorMessage: null, }; const lines = (diffFull ?? "").split("\n").length; const content = `Fetcher: GitHub ${pr.owner}/${pr.repo}#${pr.number} — ${title ?? "(no title)"}\n` + `Changed files: ${changedFiles.length}; diff lines ~${lines}${truncated ? " (meta truncated)" : ""}`; return { content, meta }; } const token = pr.platform === "gitea" ? await resolveGiteaToken() : await resolveGiteeToken(); if (!token) { const key = pr.platform === "gitea" ? "GITEA_TOKEN" : "GITEE_TOKEN"; const meta = emptyFetcherMeta({ ...metaBase, ok: false, errorCode: "auth", errorMessage: `${key} is not set (env or \`cfg get ${key}\`).`, diffText: null, }); return { content: `Fetcher: ${meta.errorMessage}`, meta }; } const apiRoot = baseUrl ?? ""; const pullJsonUrl = `${apiRoot}/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`; const pullDiffUrl = `${pullJsonUrl}.diff`; const filesUrl = `${apiRoot}/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/files`; const headersJson: Record = { Authorization: `token ${token}`, Accept: "application/json", }; let title: string | null = null; let description: string | null = null; let changedFiles: string[] = []; let httpStatus: number | null = null; try { const prRes = await fetch(pullJsonUrl, { headers: headersJson }); httpStatus = prRes.status; const bodyText = await prRes.text(); if (!prRes.ok) { const code = prRes.status === 401 || prRes.status === 403 ? "auth" : "http"; const meta = emptyFetcherMeta({ ...metaBase, ok: false, errorCode: code, errorMessage: `GET PR JSON failed: HTTP ${prRes.status} ${bodyText.slice(0, 500)}`, diffText: null, }); return { content: `Fetcher: ${meta.errorMessage}`, meta }; } const data = JSON.parse(bodyText) as Record; title = asString(data.title); description = asString(data.body); } catch (e) { const msg = e instanceof Error ? e.message : String(e); const meta = emptyFetcherMeta({ ...metaBase, ok: false, errorCode: "network", errorMessage: msg, diffText: null, }); return { content: `Fetcher: ${msg}`, meta }; } try { const filesRes = await fetch(filesUrl, { headers: headersJson }); httpStatus = filesRes.status; const filesText = await filesRes.text(); if (filesRes.ok) { const arr = JSON.parse(filesText) as unknown; if (Array.isArray(arr)) { changedFiles = arr .map((row) => typeof row === "object" && row !== null ? asString((row as Record).filename) : null, ) .filter((x): x is string => typeof x === "string" && x.length > 0); } } } catch { changedFiles = []; } let diffText: string | null = null; let diffTruncated = false; try { if (pr.platform === "gitea") { const diffRes = await fetch(pullDiffUrl, { headers: { Authorization: `token ${token}`, Accept: "text/plain", }, }); httpStatus = diffRes.status; const rawDiff = await diffRes.text(); if (!diffRes.ok) { const meta = emptyFetcherMeta({ ...metaBase, title, description, changedFiles, ok: false, errorCode: "http", errorMessage: `GET PR diff failed: HTTP ${diffRes.status} ${rawDiff.slice(0, 500)}`, diffText: null, }); return { content: `Fetcher: ${meta.errorMessage}`, meta }; } const t = truncateDiff(rawDiff); diffText = t.text; diffTruncated = t.truncated; } else { const filesRes = await fetch(filesUrl, { headers: headersJson }); httpStatus = filesRes.status; const filesText = await filesRes.text(); if (!filesRes.ok) { const meta = emptyFetcherMeta({ ...metaBase, title, description, changedFiles, ok: false, errorCode: "http", errorMessage: `GET PR files failed: HTTP ${filesRes.status} ${filesText.slice(0, 500)}`, diffText: null, }); return { content: `Fetcher: ${meta.errorMessage}`, meta }; } const arr = JSON.parse(filesText) as unknown; const patches: string[] = []; if (Array.isArray(arr)) { for (const row of arr) { if (typeof row !== "object" || row === null) { continue; } const patch = asString((row as Record).patch); if (patch) { patches.push(patch); } } } const joined = patches.join("\n"); const t = truncateDiff(joined.length > 0 ? joined : "(no patch hunks in files response)"); diffText = t.text; diffTruncated = t.truncated; } } catch (e) { const msg = e instanceof Error ? e.message : String(e); const meta = emptyFetcherMeta({ ...metaBase, title, description, changedFiles, ok: false, errorCode: "network", errorMessage: msg, diffText: null, }); return { content: `Fetcher: ${msg}`, meta }; } const meta: WorkflowMeta["fetcher"] = { ...metaBase, title, description, changedFiles, diffText, diffTruncated, ok: true, errorCode: null, errorMessage: null, }; const lines = (diffText ?? "").split("\n").length; const platLabel = pr.platform === "gitea" ? "Gitea" : "Gitee"; const content = `Fetcher: ${platLabel} ${pr.owner}/${pr.repo}#${pr.number} — ${title ?? "(no title)"}\n` + `Changed files: ${changedFiles.length}; diff lines ~${lines}; HTTP=${httpStatus ?? "?"}${diffTruncated ? " (meta truncated)" : ""}`; return { content, meta }; }, async reviewer( start: StartStep, messages: WorkflowMessage[], ): Promise> { const lastFetcher = lastMetaForRole(messages, "fetcher"); const { flags } = parseStartContent(start.content); const noAgent = skipAgentAndComment(start, flags); const metaSkipComment = noAgent || flags.skipComment; if (!isFetcherMeta(lastFetcher) || lastFetcher.ok !== true) { return { content: "Reviewer: fetcher did not succeed; skipping review.", meta: emptyReviewerMeta({ fatal: true, skipComment: true, rawAgentOutput: null, reviewMarkdown: null, }), }; } const fm = lastFetcher; const diffFull = fm.diffText ?? ""; const diffForAgent = diffFull.length > DIFF_FOR_AGENT_MAX_CHARS ? diffFull.slice(0, DIFF_FOR_AGENT_MAX_CHARS) : diffFull; const diffTruncatedNote = diffFull.length > DIFF_FOR_AGENT_MAX_CHARS ? `\n(diff truncated for agent to ${DIFF_FOR_AGENT_MAX_CHARS} chars)\n` : ""; if (noAgent) { const stubMd = "## dryRun\n\n未调用 cursor-agent;未发帖。" + (isDryRun(start) ? "(start.meta.dryRun)" : "") + (flags.jsonDryRun ? "(JSON dryRun)" : ""); return { content: `Reviewer: ${stubMd}`, meta: emptyReviewerMeta({ reviewMarkdown: stubMd, verdict: "comment", critical: [], warnings: [], suggestions: [], cursorAgentExitOk: true, parseOk: true, fatal: false, skipComment: true, rawAgentOutput: null, }), }; } const verdictSchemaHint = '`verdict` must be one of: "approve" | "request-changes" | "comment".'; const prompt = `You are an expert code reviewer. Review this pull request.\n\n` + `Platform: ${fm.platform}\n` + `Repository: ${fm.owner}/${fm.repo} PR #${fm.number}\n` + `Title: ${fm.title ?? ""}\n` + `Description:\n${fm.description ?? ""}\n\n` + `Changed files (${fm.changedFiles.length}):\n${fm.changedFiles.map((f) => `- ${f}`).join("\n")}\n` + diffTruncatedNote + `\n--- unified diff (may be truncated) ---\n${diffForAgent}\n\n` + `Respond with a single JSON object ONLY (no prose outside JSON), optionally inside a markdown code fence, using this shape:\n` + `{\n` + ` "verdict": "approve" | "request-changes" | "comment",\n` + ` "critical": string[],\n` + ` "warnings": string[],\n` + ` "suggestions": string[],\n` + ` "reviewMarkdown": string\n` + `}\n` + `${verdictSchemaHint}\n` + `reviewMarkdown should be the main review in Markdown (no secrets/tokens).`; const agentResult = await cursorAgent({ prompt, mode: "ask", cwd: NERVE_ROOT, env: null, timeoutMs: CURSOR_AGENT_TIMEOUT_MS, dryRun: false, }); if (!agentResult.ok) { const err = agentResult.error; const raw = err.kind === "spawn_failed" ? "" : `${err.stdout}\n${err.stderr}`.slice(0, RAW_AGENT_OUTPUT_MAX_CHARS); return { content: `Reviewer: cursor-agent failed — ${formatSpawnFailure(agentResult.error)}`, meta: emptyReviewerMeta({ cursorAgentExitOk: false, parseOk: false, fatal: true, skipComment: metaSkipComment, rawAgentOutput: raw || null, }), }; } const rawOut = agentResult.value.slice(0, RAW_AGENT_OUTPUT_MAX_CHARS); const jsonStr = extractJsonObjectFromAgentOutput(agentResult.value); if (!jsonStr) { return { content: "Reviewer: could not find JSON object in cursor-agent output.", meta: emptyReviewerMeta({ cursorAgentExitOk: true, parseOk: false, fatal: true, skipComment: metaSkipComment, rawAgentOutput: rawOut, }), }; } let parsed: z.infer; try { parsed = reviewPayloadSchema.parse(JSON.parse(jsonStr) as unknown); } catch { return { content: "Reviewer: JSON from cursor-agent failed schema validation.", meta: emptyReviewerMeta({ cursorAgentExitOk: true, parseOk: false, fatal: true, skipComment: metaSkipComment, rawAgentOutput: rawOut, }), }; } const skipComment = metaSkipComment; const reviewPreview = `Reviewer: verdict=${parsed.verdict ?? "null"}; critical=${parsed.critical.length}; ` + `warnings=${parsed.warnings.length}; suggestions=${parsed.suggestions.length}\n` + `${(parsed.reviewMarkdown ?? "").slice(0, 1200)}${(parsed.reviewMarkdown ?? "").length > 1200 ? "…" : ""}`; return { content: reviewPreview, meta: { reviewMarkdown: parsed.reviewMarkdown, verdict: parsed.verdict, critical: parsed.critical, warnings: parsed.warnings, suggestions: parsed.suggestions, cursorAgentExitOk: true, parseOk: true, fatal: false, skipComment, rawAgentOutput: rawOut, }, }; }, async commenter( _start: StartStep, messages: WorkflowMessage[], ): Promise> { const fmRaw = lastMetaForRole(messages, "fetcher"); const rmRaw = lastMetaForRole(messages, "reviewer"); if (!isFetcherMeta(fmRaw) || !isReviewerMeta(rmRaw)) { const meta: WorkflowMeta["commenter"] = { commentUrl: null, httpStatus: null, ok: false, errorCode: "skipped", errorMessage: "Missing fetcher or reviewer meta", }; return { content: "Commenter: skipped — missing prior step meta.", meta }; } const fm = fmRaw; const rm = rmRaw; const body = buildCommentBody(rm.reviewMarkdown, rm.verdict); if (fm.platform === "github" && fm.owner && fm.repo && fm.number !== null) { const res = await postGitHubComment(fm.owner, fm.repo, fm.number, body); if (!res.ok) { const meta: WorkflowMeta["commenter"] = { commentUrl: null, httpStatus: res.status, ok: false, errorCode: res.code, errorMessage: res.message, }; return { content: `Commenter: failed to post — ${res.message}`, meta, }; } const meta: WorkflowMeta["commenter"] = { commentUrl: res.url, httpStatus: res.status, ok: true, errorCode: null, errorMessage: null, }; return { content: `Commenter: posted — ${res.url}`, meta }; } if (fm.platform === "gitea" && fm.baseUrl && fm.owner && fm.repo && fm.number !== null) { const token = await resolveGiteaToken(); if (!token) { const meta: WorkflowMeta["commenter"] = { commentUrl: null, httpStatus: null, ok: false, errorCode: "auth", errorMessage: "GITEA_TOKEN missing for comment", }; return { content: "Commenter: GITEA_TOKEN missing.", meta }; } const res = await postGiteaComment(fm.baseUrl, token, fm.owner, fm.repo, fm.number, body); if (!res.ok) { const meta: WorkflowMeta["commenter"] = { commentUrl: null, httpStatus: res.status, ok: false, errorCode: res.code, errorMessage: res.message, }; return { content: `Commenter: failed — ${res.message}`, meta }; } const meta: WorkflowMeta["commenter"] = { commentUrl: res.url, httpStatus: res.status, ok: true, errorCode: null, errorMessage: null, }; return { content: `Commenter: posted — ${res.url}`, meta }; } if (fm.platform === "gitee" && fm.owner && fm.repo && fm.number !== null) { const token = await resolveGiteeToken(); if (!token) { const meta: WorkflowMeta["commenter"] = { commentUrl: null, httpStatus: null, ok: false, errorCode: "auth", errorMessage: "GITEE_TOKEN missing for comment", }; return { content: "Commenter: GITEE_TOKEN missing.", meta }; } const res = await postGiteeComment(token, fm.owner, fm.repo, fm.number, body); if (!res.ok) { const meta: WorkflowMeta["commenter"] = { commentUrl: null, httpStatus: res.status, ok: false, errorCode: res.code, errorMessage: res.message, }; return { content: `Commenter: failed — ${res.message}`, meta }; } const meta: WorkflowMeta["commenter"] = { commentUrl: res.url, httpStatus: res.status, ok: true, errorCode: null, errorMessage: null, }; return { content: `Commenter: posted — ${res.url}`, meta }; } const meta: WorkflowMeta["commenter"] = { commentUrl: null, httpStatus: null, ok: false, errorCode: "unsupported_platform", errorMessage: "Cannot post comment for this platform/state", }; return { content: "Commenter: unsupported platform or incomplete PR coordinates.", meta }; }, async reporter( start: StartStep, messages: WorkflowMessage[], ): Promise> { const fmRaw = lastMetaForRole(messages, "fetcher"); const rmRaw = lastMetaForRole(messages, "reviewer"); const cmRaw = lastMetaForRole(messages, "commenter"); const fetcherOk = isFetcherMeta(fmRaw) ? fmRaw.ok : null; const reviewerOk = isReviewerMeta(rmRaw) ? rmRaw.cursorAgentExitOk && rmRaw.parseOk && !rmRaw.fatal : null; const commenterOk = typeof cmRaw === "object" && cmRaw !== null && "ok" in cmRaw ? (cmRaw as WorkflowMeta["commenter"]).ok : null; const verdict = isReviewerMeta(rmRaw) ? rmRaw.verdict : null; const commentUrl = typeof cmRaw === "object" && cmRaw !== null && "commentUrl" in cmRaw ? (cmRaw as WorkflowMeta["commenter"]).commentUrl : null; let outcome: WorkflowMeta["reporter"]["outcome"] = "failed"; if (fetcherOk === false) { outcome = "failed"; } else if (reviewerOk === false) { outcome = "failed"; } else if (commenterOk === false) { outcome = "partial"; } else if (fetcherOk === true && reviewerOk === true && commenterOk === true) { outcome = "success"; } else { outcome = "partial"; } const inputEcho = start.content.trim().startsWith("{") ? "(JSON payload)" : start.content.trim().slice(0, 200); const detailLines = [ `Input: ${inputEcho}`, `fetcherOk=${String(fetcherOk)} reviewerOk=${String(reviewerOk)} commenterOk=${String(commenterOk)}`, commentUrl ? `commentUrl=${commentUrl}` : "", verdict ? `verdict=${verdict}` : "", ].filter(Boolean); const detail = detailLines.join("\n").slice(0, 8000); const headline = outcome === "success" ? "PR code review posted successfully" : outcome === "partial" ? "PR review completed with comment-stage issues" : "PR code review workflow failed"; const meta: WorkflowMeta["reporter"] = { outcome, headline, fetcherOk, reviewerOk, commenterOk, commentUrl, verdict, detail, }; const content = `${headline}\n${detail}`; return { content, meta }; }, }, moderator(context: ModeratorContext) { if (context.steps.length === 0) { return "fetcher"; } const last = context.steps[context.steps.length - 1]; if (last.role === "fetcher") { const meta = last.meta; if (!meta.ok) { return END; } return "reviewer"; } if (last.role === "reviewer") { const meta = last.meta; if (meta.fatal === true || meta.skipComment === true) { return END; } return "commenter"; } if (last.role === "commenter") { return "reporter"; } if (last.role === "reporter") { return END; } return END; }, }; export default workflow;