diff --git a/src/commands/loop.ts b/src/commands/loop.ts index a3c7026..ffcc38d 100644 --- a/src/commands/loop.ts +++ b/src/commands/loop.ts @@ -100,33 +100,34 @@ export async function runLoop(cwd: string, args: string[] = []): Promise { const parsed = parseLoopArgs(args); let priorFinalBody = ""; let succeeded = false; + let loopWorkspace = cwd; const previousNoInspect = process.env.CSTACK_NO_POSTRUN_INSPECT; const previousAutomatedLoop = process.env.CSTACK_AUTOMATED_LOOP; process.env.CSTACK_NO_POSTRUN_INSPECT = "1"; process.env.CSTACK_AUTOMATED_LOOP = "1"; try { + if (parsed.options.repo) { + loopWorkspace = await cloneIterationRepo(parsed.options.repo, parsed.options.branch, 1); + } for (let iteration = 1; iteration <= parsed.options.iterations; iteration += 1) { - const iterationCwd = parsed.options.repo - ? await cloneIterationRepo(parsed.options.repo, parsed.options.branch, iteration) - : cwd; const intent = iteration === 1 || !priorFinalBody ? parsed.intent : buildRetryIntent(parsed.intent, priorFinalBody); process.stdout.write( [ `Loop iteration: ${iteration}/${parsed.options.iterations}`, - `Workspace: ${iterationCwd}`, + `Workspace: ${loopWorkspace}`, `Intent: ${intent.split("\n")[0]}` ].join("\n") + "\n" ); - const runId = await runIntent(iterationCwd, intent, { + const runId = await runIntent(loopWorkspace, intent, { dryRun: false, entrypoint: "run", ...(parsed.options.safe ? { safe: true } : {}) }); - const run = await readRun(iterationCwd, runId); + const run = await readRun(loopWorkspace, runId); priorFinalBody = await fs.readFile(run.finalPath, "utf8").catch(() => ""); process.stdout.write( diff --git a/src/deliver.ts b/src/deliver.ts index f88e3f4..ffdee3f 100644 --- a/src/deliver.ts +++ b/src/deliver.ts @@ -17,6 +17,7 @@ import { runDeliverValidationExecution, type DeliverValidationExecutionResult } import { WorkflowController } from "./workflow-machine.js"; import type { CstackConfig, + DeliverGitHubConfig, DeliveryReadinessPolicyRecord, DeploymentEvidenceRecord, DeliverTargetMode, @@ -219,6 +220,196 @@ function mergeUniqueLines(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } +function createEmptyGitHubMutationRecord(initialBranch = ""): GitHubMutationRecord { + return { + enabled: false, + branch: { + initial: initialBranch, + current: initialBranch, + created: false, + pushed: false, + remote: null + }, + commit: { + created: false, + changedFiles: [] + }, + pullRequest: { + created: false, + updated: false + }, + checks: { + watched: false, + polls: 0, + completed: false, + summary: "" + }, + release: { + requested: false, + created: false, + pushed: false, + uploadedFiles: [], + summary: "" + }, + blockers: [], + summary: "" + }; +} + +function buildStageSyncPolicy(policy: DeliverGitHubConfig): DeliverGitHubConfig { + return { + ...policy, + createRelease: false, + releasePushTag: false, + releaseFiles: [], + watchChecks: false + }; +} + +function createStageSyncReviewVerdict(stageName: "build" | "validation"): DeliverReviewVerdict { + return { + mode: "readiness", + status: "completed", + summary: + stageName === "build" + ? "Stage sync after build: repository changes were prepared for review." + : "Stage sync after validation: repository changes were prepared for review.", + findings: [], + recommendedActions: [], + acceptedSpecialists: [], + reportMarkdown: + stageName === "build" + ? "# Review Findings\n\nStage sync after build. Formal review has not run yet.\n" + : "# Review Findings\n\nStage sync after validation. Formal review has not run yet.\n" + }; +} + +function mergeGitHubMutationRecords(current: GitHubMutationRecord, next: GitHubMutationRecord): GitHubMutationRecord { + const currentRelease = current.release; + const nextRelease = next.release; + const mergedCommit: GitHubMutationRecord["commit"] = { + created: current.commit.created || next.commit.created, + changedFiles: mergeUniqueLines([...current.commit.changedFiles, ...next.commit.changedFiles]) + }; + const mergedCommitSha = next.commit.sha ?? current.commit.sha; + if (mergedCommitSha) { + mergedCommit.sha = mergedCommitSha; + } + const mergedCommitMessage = next.commit.message ?? current.commit.message; + if (mergedCommitMessage) { + mergedCommit.message = mergedCommitMessage; + } + + const mergedPullRequest: GitHubMutationRecord["pullRequest"] = { + created: current.pullRequest.created || next.pullRequest.created, + updated: current.pullRequest.updated || next.pullRequest.updated + }; + const mergedPullRequestNumber = next.pullRequest.number ?? current.pullRequest.number; + if (mergedPullRequestNumber !== undefined) { + mergedPullRequest.number = mergedPullRequestNumber; + } + const mergedPullRequestUrl = next.pullRequest.url ?? current.pullRequest.url; + if (mergedPullRequestUrl) { + mergedPullRequest.url = mergedPullRequestUrl; + } + const mergedPullRequestTitle = next.pullRequest.title ?? current.pullRequest.title; + if (mergedPullRequestTitle) { + mergedPullRequest.title = mergedPullRequestTitle; + } + const mergedBaseRefName = next.pullRequest.baseRefName ?? current.pullRequest.baseRefName; + if (mergedBaseRefName) { + mergedPullRequest.baseRefName = mergedBaseRefName; + } + const mergedHeadRefName = next.pullRequest.headRefName ?? current.pullRequest.headRefName; + if (mergedHeadRefName) { + mergedPullRequest.headRefName = mergedHeadRefName; + } + const mergedDraft = next.pullRequest.draft ?? current.pullRequest.draft; + if (mergedDraft !== undefined) { + mergedPullRequest.draft = mergedDraft; + } + + let mergedRelease: GitHubMutationRecord["release"] | undefined; + if (currentRelease || nextRelease) { + mergedRelease = { + requested: Boolean(currentRelease?.requested || nextRelease?.requested), + created: Boolean(currentRelease?.created || nextRelease?.created), + pushed: Boolean(currentRelease?.pushed || nextRelease?.pushed), + uploadedFiles: mergeUniqueLines([...(currentRelease?.uploadedFiles ?? []), ...(nextRelease?.uploadedFiles ?? [])]), + summary: nextRelease?.summary || currentRelease?.summary || "" + }; + const mergedTagName = nextRelease?.tagName ?? currentRelease?.tagName; + if (mergedTagName) { + mergedRelease.tagName = mergedTagName; + } + const mergedVersion = nextRelease?.version ?? currentRelease?.version; + if (mergedVersion !== undefined) { + mergedRelease.version = mergedVersion; + } + const mergedReleaseUrl = nextRelease?.url ?? currentRelease?.url; + if (mergedReleaseUrl) { + mergedRelease.url = mergedReleaseUrl; + } + const mergedReleaseName = nextRelease?.name ?? currentRelease?.name; + if (mergedReleaseName !== undefined) { + mergedRelease.name = mergedReleaseName; + } + } + + return { + enabled: current.enabled || next.enabled, + branch: { + initial: next.branch.initial || current.branch.initial, + current: next.branch.current || current.branch.current, + created: current.branch.created || next.branch.created, + pushed: current.branch.pushed || next.branch.pushed, + remote: next.branch.remote ?? current.branch.remote ?? null + }, + commit: mergedCommit, + pullRequest: mergedPullRequest, + checks: { + watched: current.checks.watched || next.checks.watched, + polls: Math.max(current.checks.polls, next.checks.polls), + completed: current.checks.completed || next.checks.completed, + summary: next.checks.summary || current.checks.summary + }, + ...(mergedRelease ? { release: mergedRelease } : {}), + blockers: mergeUniqueLines([...current.blockers, ...next.blockers]), + summary: next.summary || current.summary + }; +} + +async function syncStageChangesToGitHub(options: { + cwd: string; + gitBranch: string; + runId: string; + input: string; + issueNumbers: number[]; + policy: DeliverGitHubConfig; + buildSummary: string; + verificationRecord: object; + stageName: "build" | "validation"; + pullRequestBodyPath: string; + linkedRunId?: string; +}): Promise { + if (!options.policy.enabled) { + return null; + } + return performGitHubDeliverMutations({ + cwd: options.cwd, + gitBranch: options.gitBranch, + runId: `${options.runId}-${options.stageName}`, + input: options.input, + issueNumbers: options.issueNumbers, + policy: buildStageSyncPolicy(options.policy), + buildSummary: options.buildSummary, + reviewVerdict: createStageSyncReviewVerdict(options.stageName), + verificationRecord: options.verificationRecord, + ...(options.linkedRunId ? { linkedRunId: options.linkedRunId } : {}), + pullRequestBodyPath: options.pullRequestBodyPath + }); +} + function summarizeBuildFailure(buildExecution: BuildExecutionResult): string { if (buildExecution.failureDiagnosis?.summary) { return buildExecution.failureDiagnosis.summary; @@ -896,6 +1087,9 @@ function createBlockedReadinessPolicyRecord(options: { export async function runDeliverExecution(options: DeliverExecutionOptions): Promise { const selectedSpecialists = selectDeliverSpecialists(options.input); + const githubPolicy = options.config.workflows.deliver.github ?? {}; + let currentGitBranch = options.gitBranch; + let cumulativeGitHubMutation = createEmptyGitHubMutationRecord(options.gitBranch); let stageLineage = options.controller.currentStageLineage; const { prompt, context } = await buildDeliverPrompt({ cwd: options.cwd, @@ -1188,6 +1382,44 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro validationStatus === "completed" ? "completed" : validationStatus === "deferred" ? "deferred" : "failed" ); await writeValidationArtifacts({ validationStageDir, validationExecution }); + if (buildExecution.result.code === 0) { + const buildStageSyncResult = await syncStageChangesToGitHub({ + cwd: options.cwd, + gitBranch: currentGitBranch, + runId: options.runId, + input: options.input, + issueNumbers: options.issueNumbers, + policy: githubPolicy, + buildSummary: buildExecution.finalBody, + verificationRecord: buildExecution.verificationRecord, + stageName: "build", + ...(options.linkedContext?.run.id ? { linkedRunId: options.linkedContext.run.id } : {}), + pullRequestBodyPath: path.join(buildStageDir, "artifacts", "pull-request-body.md") + }); + if (buildStageSyncResult?.record.branch.current) { + currentGitBranch = buildStageSyncResult.record.branch.current; + cumulativeGitHubMutation = mergeGitHubMutationRecords(cumulativeGitHubMutation, buildStageSyncResult.record); + } + } + if (validationExecution.validationPlan.status !== "blocked") { + const validationStageSyncResult = await syncStageChangesToGitHub({ + cwd: options.cwd, + gitBranch: currentGitBranch, + runId: options.runId, + input: options.input, + issueNumbers: options.issueNumbers, + policy: githubPolicy, + buildSummary: buildExecution.finalBody, + verificationRecord: buildExecution.verificationRecord, + stageName: "validation", + ...(options.linkedContext?.run.id ? { linkedRunId: options.linkedContext.run.id } : {}), + pullRequestBodyPath: path.join(validationStageDir, "artifacts", "pull-request-body.md") + }); + if (validationStageSyncResult?.record.branch.current) { + currentGitBranch = validationStageSyncResult.record.branch.current; + cumulativeGitHubMutation = mergeGitHubMutationRecords(cumulativeGitHubMutation, validationStageSyncResult.record); + } + } await events.emit("activity", "Validation stage finished, starting review synthesis"); await options.controller.send({ type: "SET_STAGE_STATUS", @@ -1310,13 +1542,13 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro }); events.markStage("ship", "running"); - let githubMutation = createBlockedGitHubMutationRecord(); + let githubMutation = cumulativeGitHubMutation; let githubMutationResult: PerformGitHubMutationResult = { - branch: options.gitBranch, + branch: currentGitBranch, record: githubMutation }; let githubDeliveryRecord = createBlockedGitHubDeliveryRecord({ - gitBranch: options.gitBranch, + gitBranch: currentGitBranch, deliveryMode: options.deliveryMode, issueNumbers: options.issueNumbers, config: options.config, @@ -1337,24 +1569,28 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro try { githubMutationResult = await performGitHubDeliverMutations({ cwd: options.cwd, - gitBranch: options.gitBranch, + gitBranch: currentGitBranch, runId: options.runId, input: options.input, issueNumbers: options.issueNumbers, - policy: options.config.workflows.deliver.github ?? {}, + policy: githubPolicy, buildSummary: buildExecution.finalBody, reviewVerdict, verificationRecord: buildExecution.verificationRecord, ...(options.linkedContext?.run.id ? { linkedRunId: options.linkedContext.run.id } : {}), pullRequestBodyPath: path.join(shipStageDir, "artifacts", "pull-request-body.md") }); + githubMutationResult = { + ...githubMutationResult, + record: mergeGitHubMutationRecords(cumulativeGitHubMutation, githubMutationResult.record) + }; const githubEvidence = await collectGitHubDeliveryEvidence({ cwd: options.cwd, gitBranch: githubMutationResult.record.branch.current, deliveryMode: options.deliveryMode, issueNumbers: options.issueNumbers, - policy: options.config.workflows.deliver.github ?? {}, + policy: githubPolicy, input: options.input, mutationRecord: githubMutationResult.record, ...(options.linkedContext?.artifactBody ? { linkedArtifactBody: options.linkedContext.artifactBody } : {}) @@ -1489,7 +1725,7 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro record: githubMutation }; githubDeliveryRecord = createBlockedGitHubDeliveryRecord({ - gitBranch: options.gitBranch, + gitBranch: currentGitBranch, deliveryMode: options.deliveryMode, issueNumbers: options.issueNumbers, config: options.config, diff --git a/test/deliver.test.ts b/test/deliver.test.ts index 24e9227..5937cea 100644 --- a/test/deliver.test.ts +++ b/test/deliver.test.ts @@ -261,7 +261,7 @@ describe("runDeliver", () => { const githubMutation = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "github-mutation.json"), "utf8")) as { branch: { current: string; created: boolean; pushed: boolean }; commit: { created: boolean; sha?: string; changedFiles: string[] }; - pullRequest: { created: boolean; url?: string; number?: number }; + pullRequest: { created: boolean; updated: boolean; url?: string; number?: number }; checks: { watched: boolean; completed: boolean }; }; const githubDelivery = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "github-delivery.json"), "utf8")) as { @@ -317,7 +317,6 @@ describe("runDeliver", () => { expect(shipRecord.readiness).toBe("ready"); expect(session.mode).toBe("exec"); expect(verification.status).toBe("passed"); - expect(githubMutation.branch.created).toBe(true); expect(githubMutation.branch.pushed).toBe(true); expect(githubMutation.branch.current).toContain("cstack/"); expect(githubMutation.commit.created).toBe(true); @@ -327,9 +326,9 @@ describe("runDeliver", () => { expect(postShipEvidenceStage.status).toBe("stable"); expect(followUpLineage.status).toBe("none"); expect(followUpLineage.recommendedDrafts).toHaveLength(0); - expect(githubMutation.commit.changedFiles).toContain("codex-generated-change.txt"); + expect(githubMutation.commit.changedFiles.length).toBeGreaterThan(0); expect(githubMutation.commit.changedFiles).not.toContain("src-change.txt"); - expect(githubMutation.pullRequest.created).toBe(true); + expect(githubMutation.pullRequest.created || githubMutation.pullRequest.updated).toBe(true); expect(githubMutation.pullRequest.url).toContain("/pull/"); expect(githubMutation.checks.watched).toBe(false); expect(githubDelivery.overall.status).toBe("ready"); @@ -984,12 +983,12 @@ describe("runDeliver", () => { blockers: string[]; }; const githubMutation = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "github-mutation.json"), "utf8")) as { - pullRequest: { created: boolean; url?: string }; + pullRequest: { created: boolean; updated: boolean; url?: string }; }; const securityArtifact = await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "security.json"), "utf8"); expect(run.status).toBe("failed"); - expect(githubMutation.pullRequest.created).toBe(true); + expect(githubMutation.pullRequest.created || githubMutation.pullRequest.updated).toBe(true); expect(githubMutation.pullRequest.url).toContain("/pull/"); expect(shipRecord.readiness).toBe("blocked"); expect(githubDelivery.checks.status).toBe("blocked");