import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; import { llmExtract, spawnSafe } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; import { ISSUE_READER_MAX_ATTEMPTS } from "../../lib/constants.js"; import { lastMetaForRole } from "../../lib/meta-helpers.js"; import type { Provider } from "../../lib/provider.js"; import { resolveDashScopeProvider as resolveProvider } from "../../lib/provider.js"; import { runWithRetries } from "../../lib/run-with-retries.js"; import { classifyIssueReaderFailure, formatSpawnFailure } from "../../lib/spawn-utils.js"; import type { IntakeMeta } from "../intake/index.js"; export type IssueData = { id: number; number: number; title: string; body: string; state: string; labels: string[]; comments: Array<{ author: string; body: string; createdAt: string; }>; url: string; }; export type IssueReaderMeta = { issue: IssueData | null; fetchOk: boolean; transientError: boolean; attempt: number; failureKind: "none" | "transient" | "permanent"; failureReason: string | null; }; const issueSchema = z.object({ id: z.number().int().default(0), number: z.number().int().default(0), title: z.string().default(""), body: z.string().default(""), state: z.string().default("unknown"), labels: z.array(z.string().default("")).default([]), comments: z .array( z.object({ author: z.string().default("unknown"), body: z.string().default(""), createdAt: z.string().default(""), }), ) .default([]), url: z.string().default(""), }); function toIssueFromJson(raw: unknown): IssueData | null { if (typeof raw !== "object" || raw === null) { return null; } const obj = raw as Record; const labels = (Array.isArray(obj.labels) ? obj.labels : []) .map((label) => { if (typeof label === "string") { return label; } if (typeof label === "object" && label !== null && typeof (label as Record).name === "string") { return String((label as Record).name); } return ""; }) .filter((label) => label.length > 0); const comments = (Array.isArray(obj.comments) ? obj.comments : []).map((comment) => { const item = (comment ?? {}) as Record; const userObj = (item.user ?? {}) as Record; return { author: typeof item.author === "string" ? item.author : typeof userObj.login === "string" ? userObj.login : "unknown", body: typeof item.body === "string" ? item.body : "", createdAt: typeof item.created_at === "string" ? item.created_at : typeof item.createdAt === "string" ? item.createdAt : "", }; }); const parsed = issueSchema.parse({ id: Number(obj.id ?? 0), number: Number(obj.number ?? 0), title: typeof obj.title === "string" ? obj.title : "", body: typeof obj.body === "string" ? obj.body : "", state: typeof obj.state === "string" ? obj.state : "unknown", labels, comments, url: typeof obj.url === "string" ? obj.url : "", }); return parsed.number > 0 ? parsed : null; } async function parseIssueFromText(text: string, provider: Provider | null): Promise { if (provider === null) { return null; } const extracted = await llmExtract({ text, schema: issueSchema, provider }); if (!extracted.ok) { return null; } return extracted.value.number > 0 ? extracted.value : null; } export type BuildIssueReaderDeps = { nerveRoot: string; }; export function buildIssueReaderRole({ nerveRoot }: BuildIssueReaderDeps): Role { return async (_start: StartStep, messages: WorkflowMessage[]): Promise> => { const intakeMeta = lastMetaForRole(messages, "intake"); const attempt = messages.filter((message) => message.role === "issue-reader").length + 1; if ( intakeMeta === null || !intakeMeta.valid || intakeMeta.owner === null || intakeMeta.repo === null || intakeMeta.issueNumber === null ) { return { content: "issue-reader cannot continue: missing valid intake meta", meta: { issue: null, fetchOk: false, transientError: false, attempt, failureKind: "permanent", failureReason: "missing valid intake meta", }, }; } const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`; const jsonRun = await runWithRetries(async () => { const run = await spawnSafe( "tea", [ "issue", "show", String(intakeMeta.issueNumber), "--repo", repoSpec, "--comments", "--json", "id,number,title,body,state,labels,comments,url", ], { cwd: nerveRoot, env: null, timeoutMs: 60_000, }, ); if (run.ok) { return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr }; } return { ok: false, error: run.error, lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout, lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr, }; }, ISSUE_READER_MAX_ATTEMPTS); if (jsonRun.ok) { try { const issue = toIssueFromJson(JSON.parse(jsonRun.stdout) as unknown); if (issue !== null) { return { content: `issue fetched: #${issue.number} ${issue.title}`, meta: { issue, fetchOk: true, transientError: false, attempt, failureKind: "none", failureReason: null, }, }; } } catch { // fallback to text parsing } } const textRun = await runWithRetries(async () => { const run = await spawnSafe( "tea", ["issue", "show", String(intakeMeta.issueNumber), "--repo", repoSpec, "--comments"], { cwd: nerveRoot, env: null, timeoutMs: 60_000, }, ); if (run.ok) { return { ok: true, stdout: run.value.stdout, stderr: run.value.stderr }; } return { ok: false, error: run.error, lastStdout: run.error.kind === "spawn_failed" ? "" : run.error.stdout, lastStderr: run.error.kind === "spawn_failed" ? run.error.message : run.error.stderr, }; }, ISSUE_READER_MAX_ATTEMPTS); if (textRun.ok) { const provider = await resolveProvider(nerveRoot); const issue = await parseIssueFromText(textRun.stdout, provider); if (issue !== null) { return { content: `issue fetched (text+extract): #${issue.number} ${issue.title}`, meta: { issue, fetchOk: true, transientError: false, attempt, failureKind: "none", failureReason: null, }, }; } return { content: "tea issue output received, but could not parse structured fields.", meta: { issue: null, fetchOk: false, transientError: false, attempt, failureKind: "permanent", failureReason: "unable to parse tea issue output", }, }; } const classified = classifyIssueReaderFailure(textRun.error); return { content: `issue read failed after retries: ${formatSpawnFailure(textRun.error)}`, meta: { issue: null, fetchOk: false, transientError: classified.transientError, attempt, failureKind: classified.failureKind, failureReason: formatSpawnFailure(textRun.error), }, }; }; }