260 lines
7.8 KiB
TypeScript
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),
|
|
},
|
|
};
|
|
};
|
|
}
|