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.

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