| 1 | import type { |
| 2 | MergebotDeploymentEnvironment, |
| 3 | MergebotDeployments, |
| 4 | MergebotMergedCommit, |
| 5 | } from "@t3tools/contracts"; |
| 6 | |
| 7 | // A port of Mergebot's deploy pane (www/src/components/deploys.tsx) as pure derivations. |
| 8 | |
| 9 | type EnvironmentName = "staging" | "production"; |
| 10 | type EnvironmentState = "on" | "deploying" | "failed"; |
| 11 | |
| 12 | export type DeploymentMarkerTone = "default" | "pending" | "failed"; |
| 13 | |
| 14 | export type DeploymentTimelineItem = |
| 15 | | { |
| 16 | kind: "marker"; |
| 17 | key: string; |
| 18 | label: string; |
| 19 | tone: DeploymentMarkerTone; |
| 20 | href: string | null; |
| 21 | } |
| 22 | | { |
| 23 | kind: "commit"; |
| 24 | key: string; |
| 25 | commit: MergebotMergedCommit; |
| 26 | tone: "deploying" | "hotfix" | "unshipped" | "default"; |
| 27 | isDeployCandidate: boolean; |
| 28 | }; |
| 29 | |
| 30 | const RAIL_COMMIT_COUNT = 8; |
| 31 | |
| 32 | function hasStaging(deployments: MergebotDeployments) { |
| 33 | return deployments.stagingUrl !== null || deployments.staging !== null; |
| 34 | } |
| 35 | |
| 36 | function hasProduction(deployments: MergebotDeployments) { |
| 37 | return deployments.productionUrl !== null || deployments.production !== null; |
| 38 | } |
| 39 | |
| 40 | function liveSha(environment: MergebotDeploymentEnvironment | null) { |
| 41 | if (!environment) return null; |
| 42 | return environment.status === "passed" ? environment.sha : environment.deployedSha; |
| 43 | } |
| 44 | |
| 45 | function indexOfSha(commits: readonly MergebotMergedCommit[], sha: string | null | undefined) { |
| 46 | return sha ? commits.findIndex((commit) => commit.sha === sha) : -1; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Where production stands: the deployment sha, else the production branch head. Off the listed |
| 51 | * window counts as older than every commit. |
| 52 | */ |
| 53 | function productionIndex(deployments: MergebotDeployments, sha: string | null | undefined) { |
| 54 | const commits = deployments.mergedCommits; |
| 55 | const deploymentIndex = indexOfSha(commits, sha); |
| 56 | const index = |
| 57 | deploymentIndex === -1 |
| 58 | ? commits.findIndex((commit) => commit.isProductionHead) |
| 59 | : deploymentIndex; |
| 60 | return index === -1 ? commits.length : index; |
| 61 | } |
| 62 | |
| 63 | /** Commits live on staging that production does not have yet. */ |
| 64 | export function stagingOnlyCount(deployments: MergebotDeployments): number { |
| 65 | if (!hasStaging(deployments) || deployments.productionBranch === null) return 0; |
| 66 | |
| 67 | const stagingIndex = indexOfSha(deployments.mergedCommits, liveSha(deployments.staging)); |
| 68 | if (stagingIndex === -1) return 0; |
| 69 | |
| 70 | const production = productionIndex(deployments, liveSha(deployments.production)); |
| 71 | return Math.max(0, production - stagingIndex); |
| 72 | } |
| 73 | |
| 74 | /** Worst state across every configured environment, for the sidebar status dot. */ |
| 75 | export function deploymentActivity( |
| 76 | found: readonly MergebotDeployments[], |
| 77 | ): "healthy" | "deploying" | "failed" { |
| 78 | const states = new Set( |
| 79 | found.flatMap((deployments) => [ |
| 80 | ...(hasStaging(deployments) ? [environmentState(deployments.staging)] : []), |
| 81 | ...(hasProduction(deployments) ? [environmentState(deployments.production)] : []), |
| 82 | ]), |
| 83 | ); |
| 84 | if (states.has("failed")) return "failed"; |
| 85 | return states.has("deploying") ? "deploying" : "healthy"; |
| 86 | } |
| 87 | |
| 88 | function environmentState(environment: MergebotDeploymentEnvironment | null): EnvironmentState { |
| 89 | if (environment?.status === "passed") return "on"; |
| 90 | return environment?.status === "failed" ? "failed" : "deploying"; |
| 91 | } |
| 92 | |
| 93 | export function formatEta(etaAt: string, now: number): string { |
| 94 | const minutes = Math.round((new Date(etaAt).getTime() - now) / 60_000); |
| 95 | if (minutes < 1) return "<1m"; |
| 96 | if (minutes < 60) return `~${minutes}m`; |
| 97 | const remainder = minutes % 60; |
| 98 | return remainder === 0 |
| 99 | ? `~${Math.floor(minutes / 60)}h` |
| 100 | : `~${Math.floor(minutes / 60)}h ${remainder}m`; |
| 101 | } |
| 102 | |
| 103 | // "deploying to staging · deploy_client_fargate · ~2m left" |
| 104 | function withCurrentStep( |
| 105 | label: string, |
| 106 | environment: MergebotDeploymentEnvironment | null, |
| 107 | now: number, |
| 108 | ) { |
| 109 | const withStep = environment?.currentStep ? `${label} · ${environment.currentStep}` : label; |
| 110 | return environment?.etaAt ? `${withStep} · ${formatEta(environment.etaAt, now)} left` : withStep; |
| 111 | } |
| 112 | |
| 113 | function environmentPhrase(state: EnvironmentState, names: EnvironmentName[]) { |
| 114 | const joined = names.join(" + "); |
| 115 | if (state === "on") return `on ${joined}`; |
| 116 | return state === "deploying" ? `deploying to ${joined}` : `failed deployment to ${joined}`; |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * One line for every environment anchored at the same commit. Matching states collapse with "+" |
| 121 | * ("on staging + production"); mixed states join with ", ". Links only for a single environment. |
| 122 | */ |
| 123 | function environmentHeader( |
| 124 | deployments: MergebotDeployments, |
| 125 | parts: Array<{ name: EnvironmentName; state: EnvironmentState }>, |
| 126 | now: number, |
| 127 | key: string, |
| 128 | ): DeploymentTimelineItem { |
| 129 | const label = (["on", "deploying", "failed"] as const) |
| 130 | .flatMap((state) => { |
| 131 | const names = parts.filter((part) => part.state === state).map((part) => part.name); |
| 132 | const [only] = names; |
| 133 | if (!only) return []; |
| 134 | |
| 135 | const phrase = environmentPhrase(state, names); |
| 136 | if (state === "deploying" && names.length === 1) { |
| 137 | return [withCurrentStep(phrase, deployments[only], now)]; |
| 138 | } |
| 139 | return [phrase]; |
| 140 | }) |
| 141 | .join(", "); |
| 142 | |
| 143 | const tone: DeploymentMarkerTone = parts.some((part) => part.state === "failed") |
| 144 | ? "failed" |
| 145 | : parts.some((part) => part.state === "deploying") |
| 146 | ? "pending" |
| 147 | : "default"; |
| 148 | |
| 149 | const [first, ...rest] = parts; |
| 150 | const href = |
| 151 | first && rest.length === 0 |
| 152 | ? first.name === "staging" |
| 153 | ? deployments.stagingUrl |
| 154 | : deployments.productionUrl |
| 155 | : null; |
| 156 | |
| 157 | return { kind: "marker", key, label, tone, href }; |
| 158 | } |
| 159 | |
| 160 | function environmentMarker( |
| 161 | deployments: MergebotDeployments, |
| 162 | name: EnvironmentName, |
| 163 | now: number, |
| 164 | key: string, |
| 165 | ): DeploymentTimelineItem { |
| 166 | const environment = deployments[name]; |
| 167 | const status = environment?.status ?? "pending"; |
| 168 | const checksUrl = environment?.checksUrl ?? null; |
| 169 | |
| 170 | if (status === "failed") { |
| 171 | return { |
| 172 | kind: "marker", |
| 173 | key, |
| 174 | label: `failed deployment to ${name}`, |
| 175 | tone: "failed", |
| 176 | href: checksUrl, |
| 177 | }; |
| 178 | } |
| 179 | |
| 180 | if (status === "passed") { |
| 181 | const href = name === "staging" ? deployments.stagingUrl : deployments.productionUrl; |
| 182 | return { kind: "marker", key, label: `on ${name}`, tone: "default", href }; |
| 183 | } |
| 184 | |
| 185 | const label = withCurrentStep(`deploying to ${name}`, environment, now); |
| 186 | return { kind: "marker", key, label, tone: "pending", href: checksUrl }; |
| 187 | } |
| 188 | |
| 189 | /** The rail: the latest merges, reaching back far enough to show where every environment stands. */ |
| 190 | export function railCommitCount(deployments: MergebotDeployments): number { |
| 191 | const anchors = new Set( |
| 192 | [deployments.staging, deployments.production].flatMap((environment) => |
| 193 | environment ? [environment.sha, environment.deployedSha] : [], |
| 194 | ), |
| 195 | ); |
| 196 | const oldestAnchor = deployments.mergedCommits.findLastIndex( |
| 197 | (commit) => commit.isProductionHead || anchors.has(commit.sha), |
| 198 | ); |
| 199 | return Math.max(RAIL_COMMIT_COUNT, oldestAnchor + 1); |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Environment markers interleaved with merged commits, newest first. `limit` truncates the |
| 204 | * commit list after placement so markers keep their positions. |
| 205 | */ |
| 206 | export function buildDeploymentTimeline( |
| 207 | deployments: MergebotDeployments, |
| 208 | now: number, |
| 209 | limit = Number.POSITIVE_INFINITY, |
| 210 | ): DeploymentTimelineItem[] { |
| 211 | const { staging, production, productionBranch, mergedCommits } = deployments; |
| 212 | const withStaging = hasStaging(deployments); |
| 213 | const withProduction = hasProduction(deployments); |
| 214 | const defaultBranch = deployments.defaultBranch || "default"; |
| 215 | |
| 216 | // Both environments passed on the same commit: one stacked header above everything. |
| 217 | const stacked = |
| 218 | withProduction && |
| 219 | staging?.status === "passed" && |
| 220 | production?.status === "passed" && |
| 221 | staging.sha === production.sha; |
| 222 | |
| 223 | const items: DeploymentTimelineItem[] = []; |
| 224 | if (!withStaging) { |
| 225 | if (productionBranch === null) { |
| 226 | items.push({ kind: "marker", key: "top", label: defaultBranch, tone: "default", href: null }); |
| 227 | } else if (mergedCommits.some((commit) => commit.status === "default")) { |
| 228 | const label = `${defaultBranch} (unshipped)`; |
| 229 | items.push({ kind: "marker", key: "top", label, tone: "default", href: null }); |
| 230 | } |
| 231 | } else if (stacked) { |
| 232 | const parts = [ |
| 233 | { name: "staging" as const, state: "on" as const }, |
| 234 | { name: "production" as const, state: "on" as const }, |
| 235 | ]; |
| 236 | items.push(environmentHeader(deployments, parts, now, "top")); |
| 237 | } else if (staging?.status === "passed") { |
| 238 | items.push(environmentMarker(deployments, "staging", now, "top")); |
| 239 | } |
| 240 | |
| 241 | // Deploy candidates sit newer than production's target and, with staging, no newer than what |
| 242 | // staging has live: fast-forwarding to any of them is valid. |
| 243 | const productionTarget = productionIndex(deployments, production?.sha); |
| 244 | const stagingLive = withStaging ? indexOfSha(mergedCommits, liveSha(staging)) : -1; |
| 245 | const candidateFrom = stagingLive === -1 ? 0 : stagingLive; |
| 246 | |
| 247 | const deployingFrom = staging?.status === "pending" ? indexOfSha(mergedCommits, staging.sha) : -1; |
| 248 | const deployingTo = indexOfSha(mergedCommits, staging?.deployedSha); |
| 249 | const isDeployingToStaging = (index: number) => |
| 250 | staging?.status === "pending" && |
| 251 | index >= (deployingFrom === -1 ? 0 : deployingFrom) && |
| 252 | index < (deployingTo === -1 ? mergedCommits.length : deployingTo); |
| 253 | |
| 254 | mergedCommits.slice(0, limit).forEach((commit, index) => { |
| 255 | const notPassed = (environment: MergebotDeploymentEnvironment | null) => |
| 256 | environment !== null && environment.status !== "passed"; |
| 257 | |
| 258 | if (withStaging && notPassed(staging) && staging?.sha === commit.sha) { |
| 259 | items.push(environmentMarker(deployments, "staging", now, `staging:${commit.sha}`)); |
| 260 | } |
| 261 | |
| 262 | const productionMarked = |
| 263 | !stacked && |
| 264 | productionBranch !== null && |
| 265 | (withProduction ? production?.sha === commit.sha : commit.isProductionHead); |
| 266 | |
| 267 | if (productionMarked && withProduction && production?.status !== "passed") { |
| 268 | items.push(environmentMarker(deployments, "production", now, `production:${commit.sha}`)); |
| 269 | } |
| 270 | if (productionMarked && !withProduction) { |
| 271 | const key = `production:${commit.sha}`; |
| 272 | items.push({ kind: "marker", key, label: "production", tone: "default", href: null }); |
| 273 | } |
| 274 | |
| 275 | const stagingLiveHere = |
| 276 | withStaging && notPassed(staging) && staging?.deployedSha === commit.sha; |
| 277 | const productionLiveHere = |
| 278 | (productionMarked && withProduction && production?.status === "passed") || |
| 279 | (withProduction && notPassed(production) && production?.deployedSha === commit.sha); |
| 280 | const live: EnvironmentName[] = [ |
| 281 | ...(stagingLiveHere ? (["staging"] as const) : []), |
| 282 | ...(productionLiveHere ? (["production"] as const) : []), |
| 283 | ]; |
| 284 | if (live.length > 0) { |
| 285 | const parts = live.map((name) => ({ name, state: "on" as const })); |
| 286 | items.push(environmentHeader(deployments, parts, now, `live:${commit.sha}`)); |
| 287 | } |
| 288 | |
| 289 | const tone = isDeployingToStaging(index) |
| 290 | ? "deploying" |
| 291 | : commit.status === "hotfix" |
| 292 | ? "hotfix" |
| 293 | : productionBranch !== null && commit.status === "default" |
| 294 | ? "unshipped" |
| 295 | : "default"; |
| 296 | |
| 297 | items.push({ |
| 298 | kind: "commit", |
| 299 | key: commit.sha, |
| 300 | commit, |
| 301 | tone, |
| 302 | isDeployCandidate: |
| 303 | productionBranch !== null && index >= candidateFrom && index < productionTarget, |
| 304 | }); |
| 305 | }); |
| 306 | |
| 307 | return items; |
| 308 | } |