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, }, }; }; }