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 audit | 1 | /** |
| 2 | * Builds as the deployments pages show them: why one failed, in plain | |
| Merge main: Deployments panel in the About, project homepage, both sides' operations | 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. | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 5 | */ |
| Merge main: Deployments panel in the About, project homepage, both sides' operations | 6 | import 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 audit | 14 | |
| 15 | /** What git says when the commit asked for is not in the repository (as services/deployments/src/retries.ts). */ | |
| 16 | const 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 | */ | |
| 23 | export 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. */ | |
| 33 | export 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. */ | |
| 43 | const 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 | */ | |
| 52 | export 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' operations | 74 | |
| 75 | // --- A repository's deployments, wherever they run --------------------------- | |
| 76 | ||
| 77 | /** Every state a deployment can be in, in the order the filter lists them. */ | |
| 78 | export const STATES: readonly DeploymentState[] = ["success", "failure", "error", "in_progress", "queued", "inactive"]; | |
| 79 | ||
| 80 | /** Every way a deployment can be made. */ | |
| 81 | export const SOURCES: readonly DeploymentSource[] = ["actions", "g1t_page", "api"]; | |
| 82 | ||
| 83 | /** A state in a word. */ | |
| 84 | export 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. */ | |
| 94 | export 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. */ | |
| 101 | export 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. */ | |
| 106 | export 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. */ | |
| 111 | export 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. */ | |
| 116 | export 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 | ||
| 120 | type 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 | */ | |
| 127 | export 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 | */ | |
| 138 | export 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. */ | |
| 143 | export const FILTER_KEYS = ["environment", "state", "source", "creator", "ref"] as const; | |
| 144 | export type FilterKey = (typeof FILTER_KEYS)[number]; | |
| 145 | ||
| 146 | /** Deployments on a page of the list, unless the address says otherwise. */ | |
| 147 | export const PER_PAGE = 30; | |
| 148 | ||
| 149 | export 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. */ | |
| 152 | export 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. */ | |
| 170 | export 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 | */ | |
| 178 | export 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. */ | |
| 191 | export 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. */ | |
| 203 | export 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. */ | |
| 210 | export 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.