From 34e42c5c3e5d8aa7479a60f184405162b7c2cf7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 04:42:23 +0000 Subject: [PATCH] chore: remove test workflows (hello-world, pr-code-reviewer, pr-summarizer) --- workflows/hello-world/index.ts | 86 -- workflows/hello-world/package.json | 21 - workflows/hello-world/pnpm-lock.yaml | 51 - workflows/hello-world/tsconfig.json | 13 - workflows/pr-code-reviewer/index.ts | 1263 --------------------- workflows/pr-code-reviewer/package.json | 22 - workflows/pr-code-reviewer/pnpm-lock.yaml | 59 - workflows/pr-code-reviewer/tsconfig.json | 13 - workflows/pr-summarizer/index.ts | 575 ---------- workflows/pr-summarizer/package.json | 21 - workflows/pr-summarizer/pnpm-lock.yaml | 49 - workflows/pr-summarizer/tsconfig.json | 13 - 12 files changed, 2186 deletions(-) delete mode 100644 workflows/hello-world/index.ts delete mode 100644 workflows/hello-world/package.json delete mode 100644 workflows/hello-world/pnpm-lock.yaml delete mode 100644 workflows/hello-world/tsconfig.json delete mode 100644 workflows/pr-code-reviewer/index.ts delete mode 100644 workflows/pr-code-reviewer/package.json delete mode 100644 workflows/pr-code-reviewer/pnpm-lock.yaml delete mode 100644 workflows/pr-code-reviewer/tsconfig.json delete mode 100644 workflows/pr-summarizer/index.ts delete mode 100644 workflows/pr-summarizer/package.json delete mode 100644 workflows/pr-summarizer/pnpm-lock.yaml delete mode 100644 workflows/pr-summarizer/tsconfig.json diff --git a/workflows/hello-world/index.ts b/workflows/hello-world/index.ts deleted file mode 100644 index 1247b62..0000000 --- a/workflows/hello-world/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { - ModeratorContext, - RoleResult, - StartStep, - WorkflowDefinition, - WorkflowMessage, -} from "@uncaged/nerve-core"; -import { END } from "@uncaged/nerve-core"; - -type WorkflowMeta = { - greeter: { - name: string; - error: string | null; - }; -}; - -const DEFAULT_NAME = "friend"; - -function resolveNameFromContent(content: string): { name: string; error: string | null } { - const trimmed = content.trim(); - if (trimmed === "") { - return { name: DEFAULT_NAME, error: "empty_input" }; - } - - let jsonParsed: unknown; - let parseOk: boolean; - try { - jsonParsed = JSON.parse(trimmed); - parseOk = true; - } catch { - parseOk = false; - } - - if (parseOk) { - if (jsonParsed !== null && typeof jsonParsed === "object" && !Array.isArray(jsonParsed)) { - const nameField = (jsonParsed as Record).name; - if (typeof nameField === "string") { - const n = nameField.trim(); - if (n !== "") { - return { name: n, error: null }; - } - return { name: DEFAULT_NAME, error: "name_empty" }; - } - return { name: DEFAULT_NAME, error: "missing_name" }; - } - return { name: DEFAULT_NAME, error: "invalid_json_shape" }; - } - - return { name: trimmed, error: null }; -} - -async function greeter( - start: StartStep, - _messages: WorkflowMessage[], -): Promise> { - try { - const { name, error } = resolveNameFromContent(start.content); - return { - content: `Hello, ${name}!`, - meta: { name, error }, - }; - } catch (unhandled) { - const msg = unhandled instanceof Error ? unhandled.message : String(unhandled); - return { - content: `Hello, ${DEFAULT_NAME}!`, - meta: { name: DEFAULT_NAME, error: `internal_error: ${msg}` }, - }; - } -} - -const workflow: WorkflowDefinition = { - name: "hello-world", - roles: { greeter }, - moderator(context: ModeratorContext) { - if (context.steps.length === 0) { - return "greeter"; - } - const last = context.steps[context.steps.length - 1]; - if (last.role === "greeter") { - return END; - } - return END; - }, -}; - -export default workflow; diff --git a/workflows/hello-world/package.json b/workflows/hello-world/package.json deleted file mode 100644 index f229c46..0000000 --- a/workflows/hello-world/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "hello-world-workflow", - "version": "0.0.1", - "private": true, - "type": "module", - "dependencies": { - "@uncaged/nerve-core": "latest", - "@uncaged/nerve-workflow-utils": "latest" - }, - "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/hello-world/pnpm-lock.yaml b/workflows/hello-world/pnpm-lock.yaml deleted file mode 100644 index eb8e81f..0000000 --- a/workflows/hello-world/pnpm-lock.yaml +++ /dev/null @@ -1,51 +0,0 @@ -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 - 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==} - -snapshots: - - '@types/node@22.19.17': - dependencies: - undici-types: 6.21.0 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} diff --git a/workflows/hello-world/tsconfig.json b/workflows/hello-world/tsconfig.json deleted file mode 100644 index fc00159..0000000 --- a/workflows/hello-world/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["./**/*.ts"] -} diff --git a/workflows/pr-code-reviewer/index.ts b/workflows/pr-code-reviewer/index.ts deleted file mode 100644 index 53fcf91..0000000 --- a/workflows/pr-code-reviewer/index.ts +++ /dev/null @@ -1,1263 +0,0 @@ -/** - * 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 deleted file mode 100644 index f963e67..0000000 --- a/workflows/pr-code-reviewer/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 deleted file mode 100644 index 237146e..0000000 --- a/workflows/pr-code-reviewer/pnpm-lock.yaml +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index fc00159..0000000 --- a/workflows/pr-code-reviewer/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["./**/*.ts"] -} diff --git a/workflows/pr-summarizer/index.ts b/workflows/pr-summarizer/index.ts deleted file mode 100644 index 5bc50fd..0000000 --- a/workflows/pr-summarizer/index.ts +++ /dev/null @@ -1,575 +0,0 @@ -/** - * PR 摘要工作流:从 Gitea 拉取 PR 与 diff,可选 LLM 分析后输出中文 Markdown 总结。 - * 宿主需在 nerve.yaml 中注册 workflows.pr-summarizer;触发示例: - * nerve workflow trigger pr-summarizer --payload '{"prompt":""}' - * Sense 可返回 workflow: `pr-summarizer|50|`(见 parseSenseWorkflowDirective)。 - */ -import type { - ModeratorContext, - RoleResult, - StartStep, - WorkflowDefinition, - WorkflowMessage, -} from "@uncaged/nerve-core"; -import { END } from "@uncaged/nerve-core"; -import { - isDryRun, - llmExtract, - nerveAgentContext, - readNerveYaml, - spawnSafe, -} from "@uncaged/nerve-workflow-utils"; -import { join } from "node:path"; -import { z } from "zod"; - -const HOME = process.env.HOME ?? "/home/azureuser"; -const NERVE_ROOT = join(HOME, ".uncaged-nerve"); - -/** unified diff 写入 meta 前的最大字符数(超出则截断并在 content 中说明) */ -const DIFF_TEXT_MAX_CHARS = 1_500_000; -/** 送给分析模型的 diff 前缀长度上限 */ -const DIFF_LLM_MAX_CHARS = 100_000; - -type PrSummarizerMeta = { - fetcher: { - prUrl: string | null; - owner: string | null; - repo: string | null; - prIndex: number | null; - giteaBaseUrl: string | null; - title: string | null; - state: string | null; - diffText: string | null; - diffByteLength: number | null; - httpStatus: number | null; - errorMessage: string | null; - }; - analyzer: { - analysisMarkdown: string | null; - providerModel: string | null; - errorMessage: string | null; - }; - writer: { - summaryZhMarkdown: string | null; - errorMessage: string | null; - }; -}; - -const jsonPromptSchema = z.object({ - prUrl: z.string().nullish(), - owner: z.string().nullish(), - repo: z.string().nullish(), - index: z.number().int().positive().nullish(), - baseUrl: z.string().nullish(), -}); - -const analysisExtractSchema = z - .object({ - analysisMarkdown: z.string().describe("Technical PR analysis in Markdown (can be English)."), - }) - .describe("Structured PR analysis from the diff."); - -const summaryExtractSchema = z - .object({ - summaryZhMarkdown: z - .string() - .describe( - "Final deliverable: Chinese Markdown with title, key changes, risks, and test suggestions.", - ), - }) - .describe("Chinese Markdown PR summary."); - -function getNerveYaml(): string { - const result = readNerveYaml({ nerveRoot: NERVE_ROOT }); - return result.ok ? result.value : "# nerve.yaml unavailable"; -} - -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; -} - -async function resolveDashScopeProvider(): Promise<{ - baseUrl: string; - apiKey: string; - model: string; -} | null> { - const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY")); - const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL")); - const model = - process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus"; - if (!apiKey || !baseUrl) { - return null; - } - return { apiKey, baseUrl, model }; -} - -function parseGiteaPullUrl(raw: string): { - giteaBaseUrl: string; - owner: string; - repo: string; - prIndex: number; - prUrl: string; -} | null { - let u: URL; - try { - u = new URL(raw.trim()); - } catch { - return null; - } - if (u.protocol !== "http:" && u.protocol !== "https:") { - return null; - } - const parts = u.pathname.replace(/\/+$/, "").split("/").filter(Boolean); - const pullsAt = parts.indexOf("pulls"); - if (pullsAt < 2 || pullsAt + 1 >= parts.length) { - return null; - } - const indexStr = parts[pullsAt + 1]; - if (!indexStr || !/^\d+$/.test(indexStr)) { - return null; - } - const owner = parts[pullsAt - 2]; - const repo = parts[pullsAt - 1]; - if (!owner || !repo) { - return null; - } - const prIndex = Number.parseInt(indexStr, 10); - if (!Number.isFinite(prIndex) || prIndex < 1) { - return null; - } - const giteaBaseUrl = `${u.protocol}//${u.host}`; - return { giteaBaseUrl, owner, repo, prIndex, prUrl: raw.trim() }; -} - -type ResolvedPr = { - prUrl: string | null; - owner: string | null; - repo: string | null; - prIndex: number | null; - giteaBaseUrl: string | null; - parseError: string | null; -}; - -function resolvePrFromContent(content: string): ResolvedPr { - const empty: ResolvedPr = { - prUrl: null, - owner: null, - repo: null, - prIndex: null, - giteaBaseUrl: null, - parseError: null, - }; - const trimmed = content.trim(); - if (!trimmed) { - return { ...empty, parseError: "Empty prompt" }; - } - - if (trimmed.startsWith("{")) { - let parsed: unknown; - try { - parsed = JSON.parse(trimmed) as unknown; - } catch { - return { ...empty, parseError: "Invalid JSON in prompt" }; - } - const row = jsonPromptSchema.safeParse(parsed); - if (!row.success) { - return { ...empty, parseError: `JSON validation failed: ${row.error.message}` }; - } - const j = row.data; - let owner: string | null = j.owner ?? null; - let repo: string | null = j.repo ?? null; - let prIndex: number | null = j.index ?? null; - let giteaBaseUrl: string | null = j.baseUrl ?? null; - let prUrl: string | null = j.prUrl ?? null; - - if (j.prUrl) { - const p = parseGiteaPullUrl(j.prUrl); - if (p) { - owner = owner ?? p.owner; - repo = repo ?? p.repo; - prIndex = prIndex ?? p.prIndex; - giteaBaseUrl = giteaBaseUrl ?? p.giteaBaseUrl; - prUrl = prUrl ?? p.prUrl; - } - } - - if (owner && repo && prIndex !== null && giteaBaseUrl) { - const normalizedBase = giteaBaseUrl.replace(/\/+$/, ""); - const builtUrl = `${normalizedBase}/${owner}/${repo}/pulls/${prIndex}`; - return { - prUrl: prUrl ?? builtUrl, - owner, - repo, - prIndex, - giteaBaseUrl: normalizedBase, - parseError: null, - }; - } - return { - ...empty, - parseError: "JSON prompt must include resolvable owner, repo, pr index, and baseUrl (or prUrl)", - }; - } - - const p = parseGiteaPullUrl(trimmed); - if (!p) { - return { - ...empty, - parseError: "Not a valid Gitea PR URL (expected https://host/owner/repo/pulls/NUMBER)", - }; - } - return { - prUrl: p.prUrl, - owner: p.owner, - repo: p.repo, - prIndex: p.prIndex, - giteaBaseUrl: p.giteaBaseUrl.replace(/\/+$/, ""), - parseError: null, - }; -} - -function emptyFetcherMeta(): PrSummarizerMeta["fetcher"] { - return { - prUrl: null, - owner: null, - repo: null, - prIndex: null, - giteaBaseUrl: null, - title: null, - state: null, - diffText: null, - diffByteLength: null, - httpStatus: null, - errorMessage: null, - }; -} - -const workflow: WorkflowDefinition = { - name: "pr-summarizer", - - roles: { - async fetcher(start: StartStep): Promise> { - const resolved = resolvePrFromContent(start.content); - if (resolved.parseError !== null) { - const meta: PrSummarizerMeta["fetcher"] = { - ...emptyFetcherMeta(), - errorMessage: resolved.parseError, - }; - return { content: `Fetcher: parse error — ${resolved.parseError}`, meta }; - } - - const token = process.env.GITEA_TOKEN ?? null; - if (!token || token.trim() === "") { - const meta: PrSummarizerMeta["fetcher"] = { - ...emptyFetcherMeta(), - prUrl: resolved.prUrl, - owner: resolved.owner, - repo: resolved.repo, - prIndex: resolved.prIndex, - giteaBaseUrl: resolved.giteaBaseUrl, - errorMessage: "GITEA_TOKEN is not set", - }; - return { content: "Fetcher: missing GITEA_TOKEN (set env before running).", meta }; - } - - const apiRoot = `${resolved.giteaBaseUrl}/api/v1`; - const pullJsonUrl = `${apiRoot}/repos/${resolved.owner}/${resolved.repo}/pulls/${resolved.prIndex}`; - const pullDiffUrl = `${pullJsonUrl}.diff`; - - const headersJson: Record = { - Authorization: `token ${token}`, - Accept: "application/json", - }; - - let title: string | null = null; - let state: string | null = null; - let httpStatus: number | null = null; - let jsonError: string | null = null; - - try { - const prRes = await fetch(pullJsonUrl, { headers: headersJson }); - httpStatus = prRes.status; - const bodyText = await prRes.text(); - if (!prRes.ok) { - jsonError = `GET PR JSON failed: HTTP ${prRes.status} ${bodyText.slice(0, 500)}`; - } else { - const data = JSON.parse(bodyText) as Record; - const t = data.title; - const s = data.state; - title = typeof t === "string" ? t : null; - state = typeof s === "string" ? s : null; - } - } catch (e) { - jsonError = e instanceof Error ? e.message : String(e); - } - - let diffText: string | null = null; - let diffByteLength: number | null = null; - let diffError: string | null = jsonError; - let diffCharTruncated = false; - - if (jsonError === null) { - try { - const diffRes = await fetch(pullDiffUrl, { - headers: { - Authorization: `token ${token}`, - Accept: "text/plain", - }, - }); - httpStatus = diffRes.status; - const rawDiff = await diffRes.text(); - if (!diffRes.ok) { - diffError = `GET PR diff failed: HTTP ${diffRes.status} ${rawDiff.slice(0, 500)}`; - } else { - diffByteLength = Buffer.byteLength(rawDiff, "utf8"); - if (rawDiff.length > DIFF_TEXT_MAX_CHARS) { - diffText = rawDiff.slice(0, DIFF_TEXT_MAX_CHARS); - diffCharTruncated = true; - diffError = null; - } else { - diffText = rawDiff; - } - } - } catch (e) { - diffError = e instanceof Error ? e.message : String(e); - } - } - - const truncatedNote = - diffCharTruncated && diffByteLength !== null - ? ` (diff truncated in meta to ${DIFF_TEXT_MAX_CHARS} chars; full byte length ${diffByteLength})` - : ""; - - const meta: PrSummarizerMeta["fetcher"] = { - prUrl: resolved.prUrl, - owner: resolved.owner, - repo: resolved.repo, - prIndex: resolved.prIndex, - giteaBaseUrl: resolved.giteaBaseUrl, - title, - state, - diffText, - diffByteLength, - httpStatus, - errorMessage: diffError, - }; - - const content = - diffError !== null - ? `Fetcher: ${resolved.owner}/${resolved.repo}#${resolved.prIndex} — failed. ${diffError}` - : `Fetcher: ${resolved.owner}/${resolved.repo}#${resolved.prIndex} — ${title ?? "(no title)"} [${state ?? "?"}] diff bytes=${diffByteLength ?? 0} HTTP=${httpStatus ?? "?"}${truncatedNote}`; - - return { content, meta }; - }, - - async analyzer( - start: StartStep, - messages: WorkflowMessage[], - ): Promise> { - const last = messages[messages.length - 1]; - const fm = last.meta as PrSummarizerMeta["fetcher"]; - - const skip = (reason: string): RoleResult => ({ - content: `Analyzer skipped: ${reason}\n\n${reason}`, - meta: { - analysisMarkdown: `## 无法分析\n\n${reason}`, - providerModel: null, - errorMessage: reason, - }, - }); - - if (last.role !== "fetcher") { - return skip("上一则消息不是 fetcher 输出"); - } - - if (fm.errorMessage !== null) { - return skip(`拉取阶段失败: ${fm.errorMessage}`); - } - - const diff = fm.diffText; - if (diff === null || diff.length === 0) { - return skip("diff 为空,无法分析"); - } - - if (isDryRun(start)) { - return { - content: "[dryRun] Analyzer skipped real LLM call.", - meta: { - analysisMarkdown: "## dryRun\n\n未调用模型。", - providerModel: null, - errorMessage: null, - }, - }; - } - - const provider = await resolveDashScopeProvider(); - if (provider === null) { - const excerpt = diff.split("\n").slice(0, 80).join("\n"); - const analysisMarkdown = - `## 静态摘要(无 LLM 凭据)\n\n` + - `- 仓库: ${fm.owner}/${fm.repo} PR #${fm.prIndex}\n` + - `- 标题: ${fm.title ?? "(null)"}\n` + - `- diff 行数(近似): ${diff.split("\n").length}\n\n` + - `### Diff 开头\n\n\`\`\`diff\n${excerpt}\n\`\`\`\n`; - return { - content: analysisMarkdown, - meta: { - analysisMarkdown, - providerModel: null, - errorMessage: null, - }, - }; - } - - const diffForModel = diff.length > DIFF_LLM_MAX_CHARS ? diff.slice(0, DIFF_LLM_MAX_CHARS) : diff; - const truncated = diff.length > DIFF_LLM_MAX_CHARS; - - const bundle = - `Repository: ${fm.owner}/${fm.repo} PR index ${fm.prIndex}\n` + - `Title: ${fm.title ?? ""}\n` + - `State: ${fm.state ?? ""}\n` + - (truncated ? `\n(diff truncated for model input to ${DIFF_LLM_MAX_CHARS} chars)\n` : "") + - `\n--- unified diff ---\n${diffForModel}`; - - const extractPrompt = - `${nerveAgentContext}\n\n` + - `You are a senior reviewer. Analyze this Gitea pull request diff.\n` + - `Output structured findings as Markdown: scope, files touched, behavior change, risks, test ideas.\n\n` + - `Optional nerve.yaml context:\n\`\`\`yaml\n${getNerveYaml().slice(0, 4000)}\n\`\`\`\n\n` + - `---\n${bundle}`; - - const extracted = await llmExtract({ - text: extractPrompt, - schema: analysisExtractSchema, - provider, - dryRun: false, - }); - - if (!extracted.ok) { - const errText = JSON.stringify(extracted.error); - return { - content: `Analyzer LLM error: ${errText}`, - meta: { - analysisMarkdown: null, - providerModel: provider.model, - errorMessage: errText, - }, - }; - } - - const analysisMarkdown = extracted.value.analysisMarkdown; - return { - content: analysisMarkdown, - meta: { - analysisMarkdown, - providerModel: provider.model, - errorMessage: null, - }, - }; - }, - - async writer( - start: StartStep, - messages: WorkflowMessage[], - ): Promise> { - const last = messages[messages.length - 1]; - const am = last.meta as PrSummarizerMeta["analyzer"]; - - const errOut = (msg: string): RoleResult => ({ - content: `## 错误\n\n${msg}`, - meta: { - summaryZhMarkdown: `## 错误\n\n${msg}`, - errorMessage: msg, - }, - }); - - if (last.role !== "analyzer") { - return errOut("上一则消息不是 analyzer 输出,无法生成总结。"); - } - - if (am.errorMessage !== null) { - return errOut(`分析阶段失败,未生成臆造总结:${am.errorMessage}`); - } - - const analysis = am.analysisMarkdown; - if (analysis === null || analysis.trim() === "") { - return errOut("分析正文为空,无法生成中文总结。"); - } - - if (isDryRun(start)) { - const stub = "## dryRun\n\n未调用模型生成中文总结。"; - return { - content: stub, - meta: { summaryZhMarkdown: stub, errorMessage: null }, - }; - } - - const provider = await resolveDashScopeProvider(); - if (provider === null) { - const stub = - `## 中文摘要(无 LLM)\n\n` + - `以下为上游分析原文摘录,请配置 DASHSCOPE 相关凭据以生成压缩中文总结。\n\n${analysis.slice(0, 8000)}`; - return { - content: stub, - meta: { summaryZhMarkdown: stub, errorMessage: null }, - }; - } - - const writerPrompt = - `将下列 PR 技术分析改写为**中文 Markdown**交付物,包含:\n` + - `- 标题(含仓库与 PR 编号)\n` + - `- 变更要点(条列)\n` + - `- 风险与注意事项\n` + - `- 测试建议\n\n` + - `---\n${analysis}`; - - const extracted = await llmExtract({ - text: writerPrompt, - schema: summaryExtractSchema, - provider, - dryRun: false, - }); - - if (!extracted.ok) { - const msg = JSON.stringify(extracted.error); - return errOut(`Writer LLM 失败: ${msg}`); - } - - const summaryZhMarkdown = extracted.value.summaryZhMarkdown; - return { - content: summaryZhMarkdown, - meta: { - summaryZhMarkdown, - errorMessage: null, - }, - }; - }, - }, - - moderator(context: ModeratorContext) { - if (context.steps.length === 0) { - return "fetcher"; - } - const signal = context.steps[context.steps.length - 1]; - if (signal.role === "fetcher") { - return "analyzer"; - } - if (signal.role === "analyzer") { - return "writer"; - } - if (signal.role === "writer") { - return END; - } - return END; - }, -}; - -export default workflow; diff --git a/workflows/pr-summarizer/package.json b/workflows/pr-summarizer/package.json deleted file mode 100644 index 10a1d0c..0000000 --- a/workflows/pr-summarizer/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "pr-summarizer-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" - }, - "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-summarizer/pnpm-lock.yaml b/workflows/pr-summarizer/pnpm-lock.yaml deleted file mode 100644 index 15302ba..0000000 --- a/workflows/pr-summarizer/pnpm-lock.yaml +++ /dev/null @@ -1,49 +0,0 @@ -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 - -packages: - - '@types/node@22.19.17': - resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} - - 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 - - undici-types@6.21.0: {} - - zod@4.3.6: {} diff --git a/workflows/pr-summarizer/tsconfig.json b/workflows/pr-summarizer/tsconfig.json deleted file mode 100644 index fc00159..0000000 --- a/workflows/pr-summarizer/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["./**/*.ts"] -}