小橘 476ac4d7a5 workflow: pr-code-reviewer
Workflow: pr-code-reviewer
User request (summary): Create a code-reviewer workflow that: 1) Takes a PR URL (supporting Gitea git.shazhou.work, GitHub, and Gitee) as input. 2) A fetcher role detects the platform from the URL, authenticates using ava...
Reviewer (summary): npx tsc --noEmit passed and nerve.yaml contains the workflow entry
Staged paths:
- nerve.yaml
- workflows/pr-code-reviewer/index.ts
- workflows/pr-code-reviewer/package.json
- workflows/pr-code-reviewer/pnpm-lock.yaml
- workflows/pr-code-reviewer/tsconfig.json
2026-04-25 06:46:00 +00:00

1264 lines
39 KiB
TypeScript

/**
* PR Code Reviewer:拉取 PR 元数据与 diff,经 cursor-agent(ask)审查后发帖评论。
* 在 nerve.yaml 注册 workflows.pr-code-reviewer;触发示例:
* nerve workflow trigger pr-code-reviewer --payload '{"prompt":"<PR URL 或 JSON>"}'
*/
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"]>): 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"]>): 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<string | null> {
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<string | null> {
const t = process.env.GITEA_TOKEN ?? (await cfgGet("GITEA_TOKEN"));
return t && t.trim() !== "" ? t.trim() : null;
}
async function resolveGiteeToken(): Promise<string | null> {
const t = process.env.GITEE_TOKEN ?? (await cfgGet("GITEE_TOKEN"));
return t && t.trim() !== "" ? t.trim() : null;
}
async function ghAuthOk(): Promise<boolean> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<WorkflowMeta> = {
name: "pr-code-reviewer",
roles: {
async fetcher(start: StartStep): Promise<RoleResult<WorkflowMeta["fetcher"]>> {
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<string, unknown>;
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<string, unknown>).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<string, string> = {
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<string, unknown>;
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<string, unknown>).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<string, unknown>).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<RoleResult<WorkflowMeta["reviewer"]>> {
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<typeof reviewPayloadSchema>;
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<RoleResult<WorkflowMeta["commenter"]>> {
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<RoleResult<WorkflowMeta["reporter"]>> {
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<WorkflowMeta>) {
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;