From 994de1e7ff9e4a982071b644e20115ffcf5e50a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 13:20:29 +0000 Subject: [PATCH] chore(workflow): auto-generated commit --- pnpm-lock.yaml | 3 + workflows/gitea-issue-solver/.gitignore | 1 + workflows/gitea-issue-solver/build.ts | 28 + workflows/gitea-issue-solver/index.ts | 1070 +---------------- workflows/gitea-issue-solver/lib/constants.ts | 3 + .../gitea-issue-solver/lib/meta-helpers.ts | 10 + .../gitea-issue-solver/lib/nerve-read.ts | 6 + workflows/gitea-issue-solver/lib/provider.ts | 26 + .../lib/run-test-command.ts | 32 + .../lib/run-with-retries.ts | 41 + .../gitea-issue-solver/lib/spawn-utils.ts | 66 + .../gitea-issue-solver/lib/text-utils.ts | 13 + workflows/gitea-issue-solver/moderator.ts | 61 + workflows/gitea-issue-solver/package.json | 4 + .../roles/implementer/index.ts | 162 +++ .../roles/implementer/prompt.ts | 21 + .../gitea-issue-solver/roles/intake/index.ts | 67 ++ .../gitea-issue-solver/roles/intake/prompt.ts | 4 + .../roles/issue-reader/index.ts | 259 ++++ .../roles/issue-reader/prompt.ts | 4 + .../gitea-issue-solver/roles/planner/index.ts | 123 ++ .../roles/planner/prompt.ts | 31 + .../roles/pr-publisher/index.ts | 173 +++ .../roles/pr-publisher/prompt.ts | 4 + .../gitea-issue-solver/roles/tester/index.ts | 80 ++ .../gitea-issue-solver/roles/tester/prompt.ts | 4 + 26 files changed, 1231 insertions(+), 1065 deletions(-) create mode 100644 workflows/gitea-issue-solver/.gitignore create mode 100644 workflows/gitea-issue-solver/build.ts create mode 100644 workflows/gitea-issue-solver/lib/constants.ts create mode 100644 workflows/gitea-issue-solver/lib/meta-helpers.ts create mode 100644 workflows/gitea-issue-solver/lib/nerve-read.ts create mode 100644 workflows/gitea-issue-solver/lib/provider.ts create mode 100644 workflows/gitea-issue-solver/lib/run-test-command.ts create mode 100644 workflows/gitea-issue-solver/lib/run-with-retries.ts create mode 100644 workflows/gitea-issue-solver/lib/spawn-utils.ts create mode 100644 workflows/gitea-issue-solver/lib/text-utils.ts create mode 100644 workflows/gitea-issue-solver/moderator.ts create mode 100644 workflows/gitea-issue-solver/roles/implementer/index.ts create mode 100644 workflows/gitea-issue-solver/roles/implementer/prompt.ts create mode 100644 workflows/gitea-issue-solver/roles/intake/index.ts create mode 100644 workflows/gitea-issue-solver/roles/intake/prompt.ts create mode 100644 workflows/gitea-issue-solver/roles/issue-reader/index.ts create mode 100644 workflows/gitea-issue-solver/roles/issue-reader/prompt.ts create mode 100644 workflows/gitea-issue-solver/roles/planner/index.ts create mode 100644 workflows/gitea-issue-solver/roles/planner/prompt.ts create mode 100644 workflows/gitea-issue-solver/roles/pr-publisher/index.ts create mode 100644 workflows/gitea-issue-solver/roles/pr-publisher/prompt.ts create mode 100644 workflows/gitea-issue-solver/roles/tester/index.ts create mode 100644 workflows/gitea-issue-solver/roles/tester/prompt.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea1483b..a9c2385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.17 + esbuild: + specifier: ^0.27.0 + version: 0.27.7 typescript: specifier: ^5.7.0 version: 5.9.3 diff --git a/workflows/gitea-issue-solver/.gitignore b/workflows/gitea-issue-solver/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/workflows/gitea-issue-solver/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/workflows/gitea-issue-solver/build.ts b/workflows/gitea-issue-solver/build.ts new file mode 100644 index 0000000..72f0ae5 --- /dev/null +++ b/workflows/gitea-issue-solver/build.ts @@ -0,0 +1,28 @@ +import type { WorkflowDefinition } from "@uncaged/nerve-core"; +import { moderator } from "./moderator.js"; +import type { WorkflowMeta } from "./moderator.js"; +import { buildIntakeRole } from "./roles/intake/index.js"; +import { buildIssueReaderRole } from "./roles/issue-reader/index.js"; +import { buildPlannerRole } from "./roles/planner/index.js"; +import { buildImplementerRole } from "./roles/implementer/index.js"; +import { buildTesterRole } from "./roles/tester/index.js"; +import { buildPrPublisherRole } from "./roles/pr-publisher/index.js"; + +export type BuildGiteaIssueSolverDeps = { + nerveRoot: string; +}; + +export function buildGiteaIssueSolver({ nerveRoot }: BuildGiteaIssueSolverDeps): WorkflowDefinition { + return { + name: "gitea-issue-solver", + roles: { + intake: buildIntakeRole(), + "issue-reader": buildIssueReaderRole({ nerveRoot }), + planner: buildPlannerRole({ nerveRoot }), + implementer: buildImplementerRole({ nerveRoot }), + tester: buildTesterRole({ nerveRoot }), + "pr-publisher": buildPrPublisherRole({ nerveRoot }), + }, + moderator, + }; +} diff --git a/workflows/gitea-issue-solver/index.ts b/workflows/gitea-issue-solver/index.ts index df633f8..d254701 100644 --- a/workflows/gitea-issue-solver/index.ts +++ b/workflows/gitea-issue-solver/index.ts @@ -1,1069 +1,9 @@ -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, llmExtract, nerveAgentContext, readNerveYaml, spawnSafe } from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; +import { join } from "node:path"; +import { buildGiteaIssueSolver } from "./build.js"; -const NERVE_ROOT = "/home/azureuser/.uncaged-nerve"; -const ISSUE_READER_MAX_ATTEMPTS = 3; -const TESTER_MAX_ATTEMPTS = 3; -const PUBLISHER_MAX_ATTEMPTS = 3; +const HOME = process.env.HOME ?? "/home/azureuser"; +const NERVE_ROOT = join(HOME, ".uncaged-nerve"); -type IssueData = { - id: number; - number: number; - title: string; - body: string; - state: string; - labels: string[]; - comments: Array<{ - author: string; - body: string; - createdAt: string; - }>; - url: string; -}; - -type TestCommandResult = { - command: string; - ok: boolean; - stdoutPreview: string; - stderrPreview: string; -}; - -type WorkflowMeta = { - intake: { - issueUrl: string; - host: string | null; - owner: string | null; - repo: string | null; - issueNumber: number | null; - valid: boolean; - failureKind: "none" | "invalid_input"; - failureReason: string | null; - }; - "issue-reader": { - issue: IssueData | null; - fetchOk: boolean; - transientError: boolean; - attempt: number; - failureKind: "none" | "transient" | "permanent"; - failureReason: string | null; - }; - planner: { - plan: string | null; - targetFiles: string[] | null; - testCommands: string[] | null; - riskNotes: string[] | null; - planningOk: boolean; - failureKind: "none" | "planning_failed"; - failureReason: string | null; - }; - implementer: { - branchName: string | null; - changedFiles: string[] | null; - implementationOk: boolean; - attempt: number; - failureKind: "none" | "branch_failed" | "agent_failed" | "no_diff"; - failureReason: string | null; - implementationLog: string | null; - }; - tester: { - passed: boolean; - attempt: number; - failureReason: string | null; - testCommandResults: TestCommandResult[] | null; - }; - "pr-publisher": { - prUrl: string | null; - prNumber: number | null; - linkedIssue: string | null; - published: boolean; - attempt: number; - failureKind: "none" | "auth_or_network" | "permanent"; - failureReason: string | null; - }; -}; - -type Provider = { - baseUrl: string; - apiKey: string; - model: string; -}; - -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(""), -}); - -const planSchema = z.object({ - targetFiles: z.array(z.string().default("")).default([]), - testCommands: z.array(z.string().default("")).default([]), - riskNotes: z.array(z.string().default("")).default([]), -}); - -function lastMetaForRole(messages: WorkflowMessage[], role: string): M | null { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === role) { - return messages[i].meta as M; - } - } - return null; -} - -function formatSpawnFailure(error: SpawnError): string { - if (error.kind === "spawn_failed") { - return `spawn_failed: ${error.message}`; - } - if (error.kind === "timeout") { - return `timeout: stdout=${error.stdout.slice(0, 200)} stderr=${error.stderr.slice(0, 200)}`; - } - return `non_zero_exit(${error.exitCode}): stderr=${error.stderr.slice(0, 400)}`; -} - -function errorText(error: SpawnError): string { - if (error.kind === "spawn_failed") { - return error.message.toLowerCase(); - } - if (error.kind === "timeout") { - return `${error.stdout} ${error.stderr}`.toLowerCase(); - } - return `${error.stdout} ${error.stderr}`.toLowerCase(); -} - -function classifyIssueReaderFailure(error: SpawnError): { transientError: boolean; failureKind: "transient" | "permanent" } { - if (error.kind === "timeout" || error.kind === "spawn_failed") { - return { transientError: true, failureKind: "transient" }; - } - const text = errorText(error); - if ( - text.includes("network") || - text.includes("timed out") || - text.includes("timeout") || - text.includes("connection reset") || - text.includes("connection refused") || - text.includes("429") || - text.includes("500") || - text.includes("502") || - text.includes("503") || - text.includes("504") - ) { - return { transientError: true, failureKind: "transient" }; - } - return { transientError: false, failureKind: "permanent" }; -} - -function classifyPublisherFailure(error: SpawnError): "auth_or_network" | "permanent" { - if (error.kind === "timeout" || error.kind === "spawn_failed") { - return "auth_or_network"; - } - const text = errorText(error); - if ( - text.includes("401") || - text.includes("403") || - text.includes("auth") || - text.includes("token") || - text.includes("permission") || - text.includes("network") || - text.includes("connection") || - text.includes("timeout") - ) { - return "auth_or_network"; - } - return "permanent"; -} - -function parseIssueUrl(raw: string): WorkflowMeta["intake"] { - const issueUrl = raw.trim(); - const match = issueUrl.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i); - if (match === null) { - return { - issueUrl, - host: null, - owner: null, - repo: null, - issueNumber: null, - valid: false, - failureKind: "invalid_input", - failureReason: `invalid issue URL: ${issueUrl}`, - }; - } - - const issueNumber = Number(match[4]); - if (!Number.isInteger(issueNumber) || issueNumber <= 0) { - return { - issueUrl, - host: null, - owner: null, - repo: null, - issueNumber: null, - valid: false, - failureKind: "invalid_input", - failureReason: `invalid issue number: ${match[4]}`, - }; - } - - return { - issueUrl, - host: match[1], - owner: match[2], - repo: match[3], - issueNumber, - valid: true, - failureKind: "none", - failureReason: null, - }; -} - -function slugify(input: string): string { - const normalized = input - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return normalized.length > 0 ? normalized.slice(0, 40) : "update"; -} - -function readNerveConfigText(): 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; - } - const value = result.value.stdout.trim(); - return value.length > 0 ? value : null; -} - -async function resolveDashScopeProvider(): Promise { - 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 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; -} - -async function runWithRetries( - run: () => Promise< - | { ok: true; stdout: string; stderr: string } - | { ok: false; error: SpawnError; lastStdout: string; lastStderr: string } - >, - maxAttempts: number, -): Promise< - | { ok: true; stdout: string; stderr: string; attemptsUsed: number } - | { ok: false; error: SpawnError; attemptsUsed: number; lastStdout: string; lastStderr: string } -> { - let attemptsUsed = 0; - let lastError: SpawnError | null = null; - let lastStdout = ""; - let lastStderr = ""; - while (attemptsUsed < maxAttempts) { - attemptsUsed += 1; - const current = await run(); - if (current.ok) { - return { ok: true, stdout: current.stdout, stderr: current.stderr, attemptsUsed }; - } - lastError = current.error; - lastStdout = current.lastStdout; - lastStderr = current.lastStderr; - if (attemptsUsed < maxAttempts) { - const backoffMs = Math.min(1000 * 2 ** (attemptsUsed - 1), 4000); - await new Promise((resolve) => setTimeout(resolve, backoffMs)); - } - } - if (lastError === null) { - return { - ok: false, - error: { kind: "spawn_failed", message: "unknown retry failure" }, - attemptsUsed, - lastStdout, - lastStderr, - }; - } - return { ok: false, error: lastError, attemptsUsed, lastStdout, lastStderr }; -} - -function extractPrInfo(text: string): { prUrl: string | null; prNumber: number | null } { - const url = text.match(/https?:\/\/\S+/)?.[0] ?? null; - const num = text.match(/(?:pulls|pull|pr)\/(\d+)/i)?.[1] ?? text.match(/#(\d+)/)?.[1] ?? null; - return { prUrl: url, prNumber: num === null ? null : Number(num) }; -} - -async function runTestCommand(command: string): Promise<{ - ok: boolean; - stdoutPreview: string; - stderrPreview: string; - reason: string | null; -}> { - const result = await spawnSafe("bash", ["-lc", command], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 300_000, - }); - if (result.ok) { - return { - ok: true, - stdoutPreview: result.value.stdout.slice(0, 600), - stderrPreview: result.value.stderr.slice(0, 400), - reason: null, - }; - } - return { - ok: false, - stdoutPreview: (result.error.kind === "spawn_failed" ? "" : result.error.stdout).slice(0, 600), - stderrPreview: (result.error.kind === "spawn_failed" ? result.error.message : result.error.stderr).slice(0, 400), - reason: formatSpawnFailure(result.error), - }; -} - -async function intake(start: StartStep, _messages: WorkflowMessage[]): Promise> { - const parsed = parseIssueUrl(start.content); - if (!parsed.valid) { - return { content: parsed.failureReason ?? "invalid issue URL", meta: parsed }; - } - return { - content: `parsed issue URL: ${parsed.owner}/${parsed.repo}#${parsed.issueNumber} @ ${parsed.host}`, - meta: parsed, - }; -} - -async function issueReader( - _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: NERVE_ROOT, - 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: NERVE_ROOT, - 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 resolveDashScopeProvider(); - 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), - }, - }; -} - -async function planner(_start: StartStep, messages: WorkflowMessage[]): Promise> { - const issueMeta = lastMetaForRole(messages, "issue-reader"); - if (issueMeta === null || !issueMeta.fetchOk || issueMeta.issue === null) { - return { - content: "planner cannot continue: issue-reader has no issue data", - meta: { - plan: null, - targetFiles: null, - testCommands: null, - riskNotes: null, - planningOk: false, - failureKind: "planning_failed", - failureReason: "missing issue data", - }, - }; - } - - const prompt = `You are planning a fix for a Gitea issue in this repository. - -${nerveAgentContext} - -Issue URL: ${issueMeta.issue.url} -Issue title: ${issueMeta.issue.title} -Issue body: -${issueMeta.issue.body} - -Issue comments: -${issueMeta.issue.comments.map((c) => `- ${c.author} (${c.createdAt}): ${c.body}`).join("\n") || "(none)"} - -Current nerve.yaml: -\`\`\`yaml -${readNerveConfigText()} -\`\`\` - -Output implementation-ready markdown with sections: -1) Problem understanding -2) Change strategy -3) Test strategy (commands) -4) Risks`; - - const result = await cursorAgent({ - prompt, - mode: "ask", - cwd: NERVE_ROOT, - env: null, - timeoutMs: null, - }); - if (!result.ok) { - return { - content: `planner failed: ${formatSpawnFailure(result.error)}`, - meta: { - plan: null, - targetFiles: null, - testCommands: null, - riskNotes: null, - planningOk: false, - failureKind: "planning_failed", - failureReason: formatSpawnFailure(result.error), - }, - }; - } - - const plan = result.value; - const provider = await resolveDashScopeProvider(); - if (provider === null) { - return { - content: plan, - meta: { - plan, - targetFiles: null, - testCommands: null, - riskNotes: null, - planningOk: true, - failureKind: "none", - failureReason: null, - }, - }; - } - - const structured = await llmExtract({ text: plan, schema: planSchema, provider }); - if (!structured.ok) { - return { - content: `${plan}\n\n[llmExtract error] ${JSON.stringify(structured.error)}`, - meta: { - plan, - targetFiles: null, - testCommands: null, - riskNotes: null, - planningOk: true, - failureKind: "none", - failureReason: null, - }, - }; - } - - return { - content: plan, - meta: { - plan, - targetFiles: structured.value.targetFiles, - testCommands: structured.value.testCommands, - riskNotes: structured.value.riskNotes, - planningOk: true, - failureKind: "none", - failureReason: null, - }, - }; -} - -async function implementer( - _start: StartStep, - messages: WorkflowMessage[], -): Promise> { - const plannerMeta = lastMetaForRole(messages, "planner"); - const intakeMeta = lastMetaForRole(messages, "intake"); - const issueMeta = lastMetaForRole(messages, "issue-reader"); - const attempt = messages.filter((message) => message.role === "implementer").length + 1; - - if ( - plannerMeta === null || - !plannerMeta.planningOk || - plannerMeta.plan === null || - intakeMeta === null || - intakeMeta.issueNumber === null - ) { - return { - content: "implementer cannot continue: missing planner or intake context", - meta: { - branchName: null, - changedFiles: null, - implementationOk: false, - attempt, - failureKind: "agent_failed", - failureReason: "missing planner/intake context", - implementationLog: null, - }, - }; - } - - const slugSource = issueMeta?.issue?.title ?? plannerMeta.plan.split("\n")[0] ?? "fix"; - const branchName = `fix/issue-${intakeMeta.issueNumber}-${slugify(slugSource)}`; - - const existsResult = await spawnSafe("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 10_000, - }); - const checkoutResult = existsResult.ok - ? await spawnSafe("git", ["checkout", branchName], { cwd: NERVE_ROOT, env: null, timeoutMs: 20_000 }) - : await spawnSafe("git", ["checkout", "-b", branchName], { cwd: NERVE_ROOT, env: null, timeoutMs: 20_000 }); - - if (!checkoutResult.ok) { - return { - content: `branch setup failed: ${formatSpawnFailure(checkoutResult.error)}`, - meta: { - branchName, - changedFiles: null, - implementationOk: false, - attempt, - failureKind: "branch_failed", - failureReason: formatSpawnFailure(checkoutResult.error), - implementationLog: null, - }, - }; - } - - const prompt = `Implement the planned fix in this repository. - -Issue #${intakeMeta.issueNumber} -Plan: -${plannerMeta.plan} - -Target files: -${plannerMeta.targetFiles?.join("\n") ?? "(not specified)"} - -Test commands: -${plannerMeta.testCommands?.join("\n") ?? "(not specified)"} - -Apply the code changes now with minimal, focused edits.`; - - const agentResult = await cursorAgent({ - prompt, - mode: "default", - cwd: NERVE_ROOT, - env: null, - timeoutMs: null, - }); - if (!agentResult.ok) { - return { - content: `implementer agent failed: ${formatSpawnFailure(agentResult.error)}`, - meta: { - branchName, - changedFiles: null, - implementationOk: false, - attempt, - failureKind: "agent_failed", - failureReason: formatSpawnFailure(agentResult.error), - implementationLog: null, - }, - }; - } - - const diffResult = await spawnSafe("git", ["diff", "--name-only"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 15_000, - }); - if (!diffResult.ok) { - return { - content: `implementation finished but diff check failed: ${formatSpawnFailure(diffResult.error)}`, - meta: { - branchName, - changedFiles: null, - implementationOk: false, - attempt, - failureKind: "no_diff", - failureReason: formatSpawnFailure(diffResult.error), - implementationLog: agentResult.value, - }, - }; - } - - const changedFiles = diffResult.value.stdout - .split("\n") - .map((file) => file.trim()) - .filter((file) => file.length > 0); - if (changedFiles.length === 0) { - return { - content: "implementer made no local diff", - meta: { - branchName, - changedFiles: [], - implementationOk: false, - attempt, - failureKind: "no_diff", - failureReason: "git diff --name-only is empty", - implementationLog: agentResult.value, - }, - }; - } - - return { - content: `branch ready: ${branchName}\nchanged files:\n${changedFiles.join("\n")}`, - meta: { - branchName, - changedFiles, - implementationOk: true, - attempt, - failureKind: "none", - failureReason: null, - implementationLog: agentResult.value, - }, - }; -} - -async function tester(_start: StartStep, messages: WorkflowMessage[]): Promise> { - const plannerMeta = lastMetaForRole(messages, "planner"); - const implementerMeta = lastMetaForRole(messages, "implementer"); - const attempt = messages.filter((message) => message.role === "tester").length + 1; - - if (implementerMeta === null || !implementerMeta.implementationOk || implementerMeta.branchName === null) { - return { - content: "tester cannot continue: no successful implementer output", - meta: { - passed: false, - attempt, - failureReason: "no successful implementer output", - testCommandResults: null, - }, - }; - } - - const commands = - plannerMeta?.testCommands !== null && plannerMeta?.testCommands !== undefined && plannerMeta.testCommands.length > 0 - ? plannerMeta.testCommands - : ["pnpm test"]; - - const testCommandResults: TestCommandResult[] = []; - for (const command of commands) { - const run = await runTestCommand(command); - testCommandResults.push({ - command, - ok: run.ok, - stdoutPreview: run.stdoutPreview, - stderrPreview: run.stderrPreview, - }); - if (!run.ok) { - return { - content: `test failed: ${command}\n${run.reason ?? "unknown error"}`, - meta: { - passed: false, - attempt, - failureReason: `command failed: ${command} (${run.reason ?? "unknown error"})`, - testCommandResults, - }, - }; - } - } - - return { - content: `all tests passed on branch ${implementerMeta.branchName}`, - meta: { - passed: true, - attempt, - failureReason: null, - testCommandResults, - }, - }; -} - -async function prPublisher( - _start: StartStep, - messages: WorkflowMessage[], -): Promise> { - const attempt = messages.filter((message) => message.role === "pr-publisher").length + 1; - const intakeMeta = lastMetaForRole(messages, "intake"); - const issueMeta = lastMetaForRole(messages, "issue-reader"); - const plannerMeta = lastMetaForRole(messages, "planner"); - const implementerMeta = lastMetaForRole(messages, "implementer"); - const testerMeta = lastMetaForRole(messages, "tester"); - - if (testerMeta === null || !testerMeta.passed) { - return { - content: "skip PR publishing: tester.passed is not true", - meta: { - prUrl: null, - prNumber: null, - linkedIssue: intakeMeta?.issueUrl ?? null, - published: false, - attempt, - failureKind: "permanent", - failureReason: "tester did not pass", - }, - }; - } - if ( - intakeMeta === null || - intakeMeta.owner === null || - intakeMeta.repo === null || - implementerMeta === null || - implementerMeta.branchName === null - ) { - return { - content: "pr-publisher cannot continue: missing required context", - meta: { - prUrl: null, - prNumber: null, - linkedIssue: intakeMeta?.issueUrl ?? null, - published: false, - attempt, - failureKind: "permanent", - failureReason: "missing context", - }, - }; - } - - const pushRun = await runWithRetries(async () => { - const run = await spawnSafe("git", ["push", "-u", "origin", implementerMeta.branchName as string], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 180_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, - }; - }, PUBLISHER_MAX_ATTEMPTS); - - if (!pushRun.ok) { - const failureKind = classifyPublisherFailure(pushRun.error); - return { - content: `failed to push branch before PR: ${formatSpawnFailure(pushRun.error)}`, - meta: { - prUrl: null, - prNumber: null, - linkedIssue: intakeMeta.issueUrl, - published: false, - attempt, - failureKind, - failureReason: formatSpawnFailure(pushRun.error), - }, - }; - } - - const shortTitle = (issueMeta?.issue?.title ?? "issue fix").slice(0, 72); - const title = `fix: ${shortTitle}`; - const body = [ - `Issue: ${intakeMeta.issueUrl}`, - "", - "## Summary", - ...(implementerMeta.changedFiles ?? []).map((file) => `- updated \`${file}\``), - "", - "## Plan", - plannerMeta?.plan ?? "(no planner output)", - "", - "## Test Results", - testerMeta.testCommandResults?.map((r) => `- ${r.ok ? "PASS" : "FAIL"} \`${r.command}\``).join("\n") ?? - "- no test logs", - ].join("\n"); - const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`; - - const prRun = await runWithRetries(async () => { - const run = await spawnSafe( - "tea", - ["pr", "create", "--repo", repoSpec, "--title", title, "--body", body, "--head", implementerMeta.branchName as string], - { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 120_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, - }; - }, PUBLISHER_MAX_ATTEMPTS); - - if (!prRun.ok) { - const failureKind = classifyPublisherFailure(prRun.error); - return { - content: `PR creation failed: ${formatSpawnFailure(prRun.error)}`, - meta: { - prUrl: null, - prNumber: null, - linkedIssue: intakeMeta.issueUrl, - published: false, - attempt, - failureKind, - failureReason: formatSpawnFailure(prRun.error), - }, - }; - } - - const info = extractPrInfo(`${prRun.stdout}\n${prRun.stderr}`); - return { - content: `PR created: ${title}\n${prRun.stdout}`, - meta: { - prUrl: info.prUrl, - prNumber: info.prNumber, - linkedIssue: intakeMeta.issueUrl, - published: true, - attempt, - failureKind: "none", - failureReason: null, - }, - }; -} - -const workflow: WorkflowDefinition = { - name: "gitea-issue-solver", - roles: { - intake, - "issue-reader": issueReader, - planner, - implementer, - tester, - "pr-publisher": prPublisher, - }, - moderator(context: ModeratorContext) { - if (context.steps.length === 0) { - return "intake"; - } - - const last = context.steps[context.steps.length - 1]; - if (last.role === "intake") { - const meta = last.meta as WorkflowMeta["intake"]; - return meta.valid ? "issue-reader" : END; - } - - if (last.role === "issue-reader") { - const meta = last.meta as WorkflowMeta["issue-reader"]; - if (meta.fetchOk) { - return "planner"; - } - return END; - } - - if (last.role === "planner") { - const meta = last.meta as WorkflowMeta["planner"]; - return meta.planningOk ? "implementer" : END; - } - - if (last.role === "implementer") { - return "tester"; - } - - if (last.role === "tester") { - const meta = last.meta as WorkflowMeta["tester"]; - if (meta.passed) { - return "pr-publisher"; - } - return meta.attempt < TESTER_MAX_ATTEMPTS ? "implementer" : END; - } - - if (last.role === "pr-publisher") { - return END; - } - - return END; - }, -}; +const workflow = buildGiteaIssueSolver({ nerveRoot: NERVE_ROOT }); export default workflow; diff --git a/workflows/gitea-issue-solver/lib/constants.ts b/workflows/gitea-issue-solver/lib/constants.ts new file mode 100644 index 0000000..2ce0ab4 --- /dev/null +++ b/workflows/gitea-issue-solver/lib/constants.ts @@ -0,0 +1,3 @@ +export const ISSUE_READER_MAX_ATTEMPTS = 3; +export const TESTER_MAX_ATTEMPTS = 3; +export const PUBLISHER_MAX_ATTEMPTS = 3; diff --git a/workflows/gitea-issue-solver/lib/meta-helpers.ts b/workflows/gitea-issue-solver/lib/meta-helpers.ts new file mode 100644 index 0000000..f1d4c3b --- /dev/null +++ b/workflows/gitea-issue-solver/lib/meta-helpers.ts @@ -0,0 +1,10 @@ +import type { WorkflowMessage } from "@uncaged/nerve-core"; + +export function lastMetaForRole(messages: WorkflowMessage[], role: string): M | null { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === role) { + return messages[i].meta as M; + } + } + return null; +} diff --git a/workflows/gitea-issue-solver/lib/nerve-read.ts b/workflows/gitea-issue-solver/lib/nerve-read.ts new file mode 100644 index 0000000..c83ba53 --- /dev/null +++ b/workflows/gitea-issue-solver/lib/nerve-read.ts @@ -0,0 +1,6 @@ +import { readNerveYaml } from "@uncaged/nerve-workflow-utils"; + +export function readNerveConfigText(nerveRoot: string): string { + const result = readNerveYaml({ nerveRoot }); + return result.ok ? result.value : "# nerve.yaml unavailable"; +} diff --git a/workflows/gitea-issue-solver/lib/provider.ts b/workflows/gitea-issue-solver/lib/provider.ts new file mode 100644 index 0000000..53e20ce --- /dev/null +++ b/workflows/gitea-issue-solver/lib/provider.ts @@ -0,0 +1,26 @@ +import { spawnSafe } from "@uncaged/nerve-workflow-utils"; + +export type Provider = { + baseUrl: string; + apiKey: string; + model: string; +}; + +export async function cfgGet(nerveRoot: string, key: string): Promise { + const result = await spawnSafe("cfg", ["get", key], { cwd: nerveRoot, env: null, timeoutMs: 10_000 }); + if (!result.ok) { + return null; + } + const value = result.value.stdout.trim(); + return value.length > 0 ? value : null; +} + +export async function resolveDashScopeProvider(nerveRoot: string): Promise { + const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet(nerveRoot, "DASHSCOPE_API_KEY")); + const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet(nerveRoot, "DASHSCOPE_BASE_URL")); + const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet(nerveRoot, "DASHSCOPE_MODEL")) ?? "qwen-plus"; + if (!apiKey || !baseUrl) { + return null; + } + return { apiKey, baseUrl, model }; +} diff --git a/workflows/gitea-issue-solver/lib/run-test-command.ts b/workflows/gitea-issue-solver/lib/run-test-command.ts new file mode 100644 index 0000000..a43bdd4 --- /dev/null +++ b/workflows/gitea-issue-solver/lib/run-test-command.ts @@ -0,0 +1,32 @@ +import { spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { formatSpawnFailure } from "./spawn-utils.js"; + +export async function runTestCommand( + nerveRoot: string, + command: string, +): Promise<{ + ok: boolean; + stdoutPreview: string; + stderrPreview: string; + reason: string | null; +}> { + const result = await spawnSafe("bash", ["-lc", command], { + cwd: nerveRoot, + env: null, + timeoutMs: 300_000, + }); + if (result.ok) { + return { + ok: true, + stdoutPreview: result.value.stdout.slice(0, 600), + stderrPreview: result.value.stderr.slice(0, 400), + reason: null, + }; + } + return { + ok: false, + stdoutPreview: (result.error.kind === "spawn_failed" ? "" : result.error.stdout).slice(0, 600), + stderrPreview: (result.error.kind === "spawn_failed" ? result.error.message : result.error.stderr).slice(0, 400), + reason: formatSpawnFailure(result.error), + }; +} diff --git a/workflows/gitea-issue-solver/lib/run-with-retries.ts b/workflows/gitea-issue-solver/lib/run-with-retries.ts new file mode 100644 index 0000000..74c25e6 --- /dev/null +++ b/workflows/gitea-issue-solver/lib/run-with-retries.ts @@ -0,0 +1,41 @@ +import type { SpawnError } from "@uncaged/nerve-workflow-utils"; + +export async function runWithRetries( + run: () => Promise< + | { ok: true; stdout: string; stderr: string } + | { ok: false; error: SpawnError; lastStdout: string; lastStderr: string } + >, + maxAttempts: number, +): Promise< + | { ok: true; stdout: string; stderr: string; attemptsUsed: number } + | { ok: false; error: SpawnError; attemptsUsed: number; lastStdout: string; lastStderr: string } +> { + let attemptsUsed = 0; + let lastError: SpawnError | null = null; + let lastStdout = ""; + let lastStderr = ""; + while (attemptsUsed < maxAttempts) { + attemptsUsed += 1; + const current = await run(); + if (current.ok) { + return { ok: true, stdout: current.stdout, stderr: current.stderr, attemptsUsed }; + } + lastError = current.error; + lastStdout = current.lastStdout; + lastStderr = current.lastStderr; + if (attemptsUsed < maxAttempts) { + const backoffMs = Math.min(1000 * 2 ** (attemptsUsed - 1), 4000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + if (lastError === null) { + return { + ok: false, + error: { kind: "spawn_failed", message: "unknown retry failure" }, + attemptsUsed, + lastStdout, + lastStderr, + }; + } + return { ok: false, error: lastError, attemptsUsed, lastStdout, lastStderr }; +} diff --git a/workflows/gitea-issue-solver/lib/spawn-utils.ts b/workflows/gitea-issue-solver/lib/spawn-utils.ts new file mode 100644 index 0000000..d6cafb4 --- /dev/null +++ b/workflows/gitea-issue-solver/lib/spawn-utils.ts @@ -0,0 +1,66 @@ +import type { SpawnError } from "@uncaged/nerve-workflow-utils"; + +export function formatSpawnFailure(error: SpawnError): string { + if (error.kind === "spawn_failed") { + return `spawn_failed: ${error.message}`; + } + if (error.kind === "timeout") { + return `timeout: stdout=${error.stdout.slice(0, 200)} stderr=${error.stderr.slice(0, 200)}`; + } + return `non_zero_exit(${error.exitCode}): stderr=${error.stderr.slice(0, 400)}`; +} + +export function errorText(error: SpawnError): string { + if (error.kind === "spawn_failed") { + return error.message.toLowerCase(); + } + if (error.kind === "timeout") { + return `${error.stdout} ${error.stderr}`.toLowerCase(); + } + return `${error.stdout} ${error.stderr}`.toLowerCase(); +} + +export function classifyIssueReaderFailure(error: SpawnError): { + transientError: boolean; + failureKind: "transient" | "permanent"; +} { + if (error.kind === "timeout" || error.kind === "spawn_failed") { + return { transientError: true, failureKind: "transient" }; + } + const text = errorText(error); + if ( + text.includes("network") || + text.includes("timed out") || + text.includes("timeout") || + text.includes("connection reset") || + text.includes("connection refused") || + text.includes("429") || + text.includes("500") || + text.includes("502") || + text.includes("503") || + text.includes("504") + ) { + return { transientError: true, failureKind: "transient" }; + } + return { transientError: false, failureKind: "permanent" }; +} + +export function classifyPublisherFailure(error: SpawnError): "auth_or_network" | "permanent" { + if (error.kind === "timeout" || error.kind === "spawn_failed") { + return "auth_or_network"; + } + const text = errorText(error); + if ( + text.includes("401") || + text.includes("403") || + text.includes("auth") || + text.includes("token") || + text.includes("permission") || + text.includes("network") || + text.includes("connection") || + text.includes("timeout") + ) { + return "auth_or_network"; + } + return "permanent"; +} diff --git a/workflows/gitea-issue-solver/lib/text-utils.ts b/workflows/gitea-issue-solver/lib/text-utils.ts new file mode 100644 index 0000000..a4e7255 --- /dev/null +++ b/workflows/gitea-issue-solver/lib/text-utils.ts @@ -0,0 +1,13 @@ +export function slugify(input: string): string { + const normalized = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized.length > 0 ? normalized.slice(0, 40) : "update"; +} + +export function extractPrInfo(text: string): { prUrl: string | null; prNumber: number | null } { + const url = text.match(/https?:\/\/\S+/)?.[0] ?? null; + const num = text.match(/(?:pulls|pull|pr)\/(\d+)/i)?.[1] ?? text.match(/#(\d+)/)?.[1] ?? null; + return { prUrl: url, prNumber: num === null ? null : Number(num) }; +} diff --git a/workflows/gitea-issue-solver/moderator.ts b/workflows/gitea-issue-solver/moderator.ts new file mode 100644 index 0000000..fc5ab51 --- /dev/null +++ b/workflows/gitea-issue-solver/moderator.ts @@ -0,0 +1,61 @@ +import { END } from "@uncaged/nerve-core"; +import type { Moderator } from "@uncaged/nerve-core"; +import { TESTER_MAX_ATTEMPTS } from "./lib/constants.js"; +import type { IntakeMeta } from "./roles/intake/index.js"; +import type { IssueReaderMeta } from "./roles/issue-reader/index.js"; +import type { PlannerMeta } from "./roles/planner/index.js"; +import type { ImplementerMeta } from "./roles/implementer/index.js"; +import type { TesterMeta } from "./roles/tester/index.js"; +import type { PrPublisherMeta } from "./roles/pr-publisher/index.js"; + +export type WorkflowMeta = { + intake: IntakeMeta; + "issue-reader": IssueReaderMeta; + planner: PlannerMeta; + implementer: ImplementerMeta; + tester: TesterMeta; + "pr-publisher": PrPublisherMeta; +}; + +export const moderator: Moderator = (context) => { + if (context.steps.length === 0) { + return "intake"; + } + + const last = context.steps[context.steps.length - 1]; + if (last.role === "intake") { + const meta = last.meta as WorkflowMeta["intake"]; + return meta.valid ? "issue-reader" : END; + } + + if (last.role === "issue-reader") { + const meta = last.meta as WorkflowMeta["issue-reader"]; + if (meta.fetchOk) { + return "planner"; + } + return END; + } + + if (last.role === "planner") { + const meta = last.meta as WorkflowMeta["planner"]; + return meta.planningOk ? "implementer" : END; + } + + if (last.role === "implementer") { + return "tester"; + } + + if (last.role === "tester") { + const meta = last.meta as WorkflowMeta["tester"]; + if (meta.passed) { + return "pr-publisher"; + } + return meta.attempt < TESTER_MAX_ATTEMPTS ? "implementer" : END; + } + + if (last.role === "pr-publisher") { + return END; + } + + return END; +}; diff --git a/workflows/gitea-issue-solver/package.json b/workflows/gitea-issue-solver/package.json index b41a656..78fe8bd 100644 --- a/workflows/gitea-issue-solver/package.json +++ b/workflows/gitea-issue-solver/package.json @@ -3,6 +3,9 @@ "version": "0.0.1", "private": true, "type": "module", + "scripts": { + "build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external" + }, "dependencies": { "@uncaged/nerve-core": "latest", "@uncaged/nerve-workflow-utils": "latest", @@ -10,6 +13,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "^0.27.0", "typescript": "^5.7.0" } } diff --git a/workflows/gitea-issue-solver/roles/implementer/index.ts b/workflows/gitea-issue-solver/roles/implementer/index.ts new file mode 100644 index 0000000..b7bb124 --- /dev/null +++ b/workflows/gitea-issue-solver/roles/implementer/index.ts @@ -0,0 +1,162 @@ +import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { cursorAgent, spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { lastMetaForRole } from "../../lib/meta-helpers.js"; +import { formatSpawnFailure } from "../../lib/spawn-utils.js"; +import { slugify } from "../../lib/text-utils.js"; +import type { IntakeMeta } from "../intake/index.js"; +import type { IssueReaderMeta } from "../issue-reader/index.js"; +import type { PlannerMeta } from "../planner/index.js"; +import { buildImplementerPrompt } from "./prompt.js"; + +export type ImplementerMeta = { + branchName: string | null; + changedFiles: string[] | null; + implementationOk: boolean; + attempt: number; + failureKind: "none" | "branch_failed" | "agent_failed" | "no_diff"; + failureReason: string | null; + implementationLog: string | null; +}; + +export type BuildImplementerDeps = { + nerveRoot: string; +}; + +export function buildImplementerRole({ nerveRoot }: BuildImplementerDeps): Role { + return async (_start: StartStep, messages: WorkflowMessage[]): Promise> => { + const plannerMeta = lastMetaForRole(messages, "planner"); + const intakeMeta = lastMetaForRole(messages, "intake"); + const issueMeta = lastMetaForRole(messages, "issue-reader"); + const attempt = messages.filter((message) => message.role === "implementer").length + 1; + + if ( + plannerMeta === null || + !plannerMeta.planningOk || + plannerMeta.plan === null || + intakeMeta === null || + intakeMeta.issueNumber === null + ) { + return { + content: "implementer cannot continue: missing planner or intake context", + meta: { + branchName: null, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "agent_failed", + failureReason: "missing planner/intake context", + implementationLog: null, + }, + }; + } + + const slugSource = issueMeta?.issue?.title ?? plannerMeta.plan.split("\n")[0] ?? "fix"; + const branchName = `fix/issue-${intakeMeta.issueNumber}-${slugify(slugSource)}`; + + const existsResult = await spawnSafe("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], { + cwd: nerveRoot, + env: null, + timeoutMs: 10_000, + }); + const checkoutResult = existsResult.ok + ? await spawnSafe("git", ["checkout", branchName], { cwd: nerveRoot, env: null, timeoutMs: 20_000 }) + : await spawnSafe("git", ["checkout", "-b", branchName], { cwd: nerveRoot, env: null, timeoutMs: 20_000 }); + + if (!checkoutResult.ok) { + return { + content: `branch setup failed: ${formatSpawnFailure(checkoutResult.error)}`, + meta: { + branchName, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "branch_failed", + failureReason: formatSpawnFailure(checkoutResult.error), + implementationLog: null, + }, + }; + } + + const prompt = buildImplementerPrompt({ + issueNumber: intakeMeta.issueNumber, + plan: plannerMeta.plan, + targetFilesText: plannerMeta.targetFiles?.join("\n") ?? "(not specified)", + testCommandsText: plannerMeta.testCommands?.join("\n") ?? "(not specified)", + }); + + const agentResult = await cursorAgent({ + prompt, + mode: "default", + model: "auto", + cwd: nerveRoot, + env: null, + timeoutMs: null, + }); + if (!agentResult.ok) { + return { + content: `implementer agent failed: ${formatSpawnFailure(agentResult.error)}`, + meta: { + branchName, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "agent_failed", + failureReason: formatSpawnFailure(agentResult.error), + implementationLog: null, + }, + }; + } + + const diffResult = await spawnSafe("git", ["diff", "--name-only"], { + cwd: nerveRoot, + env: null, + timeoutMs: 15_000, + }); + if (!diffResult.ok) { + return { + content: `implementation finished but diff check failed: ${formatSpawnFailure(diffResult.error)}`, + meta: { + branchName, + changedFiles: null, + implementationOk: false, + attempt, + failureKind: "no_diff", + failureReason: formatSpawnFailure(diffResult.error), + implementationLog: agentResult.value, + }, + }; + } + + const changedFiles = diffResult.value.stdout + .split("\n") + .map((file) => file.trim()) + .filter((file) => file.length > 0); + if (changedFiles.length === 0) { + return { + content: "implementer made no local diff", + meta: { + branchName, + changedFiles: [], + implementationOk: false, + attempt, + failureKind: "no_diff", + failureReason: "git diff --name-only is empty", + implementationLog: agentResult.value, + }, + }; + } + + return { + content: `branch ready: ${branchName}\nchanged files:\n${changedFiles.join("\n")}`, + meta: { + branchName, + changedFiles, + implementationOk: true, + attempt, + failureKind: "none", + failureReason: null, + implementationLog: agentResult.value, + }, + }; + }; +} diff --git a/workflows/gitea-issue-solver/roles/implementer/prompt.ts b/workflows/gitea-issue-solver/roles/implementer/prompt.ts new file mode 100644 index 0000000..31f8ba5 --- /dev/null +++ b/workflows/gitea-issue-solver/roles/implementer/prompt.ts @@ -0,0 +1,21 @@ +export function buildImplementerPrompt(params: { + issueNumber: number; + plan: string; + targetFilesText: string; + testCommandsText: string; +}): string { + const { issueNumber, plan, targetFilesText, testCommandsText } = params; + return `Implement the planned fix in this repository. + +Issue #${issueNumber} +Plan: +${plan} + +Target files: +${targetFilesText} + +Test commands: +${testCommandsText} + +Apply the code changes now with minimal, focused edits.`; +} diff --git a/workflows/gitea-issue-solver/roles/intake/index.ts b/workflows/gitea-issue-solver/roles/intake/index.ts new file mode 100644 index 0000000..4780e8b --- /dev/null +++ b/workflows/gitea-issue-solver/roles/intake/index.ts @@ -0,0 +1,67 @@ +import type { Role, RoleResult, StartStep } from "@uncaged/nerve-core"; + +export type IntakeMeta = { + issueUrl: string; + host: string | null; + owner: string | null; + repo: string | null; + issueNumber: number | null; + valid: boolean; + failureKind: "none" | "invalid_input"; + failureReason: string | null; +}; + +function parseIssueUrl(raw: string): IntakeMeta { + const issueUrl = raw.trim(); + const match = issueUrl.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i); + if (match === null) { + return { + issueUrl, + host: null, + owner: null, + repo: null, + issueNumber: null, + valid: false, + failureKind: "invalid_input", + failureReason: `invalid issue URL: ${issueUrl}`, + }; + } + + const issueNumber = Number(match[4]); + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + return { + issueUrl, + host: null, + owner: null, + repo: null, + issueNumber: null, + valid: false, + failureKind: "invalid_input", + failureReason: `invalid issue number: ${match[4]}`, + }; + } + + return { + issueUrl, + host: match[1], + owner: match[2], + repo: match[3], + issueNumber, + valid: true, + failureKind: "none", + failureReason: null, + }; +} + +export function buildIntakeRole(): Role { + return async (start: StartStep): Promise> => { + const parsed = parseIssueUrl(start.content); + if (!parsed.valid) { + return { content: parsed.failureReason ?? "invalid issue URL", meta: parsed }; + } + return { + content: `parsed issue URL: ${parsed.owner}/${parsed.repo}#${parsed.issueNumber} @ ${parsed.host}`, + meta: parsed, + }; + }; +} diff --git a/workflows/gitea-issue-solver/roles/intake/prompt.ts b/workflows/gitea-issue-solver/roles/intake/prompt.ts new file mode 100644 index 0000000..6d34733 --- /dev/null +++ b/workflows/gitea-issue-solver/roles/intake/prompt.ts @@ -0,0 +1,4 @@ +/** Intake role parses the issue URL from the workflow start message; no LLM prompt. */ +export function intakePrompt(): string { + return ""; +} diff --git a/workflows/gitea-issue-solver/roles/issue-reader/index.ts b/workflows/gitea-issue-solver/roles/issue-reader/index.ts new file mode 100644 index 0000000..785df91 --- /dev/null +++ b/workflows/gitea-issue-solver/roles/issue-reader/index.ts @@ -0,0 +1,259 @@ +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), + }, + }; + }; +} diff --git a/workflows/gitea-issue-solver/roles/issue-reader/prompt.ts b/workflows/gitea-issue-solver/roles/issue-reader/prompt.ts new file mode 100644 index 0000000..5eeb14f --- /dev/null +++ b/workflows/gitea-issue-solver/roles/issue-reader/prompt.ts @@ -0,0 +1,4 @@ +/** Issue reader fetches issue data via tea CLI; no separate LLM prompt. */ +export function issueReaderPrompt(): string { + return ""; +} diff --git a/workflows/gitea-issue-solver/roles/planner/index.ts b/workflows/gitea-issue-solver/roles/planner/index.ts new file mode 100644 index 0000000..39cf7e3 --- /dev/null +++ b/workflows/gitea-issue-solver/roles/planner/index.ts @@ -0,0 +1,123 @@ +import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { cursorAgent, llmExtract } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { readNerveConfigText } from "../../lib/nerve-read.js"; +import { resolveDashScopeProvider } from "../../lib/provider.js"; +import { lastMetaForRole } from "../../lib/meta-helpers.js"; +import { formatSpawnFailure } from "../../lib/spawn-utils.js"; +import type { IssueReaderMeta } from "../issue-reader/index.js"; +import { buildPlannerPrompt } from "./prompt.js"; + +export type PlannerMeta = { + plan: string | null; + targetFiles: string[] | null; + testCommands: string[] | null; + riskNotes: string[] | null; + planningOk: boolean; + failureKind: "none" | "planning_failed"; + failureReason: string | null; +}; + +const planSchema = z.object({ + targetFiles: z.array(z.string().default("")).default([]), + testCommands: z.array(z.string().default("")).default([]), + riskNotes: z.array(z.string().default("")).default([]), +}); + +export type BuildPlannerDeps = { + nerveRoot: string; +}; + +export function buildPlannerRole({ nerveRoot }: BuildPlannerDeps): Role { + return async (_start: StartStep, messages: WorkflowMessage[]): Promise> => { + const issueMeta = lastMetaForRole(messages, "issue-reader"); + if (issueMeta === null || !issueMeta.fetchOk || issueMeta.issue === null) { + return { + content: "planner cannot continue: issue-reader has no issue data", + meta: { + plan: null, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: false, + failureKind: "planning_failed", + failureReason: "missing issue data", + }, + }; + } + + const prompt = buildPlannerPrompt({ + issue: issueMeta.issue, + nerveYamlText: readNerveConfigText(nerveRoot), + }); + + const result = await cursorAgent({ + prompt, + mode: "ask", + model: "auto", + cwd: nerveRoot, + env: null, + timeoutMs: null, + }); + if (!result.ok) { + return { + content: `planner failed: ${formatSpawnFailure(result.error)}`, + meta: { + plan: null, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: false, + failureKind: "planning_failed", + failureReason: formatSpawnFailure(result.error), + }, + }; + } + + const plan = result.value; + const provider = await resolveDashScopeProvider(nerveRoot); + if (provider === null) { + return { + content: plan, + meta: { + plan, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: true, + failureKind: "none", + failureReason: null, + }, + }; + } + + const structured = await llmExtract({ text: plan, schema: planSchema, provider }); + if (!structured.ok) { + return { + content: `${plan}\n\n[llmExtract error] ${JSON.stringify(structured.error)}`, + meta: { + plan, + targetFiles: null, + testCommands: null, + riskNotes: null, + planningOk: true, + failureKind: "none", + failureReason: null, + }, + }; + } + + return { + content: plan, + meta: { + plan, + targetFiles: structured.value.targetFiles, + testCommands: structured.value.testCommands, + riskNotes: structured.value.riskNotes, + planningOk: true, + failureKind: "none", + failureReason: null, + }, + }; + }; +} diff --git a/workflows/gitea-issue-solver/roles/planner/prompt.ts b/workflows/gitea-issue-solver/roles/planner/prompt.ts new file mode 100644 index 0000000..dedef3d --- /dev/null +++ b/workflows/gitea-issue-solver/roles/planner/prompt.ts @@ -0,0 +1,31 @@ +import { nerveAgentContext } from "@uncaged/nerve-workflow-utils"; +import type { IssueData } from "../issue-reader/index.js"; + +export function buildPlannerPrompt(params: { + issue: IssueData; + nerveYamlText: string; +}): string { + const { issue, nerveYamlText } = params; + return `You are planning a fix for a Gitea issue in this repository. + +${nerveAgentContext} + +Issue URL: ${issue.url} +Issue title: ${issue.title} +Issue body: +${issue.body} + +Issue comments: +${issue.comments.map((c) => `- ${c.author} (${c.createdAt}): ${c.body}`).join("\n") || "(none)"} + +Current nerve.yaml: +\`\`\`yaml +${nerveYamlText} +\`\`\` + +Output implementation-ready markdown with sections: +1) Problem understanding +2) Change strategy +3) Test strategy (commands) +4) Risks`; +} diff --git a/workflows/gitea-issue-solver/roles/pr-publisher/index.ts b/workflows/gitea-issue-solver/roles/pr-publisher/index.ts new file mode 100644 index 0000000..e3e32ee --- /dev/null +++ b/workflows/gitea-issue-solver/roles/pr-publisher/index.ts @@ -0,0 +1,173 @@ +import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { PUBLISHER_MAX_ATTEMPTS } from "../../lib/constants.js"; +import { lastMetaForRole } from "../../lib/meta-helpers.js"; +import { runWithRetries } from "../../lib/run-with-retries.js"; +import { classifyPublisherFailure, formatSpawnFailure } from "../../lib/spawn-utils.js"; +import { extractPrInfo } from "../../lib/text-utils.js"; +import type { ImplementerMeta } from "../implementer/index.js"; +import type { IntakeMeta } from "../intake/index.js"; +import type { IssueReaderMeta } from "../issue-reader/index.js"; +import type { PlannerMeta } from "../planner/index.js"; +import type { TesterMeta } from "../tester/index.js"; + +export type PrPublisherMeta = { + prUrl: string | null; + prNumber: number | null; + linkedIssue: string | null; + published: boolean; + attempt: number; + failureKind: "none" | "auth_or_network" | "permanent"; + failureReason: string | null; +}; + +export type BuildPrPublisherDeps = { + nerveRoot: string; +}; + +export function buildPrPublisherRole({ nerveRoot }: BuildPrPublisherDeps): Role { + return async (_start: StartStep, messages: WorkflowMessage[]): Promise> => { + const attempt = messages.filter((message) => message.role === "pr-publisher").length + 1; + const intakeMeta = lastMetaForRole(messages, "intake"); + const issueMeta = lastMetaForRole(messages, "issue-reader"); + const plannerMeta = lastMetaForRole(messages, "planner"); + const implementerMeta = lastMetaForRole(messages, "implementer"); + const testerMeta = lastMetaForRole(messages, "tester"); + + if (testerMeta === null || !testerMeta.passed) { + return { + content: "skip PR publishing: tester.passed is not true", + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta?.issueUrl ?? null, + published: false, + attempt, + failureKind: "permanent", + failureReason: "tester did not pass", + }, + }; + } + if ( + intakeMeta === null || + intakeMeta.owner === null || + intakeMeta.repo === null || + implementerMeta === null || + implementerMeta.branchName === null + ) { + return { + content: "pr-publisher cannot continue: missing required context", + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta?.issueUrl ?? null, + published: false, + attempt, + failureKind: "permanent", + failureReason: "missing context", + }, + }; + } + + const pushRun = await runWithRetries(async () => { + const run = await spawnSafe("git", ["push", "-u", "origin", implementerMeta.branchName as string], { + cwd: nerveRoot, + env: null, + timeoutMs: 180_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, + }; + }, PUBLISHER_MAX_ATTEMPTS); + + if (!pushRun.ok) { + const failureKind = classifyPublisherFailure(pushRun.error); + return { + content: `failed to push branch before PR: ${formatSpawnFailure(pushRun.error)}`, + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta.issueUrl, + published: false, + attempt, + failureKind, + failureReason: formatSpawnFailure(pushRun.error), + }, + }; + } + + const shortTitle = (issueMeta?.issue?.title ?? "issue fix").slice(0, 72); + const title = `fix: ${shortTitle}`; + const body = [ + `Issue: ${intakeMeta.issueUrl}`, + "", + "## Summary", + ...(implementerMeta.changedFiles ?? []).map((file) => `- updated \`${file}\``), + "", + "## Plan", + plannerMeta?.plan ?? "(no planner output)", + "", + "## Test Results", + testerMeta.testCommandResults?.map((r) => `- ${r.ok ? "PASS" : "FAIL"} \`${r.command}\``).join("\n") ?? + "- no test logs", + ].join("\n"); + const repoSpec = `${intakeMeta.owner}/${intakeMeta.repo}`; + + const prRun = await runWithRetries(async () => { + const run = await spawnSafe( + "tea", + ["pr", "create", "--repo", repoSpec, "--title", title, "--body", body, "--head", implementerMeta.branchName as string], + { + cwd: nerveRoot, + env: null, + timeoutMs: 120_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, + }; + }, PUBLISHER_MAX_ATTEMPTS); + + if (!prRun.ok) { + const failureKind = classifyPublisherFailure(prRun.error); + return { + content: `PR creation failed: ${formatSpawnFailure(prRun.error)}`, + meta: { + prUrl: null, + prNumber: null, + linkedIssue: intakeMeta.issueUrl, + published: false, + attempt, + failureKind, + failureReason: formatSpawnFailure(prRun.error), + }, + }; + } + + const info = extractPrInfo(`${prRun.stdout}\n${prRun.stderr}`); + return { + content: `PR created: ${title}\n${prRun.stdout}`, + meta: { + prUrl: info.prUrl, + prNumber: info.prNumber, + linkedIssue: intakeMeta.issueUrl, + published: true, + attempt, + failureKind: "none", + failureReason: null, + }, + }; + }; +} diff --git a/workflows/gitea-issue-solver/roles/pr-publisher/prompt.ts b/workflows/gitea-issue-solver/roles/pr-publisher/prompt.ts new file mode 100644 index 0000000..ab918a1 --- /dev/null +++ b/workflows/gitea-issue-solver/roles/pr-publisher/prompt.ts @@ -0,0 +1,4 @@ +/** PR publisher runs git push and tea pr create; no LLM prompt. */ +export function prPublisherPrompt(): string { + return ""; +} diff --git a/workflows/gitea-issue-solver/roles/tester/index.ts b/workflows/gitea-issue-solver/roles/tester/index.ts new file mode 100644 index 0000000..21c791a --- /dev/null +++ b/workflows/gitea-issue-solver/roles/tester/index.ts @@ -0,0 +1,80 @@ +import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { lastMetaForRole } from "../../lib/meta-helpers.js"; +import { runTestCommand } from "../../lib/run-test-command.js"; +import type { ImplementerMeta } from "../implementer/index.js"; +import type { PlannerMeta } from "../planner/index.js"; + +export type TestCommandResult = { + command: string; + ok: boolean; + stdoutPreview: string; + stderrPreview: string; +}; + +export type TesterMeta = { + passed: boolean; + attempt: number; + failureReason: string | null; + testCommandResults: TestCommandResult[] | null; +}; + +export type BuildTesterDeps = { + nerveRoot: string; +}; + +export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role { + return async (_start: StartStep, messages: WorkflowMessage[]): Promise> => { + const plannerMeta = lastMetaForRole(messages, "planner"); + const implementerMeta = lastMetaForRole(messages, "implementer"); + const attempt = messages.filter((message) => message.role === "tester").length + 1; + + if (implementerMeta === null || !implementerMeta.implementationOk || implementerMeta.branchName === null) { + return { + content: "tester cannot continue: no successful implementer output", + meta: { + passed: false, + attempt, + failureReason: "no successful implementer output", + testCommandResults: null, + }, + }; + } + + const commands = + plannerMeta?.testCommands !== null && plannerMeta?.testCommands !== undefined && plannerMeta.testCommands.length > 0 + ? plannerMeta.testCommands + : ["pnpm test"]; + + const testCommandResults: TestCommandResult[] = []; + for (const command of commands) { + const run = await runTestCommand(nerveRoot, command); + testCommandResults.push({ + command, + ok: run.ok, + stdoutPreview: run.stdoutPreview, + stderrPreview: run.stderrPreview, + }); + if (!run.ok) { + return { + content: `test failed: ${command}\n${run.reason ?? "unknown error"}`, + meta: { + passed: false, + attempt, + failureReason: `command failed: ${command} (${run.reason ?? "unknown error"})`, + testCommandResults, + }, + }; + } + } + + return { + content: `all tests passed on branch ${implementerMeta.branchName}`, + meta: { + passed: true, + attempt, + failureReason: null, + testCommandResults, + }, + }; + }; +} diff --git a/workflows/gitea-issue-solver/roles/tester/prompt.ts b/workflows/gitea-issue-solver/roles/tester/prompt.ts new file mode 100644 index 0000000..116b09e --- /dev/null +++ b/workflows/gitea-issue-solver/roles/tester/prompt.ts @@ -0,0 +1,4 @@ +/** Tester runs shell test commands; no LLM prompt. */ +export function testerPrompt(): string { + return ""; +}