Skip to content
225 linesCodeBlameRaw
1/**
2 * Builds as the deployments pages show them: why one failed, in plain
3 * words, and a run of the same failure as one row; and a repository's
4 * deployments wherever they run: states, environments and the list's filter.
5 */
6import type {
7 Deployment,
8 DeploymentEnvironment,
9 DeploymentFilter,
10 DeploymentSource,
11 DeploymentState,
12 DeploymentStatus,
13 RepoDeployment,
14} from "@g1t/contracts";
15
16/** What git says when the commit asked for is not in the repository (as services/deployments/src/retries.ts). */
17const MISSING_COMMIT = /reference is not a tree|not our ref|bad object|unknown revision|no such commit/i;
18
19/**
20 * Why a build failed or was skipped, as the page says it. A commit that is
21 * gone (force-pushed over, or its branch deleted) is said plainly; older
22 * builds still carry git's own words, which stay in the build log.
23 */
24export function buildError(build: Pick<Deployment, "kind" | "number" | "error">): string | null {
25 if (!build.error || !MISSING_COMMIT.test(build.error)) return build.error;
26 if (build.kind === "preview" && build.number != null) {
27 return `This pull request's commit no longer exists. Push again, or close pull request #${build.number}.`;
28 }
29 if (build.kind === "preview") return "This branch's commit no longer exists. Push to the branch again.";
30 return "This commit no longer exists in the repository. Push to the default branch again.";
31}
32
33/** A build, and how many times in a row before it its app failed the same way. */
34export type BuildGroup<T> = {
35 /** The newest of them, the one the row links to. */
36 build: T;
37 /** 1 for a build on its own. */
38 count: number;
39 /** When the oldest of them started. */
40 firstAt: string;
41};
42
43/** The app a build is for: production, or one branch's preview. */
44const appOf = (build: Pick<Deployment, "kind" | "branch">) => `${build.kind}/${build.branch ?? ""}`;
45
46/**
47 * Folds each run of identical failures of one app (production, or one
48 * branch's preview), newest first as listed, into the row of its newest:
49 * the same error, with no other outcome for that app in between. Builds
50 * of other apps between them do not break the run; any other build of
51 * the same app does.
52 */
53export function groupBuilds<T extends Pick<Deployment, "kind" | "branch" | "number" | "status" | "error" | "createdAt">>(
54 builds: T[],
55): BuildGroup<T>[] {
56 const groups: BuildGroup<T>[] = [];
57 // Each app's run still open, by the error it failed with.
58 const open = new Map<string, { group: BuildGroup<T>; error: string | null }>();
59 for (const build of builds) {
60 const app = appOf(build);
61 const run = open.get(app);
62 const error = buildError(build);
63 if (build.status === "failed" && run && run.error === error) {
64 run.group.count++;
65 run.group.firstAt = build.createdAt;
66 continue;
67 }
68 const group = { build, count: 1, firstAt: build.createdAt };
69 groups.push(group);
70 if (build.status === "failed") open.set(app, { group, error });
71 else open.delete(app);
72 }
73 return groups;
74}
75
76// --- A repository's deployments, wherever they run ---------------------------
77
78/** Every state a deployment can be in, in the order the filter lists them. */
79export const STATES: readonly DeploymentState[] = ["success", "failure", "error", "in_progress", "queued", "inactive"];
80
81/** Every way a deployment can be made. */
82export const SOURCES: readonly DeploymentSource[] = ["actions", "g1t_page", "api"];
83
84/** A state in a word. */
85export const STATE_WORD: Record<DeploymentState, string> = {
86 queued: "Queued",
87 in_progress: "Deploying",
88 success: "Deployed",
89 failure: "Failed",
90 error: "Error",
91 inactive: "Inactive",
92};
93
94/** What made a deployment, as the site names it. */
95export const SOURCE_LABEL: Record<DeploymentSource, string> = {
96 actions: "g1t Actions",
97 g1t_page: "g1t.page",
98 api: "API",
99};
100
101/** An environment's name as a heading: `production` reads Production. */
102export function environmentLabel(name: string): string {
103 return name ? name.charAt(0).toUpperCase() + name.slice(1) : name;
104}
105
106/** A commit as the site shows it. */
107export function shortSha(sha: string): string {
108 return sha.slice(0, 7);
109}
110
111/** A g1t.page build, which also has a build log and the g1t.page controls. */
112export function isPageBuild(id: string): boolean {
113 return id.startsWith("dpl_");
114}
115
116/** Where an environment is served now: its own address, or its current deployment's. */
117export function environmentUrl(env: Pick<DeploymentEnvironment, "url" | "current" | "latest">): string | null {
118 return env.url ?? env.current?.environment_url ?? (env.latest?.state === "success" ? env.latest.environment_url : null);
119}
120
121type Ordered = Pick<DeploymentEnvironment, "name" | "production_environment" | "transient_environment" | "updated_at">;
122
123/**
124 * Environments as the pages list them: production ones first (the one
125 * named `production` before the rest), then lasting ones, then transient
126 * ones such as previews; in each, the most recently deployed first.
127 */
128export function orderEnvironments<T extends Ordered>(environments: readonly T[]): T[] {
129 const rank = (env: T) => (env.production_environment ? (env.name === "production" ? 0 : 1) : env.transient_environment ? 3 : 2);
130 return [...environments].sort(
131 (a, b) => rank(a) - rank(b) || Date.parse(b.updated_at) - Date.parse(a.updated_at) || a.name.localeCompare(b.name),
132 );
133}
134
135/**
136 * The environment that is production, for the overview: one marked as
137 * production that has had a deployment, the one named `production` first.
138 */
139export function productionEnvironment<T extends Ordered & Pick<DeploymentEnvironment, "latest">>(environments: readonly T[]): T | null {
140 return orderEnvironments(environments).find((env) => env.production_environment && env.latest != null) ?? null;
141}
142
143/** The filters the list's address can carry, besides the page. */
144export const FILTER_KEYS = ["environment", "state", "source", "creator", "ref"] as const;
145export type FilterKey = (typeof FILTER_KEYS)[number];
146
147/** Deployments on a page of the list, unless the address says otherwise. */
148export const PER_PAGE = 30;
149
150export type ListFilter = Required<Pick<DeploymentFilter, FilterKey>> & { page: number; per_page: number };
151
152/** The list's filter from a page's query, ignoring anything it cannot be. */
153export function parseFilter(query: URLSearchParams): ListFilter {
154 const text = (key: string) => query.get(key)?.trim() || null;
155 const state = text("state");
156 const source = text("source");
157 const page = Number.parseInt(query.get("page") ?? "", 10);
158 const perPage = Number.parseInt(query.get("per_page") ?? "", 10);
159 return {
160 environment: text("environment"),
161 state: STATES.includes(state as DeploymentState) ? (state as DeploymentState) : null,
162 source: SOURCES.includes(source as DeploymentSource) ? (source as DeploymentSource) : null,
163 creator: text("creator"),
164 ref: text("ref"),
165 page: Number.isFinite(page) && page > 0 ? page : 1,
166 per_page: Number.isFinite(perPage) ? Math.min(100, Math.max(1, perPage)) : PER_PAGE,
167 };
168}
169
170/** Whether any filter narrows the list. */
171export function isFiltered(filter: Pick<DeploymentFilter, FilterKey>): boolean {
172 return FILTER_KEYS.some((key) => filter[key] != null && filter[key] !== "");
173}
174
175/**
176 * The address of the list with one filter changed (null clears it), from
177 * its first page; or, for `page`, the same list on another page.
178 */
179export function withFilter(path: string, filter: ListFilter, key: FilterKey | "page", value: string | number | null): string {
180 const query = new URLSearchParams();
181 for (const each of FILTER_KEYS) {
182 const kept = each === key ? value : filter[each];
183 if (kept != null && kept !== "") query.set(each, String(kept));
184 }
185 if (key === "page" && value != null && Number(value) > 1) query.set("page", String(value));
186 if (filter.per_page !== PER_PAGE) query.set("per_page", String(filter.per_page));
187 const search = query.toString();
188 return `${path}${search ? `?${search}` : ""}#history`;
189}
190
191/** The distinct values of one field across deployments, for a filter's choices, the chosen one kept. */
192export function choices(
193 deployments: readonly (Pick<RepoDeployment, "creator" | "ref"> | null | undefined)[],
194 field: "creator" | "ref",
195 chosen?: string | null,
196): string[] {
197 const seen = new Set<string>();
198 if (chosen) seen.add(chosen);
199 for (const each of deployments) if (each?.[field]) seen.add(each[field]);
200 return [...seen].sort((a, b) => a.localeCompare(b));
201}
202
203/** "31–60 of 304": the rows a page of the list shows. */
204export function pageRange(page: number, perPage: number, total: number): string {
205 const first = (page - 1) * perPage + 1;
206 if (total === 0 || first > total) return `0 of ${total}`;
207 return `${first}–${Math.min(total, page * perPage)} of ${total}`;
208}
209
210/** Whether a deployment's payload has anything in it to show. */
211export function hasPayload(payload: Record<string, unknown> | null | undefined): boolean {
212 return payload != null && typeof payload === "object" && Object.keys(payload).length > 0;
213}
214
215/**
216 * Whether a status on a deployment's timeline is its newest: the latest
217 * one written (the last listed, when two share a time). Only the newest
218 * can still be under way; one a later status followed is over, whatever
219 * it said, so it is never shown as running.
220 */
221export function isNewestStatus(statuses: readonly Pick<DeploymentStatus, "created_at">[], index: number): boolean {
222 const at = statuses[index]?.created_at;
223 if (at == null) return false;
224 return statuses.every((other, i) => i === index || (i < index ? other.created_at <= at : other.created_at < at));
225}