Skip to content

g1t/apps/web/app/lib/deployments.ts

212 lines9,041 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.