174 lines
5.8 KiB
TypeScript
174 lines
5.8 KiB
TypeScript
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<PrPublisherMeta> {
|
|
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<PrPublisherMeta>> => {
|
|
const attempt = messages.filter((message) => message.role === "pr-publisher").length + 1;
|
|
const intakeMeta = lastMetaForRole<IntakeMeta>(messages, "intake");
|
|
const issueMeta = lastMetaForRole<IssueReaderMeta>(messages, "issue-reader");
|
|
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
|
const implementerMeta = lastMetaForRole<ImplementerMeta>(messages, "implementer");
|
|
const testerMeta = lastMetaForRole<TesterMeta>(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,
|
|
},
|
|
};
|
|
};
|
|
}
|