diff --git a/nerve.yaml b/nerve.yaml index f7e1416..c9dbd48 100644 --- a/nerve.yaml +++ b/nerve.yaml @@ -26,6 +26,9 @@ workflows: pr-summarizer: concurrency: 1 overflow: drop + pr-code-reviewer: + concurrency: 1 + overflow: drop hello-world: concurrency: 1 overflow: drop diff --git a/workflows/pr-code-reviewer/index.ts b/workflows/pr-code-reviewer/index.ts new file mode 100644 index 0000000..53fcf91 --- /dev/null +++ b/workflows/pr-code-reviewer/index.ts @@ -0,0 +1,1263 @@ +/** + * 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; diff --git a/workflows/pr-code-reviewer/package.json b/workflows/pr-code-reviewer/package.json new file mode 100644 index 0000000..f963e67 --- /dev/null +++ b/workflows/pr-code-reviewer/package.json @@ -0,0 +1,22 @@ +{ + "name": "pr-code-reviewer-workflow", + "version": "0.0.1", + "private": true, + "type": "module", + "dependencies": { + "@uncaged/nerve-core": "latest", + "@uncaged/nerve-workflow-utils": "latest", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "pnpm": { + "overrides": { + "@uncaged/nerve-daemon": "link:../../../repos/nerve/packages/daemon", + "@uncaged/nerve-core": "link:../../../repos/nerve/packages/core", + "@uncaged/nerve-workflow-utils": "link:../../../repos/nerve/packages/workflow-utils" + } + } +} diff --git a/workflows/pr-code-reviewer/pnpm-lock.yaml b/workflows/pr-code-reviewer/pnpm-lock.yaml new file mode 100644 index 0000000..237146e --- /dev/null +++ b/workflows/pr-code-reviewer/pnpm-lock.yaml @@ -0,0 +1,59 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@uncaged/nerve-daemon': link:../../../repos/nerve/packages/daemon + '@uncaged/nerve-core': link:../../../repos/nerve/packages/core + '@uncaged/nerve-workflow-utils': link:../../../repos/nerve/packages/workflow-utils + +importers: + + .: + dependencies: + '@uncaged/nerve-core': + specifier: link:../../../repos/nerve/packages/core + version: link:../../../repos/nerve/packages/core + '@uncaged/nerve-workflow-utils': + specifier: link:../../../repos/nerve/packages/workflow-utils + version: link:../../../repos/nerve/packages/workflow-utils + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + +packages: + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + zod@4.3.6: {} diff --git a/workflows/pr-code-reviewer/tsconfig.json b/workflows/pr-code-reviewer/tsconfig.json new file mode 100644 index 0000000..fc00159 --- /dev/null +++ b/workflows/pr-code-reviewer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +}