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<TArgs extends unknown[]>(
hook: ((...args: TArgs) => Promise<void> | void) | undefined,
...args: TArgs
): Promise<void> {
if (!hook) {
return;
}
await hook(...args);
}
function runOptionalDependencyHookSync<TArgs extends unknown[]>(
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; subagentReviewPolicy?: string },
): string {
const ticketIndex = state.tickets.findIndex(
(candidate) => candidate.id === ticket.id,
);
const previous = ticketIndex > 0 ? state.tickets[ticketIndex - 1] : undefined;
const requiredReads = [
'docs/template/overview/start-here.md',
state.planPath,
ticket.ticketFile,
'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') {
const nextCommand =
options.subagentReviewPolicy === 'disabled' ||
options.subagentReviewPolicy === undefined
? 'open-pr'
: 'subagent-review';
lines.push('', '## RESUME COMMAND', '');
lines.push(`\`bun run deliver --plan ${state.planPath} ${nextCommand}\``);
}
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,
subagentReviewPolicy: dependencies.subagentReviewPolicy,
}),
'utf8',
);
return {
relativePath: dependencies.relativeToRepo(cwd, absolutePath),
generatedAt,
};
}
async function extractScopeSection(
ticketFilePath: string,
): Promise<string | undefined> {
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<void>;
copyLocalBootstrapFilesIfPresent: (
sourceWorktreePath: string,
targetWorktreePath: string,
) => Promise<void>;
materializeTicketContext?: (
state: DeliveryState,
sourceWorktreePath: string,
ticketId: string,
) => Promise<void>;
relativeToRepo: (cwd: string, absolutePath: string) => string;
subagentReviewPolicy?: string;
ticketBoundaryMode?: string;
},
): Promise<DeliveryState> {
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 <clean|patched|skipped>\` 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<DeliveryState> {
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<void> {
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<string> {
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<string>();
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<string>;
sourceWorktreePath: string;
targetWorktreePath: string;
}): Promise<void> {
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<void> {
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<string>([
ticketHandoffFileName(target.id),
...(previous ? [ticketHandoffFileName(previous.id)] : []),
]);
const reviewNames = new Set<string>();
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;
}
tools/delivery
ticket-flow.ts
tools/delivery/ticket-flow.ts