import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { copyFile, mkdir, readFile, readdir, unlink, writeFile, } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import type { PullRequestSummary } from './platform'; import type { ReviewActionCommit } from './pr-metadata'; import { buildPrReviewArtifactStem } from './review-artifacts'; import { saveState as saveStateImpl } from './state'; import type { ReviewPolicyStageValue } from './config'; import type { DeliveryState, InternalReviewPatchCommit, ReviewOutcome, SubagentReviewOutcome, TicketState, } from './types'; type WorkflowContractCode = | 'workflow.advance.requires_reviewed_ticket' | 'workflow.open_pr.invalid_state' | 'workflow.open_pr.requires_post_verify' | 'workflow.post_verify.requires_post_red' | 'workflow.open_pr.requires_subagent_review' | 'workflow.worktree_guard.wrong_worktree'; type WorkflowContractError = Error & { code: WorkflowContractCode }; export function createWorkflowContractError( code: WorkflowContractCode, message: string, ): WorkflowContractError { return Object.assign(new Error(message), { code }); } export async function runOptionalDependencyHook( hook: ((...args: TArgs) => Promise | void) | undefined, ...args: TArgs ): Promise { if (!hook) { return; } await hook(...args); } function runOptionalDependencyHookSync( hook: ((...args: TArgs) => void) | undefined, ...args: TArgs ): void { hook?.(...args); } function validateInternalReviewPatchCommits(input: { outcome: ReviewOutcome | SubagentReviewOutcome; patchCommits: InternalReviewPatchCommit[] | undefined; stageLabel: string; }): void { const patchCount = input.patchCommits?.length ?? 0; if (input.outcome === 'patched' && patchCount === 0) { throw new Error( `${input.stageLabel} recorded as patched requires at least one patch commit.`, ); } if (input.outcome !== 'patched' && patchCount > 0) { throw new Error( `${input.stageLabel} patch commits are only allowed when outcome is \`patched\`.`, ); } } export function findNextPendingTicket( state: DeliveryState, ): TicketState | undefined { return state.tickets.find((ticket) => ticket.status === 'pending'); } export function findTicketByBranch( state: DeliveryState, branch: string, ): TicketState | undefined { return state.tickets.find((ticket) => ticket.branch === branch); } export function canAdvanceTicket(ticket: TicketState): boolean { return ( ticket.status === 'reviewed' && (ticket.reviewOutcome === 'clean' || ticket.reviewOutcome === 'patched' || ticket.reviewOutcome === 'skipped') ); } export function buildTicketHandoff( state: DeliveryState, ticket: Pick< TicketState, 'id' | 'title' | 'ticketFile' | 'branch' | 'baseBranch' | 'worktreePath' >, modifiedSectionsNote?: string, options?: { ticketBoundaryMode?: string; documentationPrefix?: string; }, ): string { const ticketIndex = state.tickets.findIndex( (candidate) => candidate.id === ticket.id, ); const previous = ticketIndex > 0 ? state.tickets[ticketIndex - 1] : undefined; const documentationPrefix = options?.documentationPrefix ?? ''; const requiredReads = [ `${documentationPrefix}docs/template/overview/start-here.md`, state.planPath, ticket.ticketFile, `${documentationPrefix}docs/template/delivery/delivery-orchestrator.md`, ]; const lines = [ '# Ticket Handoff', '', `Phase plan: ${state.planPath}`, `Ticket: ${ticket.id} ${ticket.title}`, `Branch: ${ticket.branch}`, `Base branch: ${ticket.baseBranch}`, `Worktree: ${ticket.worktreePath}`, '', '## Required Reads', '', ...requiredReads.map((path) => `- \`${path}\``), '', '## Context Reset Contract', '', '- Re-read the required docs before implementing.', '- Start from the current repository state and this handoff artifact, not from prior chat assumptions.', '- Carry forward only explicit review notes, review artifacts, and committed branch state.', '- Do not read ahead during the AI review wait window. The wait is free (LLM idle during subprocess sleep). Be sabaai sabaai.', ]; if (modifiedSectionsNote) { lines.push('', '## Modified Sections', ''); lines.push( 'Read only the file sections listed here — do not re-read full files.', ); lines.push(''); lines.push(modifiedSectionsNote); } if (previous) { lines.push('', '## Carry Forward From Previous Ticket', ''); lines.push(`- Previous ticket: \`${previous.id} ${previous.title}\``); lines.push(`- Previous branch: \`${previous.branch}\``); if (previous.prUrl) { lines.push(`- Previous PR: ${previous.prUrl}`); } if (previous.reviewOutcome) { lines.push(`- Review outcome: \`${previous.reviewOutcome}\``); } if (previous.reviewFetchArtifactPath ?? previous.reviewArtifactPath) { lines.push( `- Review fetch artifact: \`${previous.reviewFetchArtifactPath ?? previous.reviewArtifactPath}\``, ); } if (previous.reviewTriageArtifactPath ?? previous.reviewArtifactJsonPath) { lines.push( `- Review triage artifact: \`${previous.reviewTriageArtifactPath ?? previous.reviewArtifactJsonPath}\``, ); } } lines.push('', '## Stop Conditions', ''); lines.push( '- Stop if the current ticket cannot be completed safely or prerequisite state is missing.', ); lines.push( '- Stop if review triage is ambiguous enough to require user input.', ); lines.push( '- Stop if the work requires a broader redesign beyond the ticket scope.', ); if (options?.ticketBoundaryMode === 'gated') { lines.push('', '## RESUME COMMAND', ''); lines.push(`\`bun run deliver --plan ${state.planPath} start\``); } return lines.join('\n') + '\n'; } export async function writeTicketHandoff( state: DeliveryState, cwd: string, ticketId: string, dependencies: { relativeToRepo: (cwd: string, absolutePath: string) => string; subagentReviewPolicy?: string; ticketBoundaryMode?: string; }, ): Promise<{ relativePath: string; generatedAt: string }> { const ticket = state.tickets.find((candidate) => candidate.id === ticketId); if (!ticket) { throw new Error(`Unknown ticket ${ticketId}.`); } const absolutePath = resolve( cwd, state.handoffsDirPath, `${ticket.id.toLowerCase().replace('.', '-')}-handoff.md`, ); const generatedAt = new Date().toISOString(); const modifiedSectionsNote = await extractScopeSection( resolve(cwd, ticket.ticketFile), ); await mkdir(dirname(absolutePath), { recursive: true }); await writeFile( absolutePath, buildTicketHandoff(state, ticket, modifiedSectionsNote, { ticketBoundaryMode: dependencies.ticketBoundaryMode, documentationPrefix: existsSync(resolve(cwd, '.son-of-anton')) ? '.son-of-anton/' : '', }), 'utf8', ); return { relativePath: dependencies.relativeToRepo(cwd, absolutePath), generatedAt, }; } async function extractScopeSection( ticketFilePath: string, ): Promise { try { const content = await readFile(ticketFilePath, 'utf8'); const lines = content.split('\n'); const startIdx = lines.findIndex((line) => /^##\s+Scope/.test(line)); if (startIdx === -1) { return undefined; } const sectionLines: string[] = []; for (let i = startIdx + 1; i < lines.length; i++) { const line = lines[i]!; if (/^##\s/.test(line)) { break; } sectionLines.push(line); } const body = sectionLines.join('\n').trim(); return body.length > 0 ? body : undefined; } catch { return undefined; } } export async function startTicket( state: DeliveryState, cwd: string, ticketId: string | undefined, dependencies: { addWorktree: ( cwd: string, worktreePath: string, branch: string, baseBranch: string, ) => void; bootstrapWorktreeIfNeeded: (worktreePath: string) => Promise; copyLocalBootstrapFilesIfPresent: ( sourceWorktreePath: string, targetWorktreePath: string, ) => Promise; materializeTicketContext?: ( state: DeliveryState, sourceWorktreePath: string, ticketId: string, ) => Promise; relativeToRepo: (cwd: string, absolutePath: string) => string; subagentReviewPolicy?: string; ticketBoundaryMode?: string; }, ): Promise { const active = state.tickets.find( (ticket) => ticket.status === 'in_progress', ); if (active && ticketId && active.id !== ticketId) { throw new Error(`Ticket ${active.id} is already in progress.`); } const target = (ticketId ? state.tickets.find((ticket) => ticket.id === ticketId) : (active ?? findNextPendingTicket(state))) ?? undefined; if (!target) { throw new Error('No pending ticket found.'); } const targetIndex = state.tickets.findIndex( (ticket) => ticket.id === target.id, ); const previous = targetIndex > 0 ? state.tickets[targetIndex - 1] : undefined; if (previous && previous.status !== 'done') { throw new Error( `Cannot start ${target.id} before ${previous.id} is marked done.`, ); } if (target.status === 'in_progress') { await runOptionalDependencyHook( dependencies.materializeTicketContext, state, cwd, target.id, ); return state; } if (!existsSync(target.worktreePath)) { dependencies.addWorktree( cwd, target.worktreePath, target.branch, target.baseBranch, ); } await dependencies.copyLocalBootstrapFilesIfPresent(cwd, target.worktreePath); await dependencies.bootstrapWorktreeIfNeeded(target.worktreePath); const handoff = await writeTicketHandoff(state, cwd, target.id, { relativeToRepo: dependencies.relativeToRepo, subagentReviewPolicy: dependencies.subagentReviewPolicy, ticketBoundaryMode: dependencies.ticketBoundaryMode, }); const nextState: DeliveryState = { ...state, tickets: state.tickets.map((ticket) => ticket.id === target.id ? { ...ticket, status: 'in_progress' as const, handoffPath: handoff.relativePath, handoffGeneratedAt: handoff.generatedAt, } : ticket, ), }; await runOptionalDependencyHook( dependencies.materializeTicketContext, nextState, cwd, target.id, ); return nextState; } export function recordPostVerify( state: DeliveryState, ticketId?: string, outcome?: ReviewOutcome, patchCommits?: InternalReviewPatchCommit[], now: () => string = () => new Date().toISOString(), ): DeliveryState { const target = (ticketId ? state.tickets.find((ticket) => ticket.id === ticketId) : (state.tickets.find((ticket) => ticket.status === 'red_complete') ?? state.tickets.find((ticket) => ticket.status === 'in_progress'))) ?? undefined; if (!target) { throw new Error('No in-progress ticket found to mark verified.'); } if (target.status === 'verified') { return state; } if (target.status !== 'in_progress' && target.status !== 'red_complete') { throw new Error( `Ticket ${target.id} must be in progress or red_complete before post-verify can be recorded.`, ); } const completedAt = now(); const resolvedOutcome: ReviewOutcome = outcome ?? 'clean'; validateInternalReviewPatchCommits({ outcome: resolvedOutcome, patchCommits, stageLabel: 'Post-verify', }); return { ...state, tickets: state.tickets.map((ticket) => ticket.id === target.id ? { ...ticket, status: 'verified' as const, verifiedAt: completedAt, verifyOutcome: resolvedOutcome, verifyPatchCommits: patchCommits, } : ticket, ), }; } export function recordPostRed( state: DeliveryState, input: { headSha: string; latestCommitSubject: string; ticketId?: string; verifyExitCode: number; }, ): DeliveryState { const target = (input.ticketId ? state.tickets.find((ticket) => ticket.id === input.ticketId) : state.tickets.find((ticket) => ticket.status === 'in_progress')) ?? undefined; if (!target) { throw new Error('No in-progress ticket found to mark red_complete.'); } if (target.status === 'red_complete') { return state; } if (target.status !== 'in_progress') { throw new Error( `Ticket ${target.id} must be in progress before post-red can be recorded.`, ); } if (!input.latestCommitSubject.includes('[red]')) { throw new Error( `Ticket ${target.id} post-red requires a HEAD commit subject containing \`[red]\`. ` + `Either author a \`[red]\` commit before continuing, or declare \`Red: skip\` in the ticket metadata if the ticket has no testable behavior.`, ); } if (input.verifyExitCode === 0) { throw new Error( `Ticket ${target.id} post-red requires a failing verification run before delivery can advance. ` + `Either author a \`[red]\` commit (with a test that genuinely fails) before continuing, or declare \`Red: skip\` in the ticket metadata if the ticket has no testable behavior.`, ); } return { ...state, tickets: state.tickets.map((ticket) => ticket.id === target.id ? { ...ticket, status: 'red_complete' as const, redCommitSha: input.headSha, } : ticket, ), }; } export function recordSubagentReview( state: DeliveryState, outcome?: 'clean' | 'patched' | 'skipped', isDocOnly?: boolean, policy: ReviewPolicyStageValue = 'skip_doc_only', patchCommits?: InternalReviewPatchCommit[], agentName?: string, now: () => string = () => new Date().toISOString(), ticketId?: string, artifactPath?: string, ): DeliveryState { const target = (ticketId ? state.tickets.find((ticket) => ticket.id === ticketId) : state.tickets.find((ticket) => ticket.status === 'verified')) ?? undefined; if (!target) { throw new Error( ticketId ? `Unknown ticket ${ticketId}.` : 'No ticket at verified status found to record subagent review.', ); } if (target.status !== 'verified') { throw new Error( `Ticket ${target.id} must be at verified status before subagent review can be recorded.`, ); } const docOnly = isDocOnly ?? !!target.docOnly; let resolvedOutcome: SubagentReviewOutcome; if (outcome === 'skipped' || (policy === 'skip_doc_only' && docOnly)) { if (outcome === 'skipped' && policy === 'required') { throw new Error( `Ticket ${target.id} subagentReview is set to "required" — cannot record skipped. Pass \`clean\` or \`patched\`.`, ); } resolvedOutcome = 'skipped'; } else if (outcome === 'clean' || outcome === 'patched') { resolvedOutcome = outcome; } else { throw new Error( `Ticket ${target.id} requires a subagent review outcome. Pass \`clean\`, \`patched\`, or \`skipped\`.`, ); } validateInternalReviewPatchCommits({ outcome: resolvedOutcome, patchCommits, stageLabel: 'Subagent review', }); const completedAt = now(); return { ...state, tickets: state.tickets.map((ticket) => ticket.id === target.id ? { ...ticket, status: 'subagent_review_complete' as const, subagentReviewOutcome: resolvedOutcome, subagentReviewCompletedAt: completedAt, subagentReviewPatchCommits: patchCommits, subagentReviewAgent: agentName, subagentRunnerArtifactPath: artifactPath, } : ticket, ), }; } export function openPullRequest( state: DeliveryState, cwd: string, ticketId: string | undefined, dependencies: { assertReviewerFacingMarkdown: (markdown: string) => void; buildPullRequestBody: ( state: DeliveryState, ticket: TicketState, options?: { actionCommits?: ReviewActionCommit[]; currentHeadSha?: string; githubRepo?: { defaultBranch: string; name: string; owner: string }; }, ) => string; buildPullRequestTitle: ( ticket: Pick< TicketState, 'id' | 'title' | 'ticketFile' | 'type' | 'scope' >, ) => string; subagentReviewPolicy?: ReviewPolicyStageValue; createPullRequest: ( cwd: string, options: { base: string; body: string; head: string; title: string; }, ) => { number: number; url: string }; editPullRequest: ( cwd: string, prNumber: number, options: { base?: string; body?: string; title?: string; }, ) => void; ensureBranchPushed: (cwd: string, branch: string) => void; findOpenPullRequest: ( cwd: string, branch: string, ) => PullRequestSummary | undefined; reportProgress?: (message: string) => void; resolveGitHubRepo?: ( cwd: string, ) => { defaultBranch: string; name: string; owner: string } | undefined; }, ): DeliveryState { const target = (ticketId ? state.tickets.find((ticket) => ticket.id === ticketId) : (state.tickets.find( (ticket) => ticket.status === 'subagent_review_complete', ) ?? state.tickets.find((ticket) => ticket.status === 'red_complete') ?? state.tickets.find((ticket) => ticket.status === 'verified') ?? state.tickets.find((ticket) => ticket.status === 'in_review') ?? state.tickets.find((ticket) => ticket.status === 'in_progress'))) ?? undefined; if (!target) { throw createWorkflowContractError( 'workflow.open_pr.invalid_state', 'No ticket in a PR-openable state found to open as a PR.', ); } if (target.status === 'in_progress' || target.status === 'red_complete') { throw createWorkflowContractError( 'workflow.open_pr.requires_post_verify', `Ticket ${target.id} is at status ${target.status}. Complete post-verify before opening a PR.`, ); } if ( target.status === 'verified' && dependencies.subagentReviewPolicy !== undefined && dependencies.subagentReviewPolicy !== 'disabled' ) { throw createWorkflowContractError( 'workflow.open_pr.requires_subagent_review', `Ticket ${target.id} is at status verified and requires subagent-review before opening a PR. Run \`bun run deliver --plan ${state.planPath} subagent-review \` after completing the subagent review step. If the subagent is unavailable, set subagentReview to "disabled" in orchestrator.config.json to bypass.`, ); } if ( target.status !== 'verified' && target.status !== 'subagent_review_complete' && target.status !== 'in_review' ) { throw createWorkflowContractError( 'workflow.open_pr.invalid_state', `Ticket ${target.id} is not in a PR-openable state. Current status: ${target.status}.`, ); } runOptionalDependencyHookSync( dependencies.reportProgress, `open-pr: publishing branch ${target.branch} to origin (push hooks may take a bit)...`, ); dependencies.ensureBranchPushed(target.worktreePath, target.branch); const title = dependencies.buildPullRequestTitle(target); const body = dependencies.buildPullRequestBody(state, target, { githubRepo: dependencies.resolveGitHubRepo?.(target.worktreePath), }); dependencies.assertReviewerFacingMarkdown(body); const existingPullRequest = dependencies.findOpenPullRequest( target.worktreePath, target.branch, ); let prUrl: string; let prNumber: number; if (existingPullRequest) { runOptionalDependencyHookSync( dependencies.reportProgress, `open-pr: updating PR #${existingPullRequest.number} on GitHub...`, ); dependencies.editPullRequest( target.worktreePath, existingPullRequest.number, { base: target.baseBranch, body, title, }, ); prUrl = existingPullRequest.url; prNumber = existingPullRequest.number; } else { runOptionalDependencyHookSync( dependencies.reportProgress, 'open-pr: creating PR on GitHub...', ); const pullRequest = dependencies.createPullRequest(target.worktreePath, { base: target.baseBranch, body, head: target.branch, title, }); prUrl = pullRequest.url; prNumber = pullRequest.number; } runOptionalDependencyHookSync( dependencies.reportProgress, `open-pr: PR ready ${prUrl}`, ); const now = new Date().toISOString(); return { ...state, tickets: state.tickets.map((ticket) => ticket.id === target.id ? { ...ticket, status: 'in_review', prUrl, prNumber, prOpenedAt: ticket.prOpenedAt ?? now, } : ticket, ), }; } export async function advanceToNextTicket( state: DeliveryState, cwd: string, dependencies: { ensureBranchPushed?: (cwd: string, branch: string) => void; updatePullRequestBody: (state: DeliveryState, ticket: TicketState) => void; }, ): Promise { const current = state.tickets.find((ticket) => ticket.status === 'reviewed'); if (!current) { const activeTicket = state.tickets.find((t) => t.status === 'in_progress') ?? state.tickets.find((t) => t.status === 'verified') ?? state.tickets.find((t) => t.status === 'subagent_review_complete') ?? state.tickets.find((t) => t.status === 'in_review') ?? state.tickets.find((t) => t.status === 'needs_patch') ?? state.tickets.find((t) => t.status === 'operator_input_needed'); if (activeTicket) { throw createWorkflowContractError( 'workflow.advance.requires_reviewed_ticket', `No reviewed ticket is ready to advance. Active ticket ${activeTicket.id} is at status ${activeTicket.status}.`, ); } throw createWorkflowContractError( 'workflow.advance.requires_reviewed_ticket', 'No reviewed ticket is ready to advance.', ); } if (!canAdvanceTicket(current)) { throw new Error( `Ticket ${current.id} cannot advance until review is recorded as clean, patched, or skipped.`, ); } dependencies.ensureBranchPushed?.(current.worktreePath, current.branch); dependencies.updatePullRequestBody(state, current); return { ...state, tickets: state.tickets.map((ticket) => ticket.id === current.id ? { ...ticket, status: 'done' } : ticket, ), }; } function ticketHandoffFileName(ticketId: string): string { return `${ticketId.toLowerCase().replace('.', '-')}-handoff.md`; } async function copyFileIntoWorktree( sourcePath: string, targetPath: string, ): Promise { if (!existsSync(sourcePath) || resolve(sourcePath) === resolve(targetPath)) { return; } await mkdir(dirname(targetPath), { recursive: true }); await copyFile(sourcePath, targetPath); } function listTrackedFileNamesInDir( worktreePath: string, relativeDirPath: string, ): Set { const result = spawnSync('git', ['ls-files', '--', relativeDirPath], { cwd: worktreePath, encoding: 'utf8', }); if (result.status !== 0 || result.stdout.trim().length === 0) { return new Set(); } const tracked = new Set(); for (const line of result.stdout.split('\n')) { const trimmed = line.trim(); if (trimmed.length === 0) { continue; } tracked.add(basename(trimmed)); } return tracked; } async function copyTicketScopedArtifacts(input: { artifactDirPath: string; artifactNames: Set; sourceWorktreePath: string; targetWorktreePath: string; }): Promise { const sourceDir = resolve(input.sourceWorktreePath, input.artifactDirPath); if (!existsSync(sourceDir) || input.artifactNames.size === 0) { return; } const targetDir = resolve(input.targetWorktreePath, input.artifactDirPath); const trackedInTarget = listTrackedFileNamesInDir( input.targetWorktreePath, input.artifactDirPath, ); if (existsSync(targetDir)) { for (const fileName of await readdir(targetDir)) { if ( !input.artifactNames.has(fileName) && !trackedInTarget.has(fileName) ) { await unlink(resolve(targetDir, fileName)); } } } for (const fileName of await readdir(sourceDir)) { if (!input.artifactNames.has(fileName)) { continue; } await copyFileIntoWorktree( resolve(sourceDir, fileName), resolve(input.targetWorktreePath, input.artifactDirPath, fileName), ); } } export async function materializeTicketContext( state: DeliveryState, sourceWorktreePath: string, ticketId: string, ): Promise { const targetIndex = state.tickets.findIndex( (ticket) => ticket.id === ticketId, ); const target = targetIndex >= 0 ? state.tickets[targetIndex] : undefined; if (!target) { throw new Error(`Unknown ticket ${ticketId}.`); } const previous = targetIndex > 0 ? state.tickets[targetIndex - 1] : undefined; await saveStateImpl(target.worktreePath, state); const handoffNames = new Set([ ticketHandoffFileName(target.id), ...(previous ? [ticketHandoffFileName(previous.id)] : []), ]); const reviewNames = new Set(); const scopedTickets = [target, previous].filter( (ticket): ticket is TicketState => ticket !== undefined, ); for (const ticket of scopedTickets) { const artifactStem = buildPrReviewArtifactStem(ticket.id); for (const fileName of [ `${artifactStem}.fetch.json`, `${artifactStem}.triage.json`, ]) { reviewNames.add(fileName); } } await copyTicketScopedArtifacts({ artifactDirPath: state.handoffsDirPath, artifactNames: handoffNames, sourceWorktreePath, targetWorktreePath: target.worktreePath, }); await copyTicketScopedArtifacts({ artifactDirPath: state.reviewsDirPath, artifactNames: reviewNames, sourceWorktreePath, targetWorktreePath: target.worktreePath, }); } export function restackTicket( state: DeliveryState, cwd: string, ticketId: string | undefined, dependencies: { buildPullRequestBody: ( state: DeliveryState, ticket: TicketState, options?: { actionCommits?: ReviewActionCommit[]; currentHeadSha?: string; githubRepo?: { defaultBranch: string; name: string; owner: string }; }, ) => string; defaultBranch: string; deliveryBaseBranch: string; editPullRequest: ( cwd: string, prNumber: number, options: { base?: string; body?: string; title?: string; }, ) => void; ensureCleanWorktree: (cwd: string) => void; fetchOrigin: (cwd: string) => void; findOpenPullRequest: ( cwd: string, branch: string, ) => PullRequestSummary | undefined; hasMergedPullRequestForBranch: (cwd: string, branch: string) => boolean; readCurrentBranch: (cwd: string) => string; readMergeBase: ( cwd: string, branch: string, previousBranch: string, ) => string; rebaseOnto: (cwd: string, rebaseTarget: string, oldBase: string) => void; rebaseOntoDefaultBranch: (cwd: string, defaultBranch: string) => void; resolveGitHubRepo?: ( cwd: string, ) => { defaultBranch: string; name: string; owner: string } | undefined; }, ): DeliveryState { dependencies.ensureCleanWorktree(cwd); const currentBranch = dependencies.readCurrentBranch(cwd); const target = (ticketId ? state.tickets.find((ticket) => ticket.id === ticketId) : findTicketByBranch(state, currentBranch)) ?? undefined; if (!target) { throw new Error( ticketId ? `Unknown ticket ${ticketId}.` : `Current branch ${currentBranch} is not tracked by the delivery plan.`, ); } if (target.branch !== currentBranch) { throw new Error( `Restack must run from ${target.branch}. Current branch is ${currentBranch}.`, ); } dependencies.fetchOrigin(cwd); const targetIndex = state.tickets.findIndex( (ticket) => ticket.id === target.id, ); const previous = targetIndex > 0 ? state.tickets[targetIndex - 1] : undefined; let nextBaseBranch = dependencies.deliveryBaseBranch; let rebaseTarget = `origin/${dependencies.deliveryBaseBranch}`; if (previous) { const oldBase = dependencies.readMergeBase( cwd, target.branch, previous.branch, ); if (!oldBase) { throw new Error( `Could not determine the shared ancestor between ${target.branch} and ${previous.branch}.`, ); } if (!dependencies.hasMergedPullRequestForBranch(cwd, previous.branch)) { nextBaseBranch = previous.branch; rebaseTarget = previous.branch; } dependencies.rebaseOnto(cwd, rebaseTarget, oldBase); } else { dependencies.rebaseOntoDefaultBranch(cwd, dependencies.deliveryBaseBranch); } const nextState: DeliveryState = { ...state, tickets: state.tickets.map((ticket) => ticket.id === target.id ? { ...ticket, baseBranch: nextBaseBranch, } : ticket, ), }; const updatedTarget = nextState.tickets.find( (ticket) => ticket.id === target.id, ); if (!updatedTarget) { throw new Error(`Unknown ticket ${target.id}.`); } const pullRequest = dependencies.findOpenPullRequest(cwd, target.branch); if (pullRequest) { dependencies.editPullRequest(cwd, pullRequest.number, { base: nextBaseBranch, body: dependencies.buildPullRequestBody(nextState, updatedTarget, { githubRepo: dependencies.resolveGitHubRepo?.(cwd), }), }); } return nextState; }