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.

319 linesCodeBlameRaw
1/**
2 * What went live, and when: Home's "Live 9 min after merge".
3 *
4 * When a production deployment succeeds (`deployment_status.created`
5 * with `success` in a production environment: a g1t.page build, or one a
6 * workflow or the API reported, since builds are published into the same
7 * model), its commit's history is read back from repos and every
8 * commit in it that no production deployment shipped before is written
9 * down as shipped by this one (`shipped_commits`, migration 0017). A
10 * commit's first production deployment is the one that took it live: a
11 * rollback to an older commit ships nothing new, and a deployment's event
12 * delivered twice is walked once (`shipped_walks`).
13 *
14 * Asking (`shipped`) reads only what was written, so a page never walks
15 * history. A change not shipped is `deploying` while a production
16 * deployment after it is on its way, `failed` when the latest after it
17 * failed, and `waiting` otherwise. A change that landed before recording
18 * began for its repository is matched to the first production deployment
19 * that succeeded after it instead.
20 */
21import type {
22 Commit,
23 DeploymentStatus,
24 RepoDeployment,
25 RepoPath,
26 Result,
27 ShipState,
28 ShippedAsk,
29 ShippedCommit,
30 ShippedRepo,
31 User,
32 Viewer,
33} from "@g1t/contracts";
34
35/** Reads a commit's history back, newest first (repos' `log`). */
36export type ReadLog = (repo: RepoPath, actor: User, ref: string, limit: number) => Promise<Result<Pick<Commit, "hash" | "parents">[]>>;
37
38/** Commits read back per page of history, and the most pages one deployment walks. */
39export const WALK_PAGE = 200;
40export const WALK_PAGES = 5;
41/** The most repositories, and commits in all, one `shipped` call asks about. */
42export const MAX_REPOS = 20;
43export const MAX_COMMITS = 300;
44
45/** A production deployment that went live, as its event says. */
46export type Shipment = {
47 deploymentId: string;
48 repoId: string;
49 repo: RepoPath;
50 sha: string;
51 environment: string;
52 deployedAt: string;
53 /** Its page on the site. */
54 url: string | null;
55};
56
57/**
58 * The commits of one page of history this deployment ships: those not
59 * known to be shipped already, each once. Whether to read the next page:
60 * only when this page was full and held nothing shipped before, so the
61 * walk stops at the last deployment's history.
62 */
63export function walkPage(
64 page: Pick<Commit, "hash" | "parents">[],
65 known: ReadonlySet<string>,
66 full: boolean,
67): { ship: string[]; more: boolean; next: string | null } {
68 const ship = [...new Set(page.map((commit) => commit.hash).filter((hash) => !known.has(hash)))];
69 const reachedKnown = page.some((commit) => known.has(commit.hash));
70 const last = page.at(-1);
71 const next = last?.parents[0] ?? null;
72 return { ship, more: full && !reachedKnown && next != null, next };
73}
74
75/** A production attempt after a change landed, as either table keeps it. */
76export type Attempt = { at: string; outcome: "live" | "running" | "failed"; url: string | null; environment: string };
77
78/**
79 * A change's state from what was recorded: shipped (its first production
80 * deployment), else from the production attempts after it landed (oldest
81 * first). `recordingStart` is when the repository's first walk went live:
82 * a change that landed before it was never walked, so the first attempt
83 * after it that went live shipped it.
84 */
85export function shipStateOf(
86 ask: ShippedAsk,
87 shipped: { deployed_at: string; url: string | null; environment: string } | null,
88 attempts: Attempt[],
89 recordingStart: string | null,
90): ShippedCommit {
91 const base = { sha: ask.sha };
92 const after = attempts.filter((attempt) => attempt.at >= ask.landed_at);
93 // Landed before anything was walked: the first walk wrote its history
94 // down as of then, so the first deployment after it that went live is nearer.
95 if (recordingStart == null || ask.landed_at < recordingStart) {
96 const live = after.find((attempt) => attempt.outcome === "live");
97 if (live) return { ...base, state: "live", deployed_at: live.at, url: live.url, environment: live.environment };
98 }
99 if (shipped) return { ...base, state: "live", deployed_at: shipped.deployed_at, url: shipped.url, environment: shipped.environment };
100 const running = after.find((attempt) => attempt.outcome === "running");
101 if (running) return { ...base, state: "deploying", deployed_at: null, url: running.url, environment: running.environment };
102 const latest = after.at(-1);
103 const state: ShipState = latest?.outcome === "failed" ? "failed" : "waiting";
104 return { ...base, state, deployed_at: null, url: state === "failed" ? (latest?.url ?? null) : null, environment: latest?.environment ?? null };
105}
106
107/** A g1t.page build's outcome. */
108function buildOutcome(status: string): Attempt["outcome"] | null {
109 if (status === "ready" || status === "replaced" || status === "down") return "live";
110 if (status === "queued" || status === "building") return "running";
111 if (status === "failed") return "failed";
112 return null;
113}
114
115/** A reported deployment's outcome: `inactive` is a success a newer one replaced. */
116function reportedOutcome(state: string): Attempt["outcome"] | null {
117 if (state === "success" || state === "inactive") return "live";
118 if (state === "queued" || state === "in_progress" || state === "pending") return "running";
119 if (state === "failure" || state === "error") return "failed";
120 return null;
121}
122
123/** A site path from an address that may be the site's own, absolute. */
124export function sitePath(url: string | null | undefined, site: string): string | null {
125 if (!url) return null;
126 if (url.startsWith("/")) return url;
127 if (site && url.startsWith(site)) return url.slice(site.length) || "/";
128 return null;
129}
130
131/**
132 * The shipment a `deployment_status.created` event is, if it is one: a
133 * success in a production environment. A g1t.page build links to its
134 * page, a reported deployment to its workflow run, else to the
135 * repository's deployments.
136 */
137export function shipmentOf(
138 data: {
139 repoId: string;
140 deployment: Pick<RepoDeployment, "id" | "sha" | "environment" | "production_environment" | "source" | "project" | "run_url" | "updated_at">;
141 deploymentStatus: Pick<DeploymentStatus, "state" | "created_at">;
142 },
143 repo: RepoPath,
144 site: string,
145): Shipment | null {
146 const { deployment, deploymentStatus } = data;
147 if (deploymentStatus?.state !== "success" || !deployment?.production_environment || !deployment.sha) return null;
148 const own = `/${repo.namespace}/${repo.name}`;
149 const url =
150 deployment.source === "g1t_page" && deployment.project
151 ? `/${repo.namespace}/${deployment.project}/deployments/${deployment.id}`
152 : (sitePath(deployment.run_url, site) ?? `${own}/deployments`);
153 return {
154 deploymentId: deployment.id,
155 repoId: data.repoId,
156 repo,
157 sha: deployment.sha,
158 environment: deployment.environment,
159 deployedAt: deploymentStatus.created_at || deployment.updated_at,
160 url,
161 };
162}
163
164type Env = { DB: D1Database; SITE: string };
165
166/**
167 * Only types are imported here, so the rules above are tested on their
168 * own; the service hands in how to read history and as whom.
169 */
170export class Shipped {
171 constructor(
172 private readonly env: Env,
173 /** Reads a workspace's repositories as the workspace itself. */
174 private readonly actorFor: (workspace: string) => Promise<User | null>,
175 private readonly readLog: ReadLog,
176 ) {}
177
178 private get db(): D1Database {
179 return this.env.DB;
180 }
181
182 /** Writes down what a production deployment shipped. Once per deployment. */
183 async record(shipment: Shipment): Promise<number> {
184 const walked = await this.db
185 .prepare(
186 `INSERT INTO shipped_walks (deployment_id, repo_id, sha, environment, deployed_at) VALUES (?, ?, ?, ?, ?)
187 ON CONFLICT DO NOTHING RETURNING deployment_id`,
188 )
189 .bind(shipment.deploymentId, shipment.repoId, shipment.sha, shipment.environment, shipment.deployedAt)
190 .first<{ deployment_id: string }>();
191 if (!walked) return 0;
192 const actor = await this.actorFor(shipment.repo.namespace);
193 if (!actor) return 0;
194 let ref: string | null = shipment.sha;
195 let shipped = 0;
196 try {
197 for (let page = 0; page < WALK_PAGES && ref; page++) {
198 const history = await this.readLog(shipment.repo, actor, ref, WALK_PAGE);
199 if (!history.ok || history.value.length === 0) break;
200 const hashes = history.value.map((commit) => commit.hash);
201 const known = await this.db
202 .prepare("SELECT sha FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))")
203 .bind(shipment.repoId, JSON.stringify(hashes))
204 .all<{ sha: string }>();
205 const { ship, more, next } = walkPage(history.value, new Set(known.results.map((row) => row.sha)), history.value.length >= WALK_PAGE);
206 if (ship.length > 0) {
207 await this.db.batch(
208 ship.map((sha) =>
209 this.db
210 .prepare(
211 `INSERT INTO shipped_commits (repo_id, sha, deployment_id, environment, deployed_at, url) VALUES (?, ?, ?, ?, ?, ?)
212 ON CONFLICT DO NOTHING`,
213 )
214 .bind(shipment.repoId, sha, shipment.deploymentId, shipment.environment, shipment.deployedAt, shipment.url),
215 ),
216 );
217 shipped += ship.length;
218 }
219 ref = more ? next : null;
220 }
221 } finally {
222 await this.db.prepare("UPDATE shipped_walks SET commits = ? WHERE deployment_id = ?").bind(shipped, shipment.deploymentId).run();
223 }
224 return shipped;
225 }
226
227 /** Whether each change asked about is live (see the module's notes). Members of the workspace only. */
228 async answer(a: { workspace: string; viewer: Viewer; repos: { repo_id: string; commits: ShippedAsk[] }[] }): Promise<Result<ShippedRepo[]>> {
229 const workspace = String(a.workspace ?? "").toLowerCase();
230 if (!a.viewer?.workspaces?.some((membership) => membership.slug === workspace)) {
231 return { ok: false, error: { code: "forbidden", message: "Only members can see a workspace's deployments." } };
232 }
233 const asked = (Array.isArray(a.repos) ? a.repos : []).slice(0, MAX_REPOS);
234 let budget = MAX_COMMITS;
235 const out: ShippedRepo[] = [];
236 for (const repo of asked) {
237 const commits = (Array.isArray(repo.commits) ? repo.commits : [])
238 .filter((ask) => typeof ask?.sha === "string" && typeof ask?.landed_at === "string")
239 .slice(0, Math.max(0, budget));
240 budget -= commits.length;
241 out.push(await this.forRepo(String(repo.repo_id), workspace, commits));
242 }
243 return { ok: true, value: out };
244 }
245
246 private async forRepo(repoId: string, workspace: string, commits: ShippedAsk[]): Promise<ShippedRepo> {
247 // Only this workspace's repositories: the g1t.page builds and the
248 // reported deployments say whose they are.
249 const oldest = commits.reduce<string | null>((min, ask) => (min == null || ask.landed_at < min ? ask.landed_at : min), null);
250 const [shipped, start, builds, reported] = await Promise.all([
251 commits.length
252 ? this.db
253 .prepare("SELECT sha, deployed_at, url, environment FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))")
254 .bind(repoId, JSON.stringify(commits.map((ask) => ask.sha)))
255 .all<{ sha: string; deployed_at: string; url: string | null; environment: string }>()
256 : null,
257 this.db.prepare("SELECT MIN(deployed_at) AS at FROM shipped_walks WHERE repo_id = ?").bind(repoId).first<{ at: string | null }>(),
258 this.db
259 .prepare(
260 `SELECT id, slug, status, created_at, finished_at FROM deployments
261 WHERE repo_id = ? AND workspace = ? AND kind = 'production' AND created_at >= ? ORDER BY created_at LIMIT 200`,
262 )
263 .bind(repoId, workspace, oldest ?? "9999")
264 .all<{ id: string; slug: string; status: string; created_at: string; finished_at: string | null }>(),
265 this.db
266 .prepare(
267 `SELECT id, environment, state, run_url, created_at, updated_at FROM reported_deployments
268 WHERE repo_id = ? AND production_environment = 1 AND created_at >= ? ORDER BY created_at LIMIT 200`,
269 )
270 .bind(repoId, oldest ?? "9999")
271 .all<{ id: string; environment: string; state: string; run_url: string | null; created_at: string; updated_at: string }>(),
272 ]);
273 const deploys = builds.results.length > 0 || reported.results.length > 0 || (start?.at ?? null) != null || (await this.deploysAtAll(repoId, workspace));
274 const attempts: Attempt[] = [
275 ...builds.results.flatMap((row) => {
276 const outcome = buildOutcome(row.status);
277 return outcome
278 ? [
279 {
280 at: outcome === "running" ? row.created_at : (row.finished_at ?? row.created_at),
281 outcome,
282 url: `/${workspace}/${row.slug}/deployments/${row.id}`,
283 environment: "production",
284 },
285 ]
286 : [];
287 }),
288 ...reported.results.flatMap((row) => {
289 const outcome = reportedOutcome(row.state);
290 // A success replaced since (`inactive`) was last changed when it was replaced: it went live nearer its start.
291 const at = outcome === "running" || row.state === "inactive" ? row.created_at : row.updated_at;
292 return outcome ? [{ at, outcome, url: sitePath(row.run_url, this.env.SITE), environment: row.environment }] : [];
293 }),
294 ].sort((x, y) => x.at.localeCompare(y.at));
295 const byHash = new Map((shipped?.results ?? []).map((row) => [row.sha, row]));
296 return {
297 repo_id: repoId,
298 deploys,
299 commits: deploys ? commits.map((ask) => shipStateOf(ask, byHash.get(ask.sha) ?? null, attempts, start?.at ?? null)) : [],
300 };
301 }
302
303 /** Whether the repository ever deployed to production. */
304 private async deploysAtAll(repoId: string, workspace: string): Promise<boolean> {
305 const [build, reported] = await Promise.all([
306 this.db.prepare("SELECT 1 AS x FROM deployments WHERE repo_id = ? AND workspace = ? AND kind = 'production' LIMIT 1").bind(repoId, workspace).first(),
307 this.db.prepare("SELECT 1 AS x FROM reported_deployments WHERE repo_id = ? AND production_environment = 1 LIMIT 1").bind(repoId).first(),
308 ]);
309 return build != null || reported != null;
310 }
311
312 /** A repository purged: what it shipped goes with it. */
313 async purge(repoId: string): Promise<void> {
314 await this.db.batch([
315 this.db.prepare("DELETE FROM shipped_commits WHERE repo_id = ?").bind(repoId),
316 this.db.prepare("DELETE FROM shipped_walks WHERE repo_id = ?").bind(repoId),
317 ]);
318 }
319}