| 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 | */ |
| 6 | import 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). */ |
| 9 | const 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 | */ |
| 16 | export 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. */ |
| 26 | export 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. */ |
| 36 | const 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 | */ |
| 45 | export 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. */ |
| 69 | export const STATES: readonly DeploymentState[] = ["success", "failure", "error", "in_progress", "queued", "inactive"]; |
| 70 | |
| 71 | /** Every way a deployment can be made. */ |
| 72 | export const SOURCES: readonly DeploymentSource[] = ["actions", "g1t_page", "api"]; |
| 73 | |
| 74 | /** A state in a word. */ |
| 75 | export 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. */ |
| 85 | export 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. */ |
| 92 | export 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. */ |
| 97 | export 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. */ |
| 102 | export 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. */ |
| 107 | export 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 | |
| 111 | type 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 | */ |
| 118 | export 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 | */ |
| 127 | export 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. */ |
| 132 | export 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 | */ |
| 145 | export 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. */ |
| 166 | export const FILTER_KEYS = ["environment", "state", "source", "creator", "ref"] as const; |
| 167 | export type FilterKey = (typeof FILTER_KEYS)[number]; |
| 168 | |
| 169 | /** Deployments on a page of the list, unless the address says otherwise. */ |
| 170 | export const PER_PAGE = 30; |
| 171 | |
| 172 | export 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. */ |
| 175 | export 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. */ |
| 193 | export 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 | */ |
| 201 | export 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. */ |
| 214 | export 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. */ |
| 226 | export 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. */ |
| 233 | export 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 | */ |
| 243 | export 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 | } |