Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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