小橘 57c740cdde Revert "chore(workflow): auto-generated commit"
This reverts commit 75f2768a8c7713879bb2ab564f42f24bc609338e.
2026-04-28 15:49:22 +00:00

260 lines
7.8 KiB
TypeScript

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<string, unknown>;
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<string, unknown>).name === "string") {
return String((label as Record<string, unknown>).name);
}
return "";
})
.filter((label) => label.length > 0);
const comments = (Array.isArray(obj.comments) ? obj.comments : []).map((comment) => {
const item = (comment ?? {}) as Record<string, unknown>;
const userObj = (item.user ?? {}) as Record<string, unknown>;
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<IssueData | null> {
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<IssueReaderMeta> {
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<IssueReaderMeta>> => {
const intakeMeta = lastMetaForRole<IntakeMeta>(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),
},
};
};
}