import { publishStepDisplayName } from './displayNames.js'; import type { PublishPlan, PublishPlanTarget, PublishStep, PublishStepKind, PublishStepStatus } from './index.js'; import { PUBLISH_STEP_SPINE } from './index.js'; /** Worst-first. When summarizing many steps into one status, first match wins. */ const STATUS_PRECEDENCE: PublishStepStatus[] = [ 'failed', 'blocked', 'action_required', 'stale', 'in_progress', 'pending', 'advisory', 'passed', 'skipped' ]; const CLEAR_STATUSES = new Set(['passed', 'advisory', 'skipped']); /** Collapse ordered steps into the single status shown on the plan. Empty = passed. */ export function rollUpPublishPlan(steps: PublishStep[]): PublishStepStatus { const present = new Set(steps.map((s) => s.status)); return STATUS_PRECEDENCE.find((s) => present.has(s)) ?? 'passed'; } /** Order steps by the fixed spine; unknown ids are appended in input order. */ export function orderPublishSteps(steps: PublishStep[]): PublishStep[] { const byId = new Map(steps.map((s) => [s.id, s])); const ordered: PublishStep[] = []; for (const id of PUBLISH_STEP_SPINE) { const step = byId.get(id); if (step) { ordered.push(step); byId.delete(id); } } for (const step of steps) { if (byId.has(step.id)) { ordered.push(step); byId.delete(step.id); } } return ordered; } function isClear(status: PublishStepStatus): boolean { return CLEAR_STATUSES.has(status); } /** * Kinds that execute concurrently and therefore must not appear as each other's * prerequisite. The SABS build and the policy agents both key off the commit and * run in parallel — policy agents analyze the committed code directly and do not * wait for the build artifact — so neither should show "Waiting on" the other in * the rail. They both still gate the final publish. */ const CONCURRENT_KINDS = new Set(['build', 'policy_gates']); /** An earlier unclear step gates a later step unless the two run concurrently. */ function earlierKindBlocks(earlier: PublishStepKind, current: PublishStepKind): boolean { return !(CONCURRENT_KINDS.has(earlier) && CONCURRENT_KINDS.has(current)); } /** * For each step that is not yet clear, set `blockedBy` to kinds of earlier * steps that are not clear and that actually gate it (concurrent steps such as * build and policy gates do not gate each other). Premature `in_progress` (e.g. * deploy already QUEUED while policy gates still run) is held as `pending` so * the rail does not show "Publishing…" ahead of unfinished prerequisites. Failed * and other terminal statuses are left alone. Clears blockedBy when the gating * steps clear. */ export function applyBlockedByEligibility(steps: PublishStep[]): PublishStep[] { // Kind set (not a list): multiple earlier steps can share a kind, but the // rail waits on kinds — blockedBy must not repeat the same kind. const unclearKinds = new Set(); return steps.map((step) => { const blockers = [...unclearKinds].filter((kind) => earlierKindBlocks(kind, step.kind)); if (blockers.length > 0 && (step.status === 'pending' || step.status === 'in_progress')) { const next: PublishStep = { ...step, status: 'pending', blockedBy: blockers, displayName: publishStepDisplayName(step.kind, 'pending') }; unclearKinds.add(step.kind); return next; } if (step.status === 'pending') { const rest = { ...step }; delete rest.blockedBy; unclearKinds.add(step.kind); return rest; } if (!isClear(step.status)) { unclearKinds.add(step.kind); } return step; }); } /** Order, apply eligibility, roll up. */ export function buildPublishPlan(target: PublishPlanTarget, steps: PublishStep[]): PublishPlan { const ordered = applyBlockedByEligibility(orderPublishSteps(steps)); return { status: rollUpPublishPlan(ordered), steps: ordered, target }; }