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
1264 lines
39 KiB
TypeScript
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;
|