1import 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
9type EnvironmentName = "staging" | "production";
10type EnvironmentState = "on" | "deploying" | "failed";
11
12export type DeploymentMarkerTone = "default" | "pending" | "failed";
13
14export 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
30const RAIL_COMMIT_COUNT = 8;
31
32function hasStaging(deployments: MergebotDeployments) {
33 return deployments.stagingUrl !== null || deployments.staging !== null;
34}
35
36function hasProduction(deployments: MergebotDeployments) {
37 return deployments.productionUrl !== null || deployments.production !== null;
38}
39
40function liveSha(environment: MergebotDeploymentEnvironment | null) {
41 if (!environment) return null;
42 return environment.status === "passed" ? environment.sha : environment.deployedSha;
43}
44
45function 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 */
53function 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. */
64export 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. */
75export 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
88function environmentState(environment: MergebotDeploymentEnvironment | null): EnvironmentState {
89 if (environment?.status === "passed") return "on";
90 return environment?.status === "failed" ? "failed" : "deploying";
91}
92
93export 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"
104function 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
113function 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 */
123function 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
160function 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. */
190export 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 */
206export 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}