Skip to content
989 linesCodeBlameRaw

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.

Projects: what a workspace builds and runs, first on every page1/**
2 * The projects service: what a workspace builds and runs.
3 *
4 * A project has one source, where its code lives: today a repository hosted
5 * on g1t and a root directory in it. Everything about running it
6 * (deployments, environments, domains, secrets and variables) hangs off the
7 * project; other services key their data by its id. Every repository gets
8 * a project of its own name: when it is created (from `repo.created`), and
9 * for repositories made before projects existed, the first time their
10 * workspace's projects are asked for.
11 *
12 * Reached through service bindings: `POST /rpc/<method>`.
13 */
14
Project dependencies: addresses, preview stacks, Affects, and agents who know15
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16import { NEEDS, repoRef } from "./access";
Fast pages, required checks on the branch, self-hosted runners, honest incidents17import { effectiveDescription, ownDescription } from "./description";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9718import {
19 type KindFacts,
20 type RootFiles,
21 MANIFESTS,
22 detectKind,
23 detectedKind,
24 goFilesToRead,
25 kindSetting,
26 neverRuns,
27 nextSetting,
28 resolveKind,
29 runsSetting,
30} from "./kind";
31import { cleanLinks, cleanUrl, projectLinks } from "./links";
Projects: when each was last pushed, and how active it is lately32import { ACTIVE, decayed, raised } from "./activity";
Projects: pinned and recent projects, per person per workspace33import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains34import { renameStatements } from "./rename";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look35import { moveStatements, slugOf, strandedQuery } from "./transfer";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains36
Projects: what a workspace builds and runs, first on every page37import {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look38 can,
39 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains40 currentWorkspaceSlug,
Projects: what a workspace builds and runs, first on every page41 fail,
42 identityClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains43 staleSlugs,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look44 needs,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member45 isProtectedWorkspace,
Projects: what a workspace builds and runs, first on every page46 newId,
47 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents48 openD1,
A project is an app or a library: libraries show their package and how to ship a release, not production49 packagesClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50 permission,
51 repoMove,
Projects: what a workspace builds and runs, first on every page52 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53 staleMovedPaths,
A project is an app or a library: libraries show their package and how to ship a release, not production54 type Ecosystem,
Projects: what a workspace builds and runs, first on every page55 type G1tEvent,
56 type NewProject,
57 type Project,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9758 type ProjectChanges,
59 type ProjectKind,
A project is an app or a library: libraries show their package and how to ship a release, not production60 type ProjectEcosystem,
Projects: pinned and recent projects, per person per workspace61 type ProjectShortcuts,
Projects: what a workspace builds and runs, first on every page62 type Repo,
63 type Result,
64 type ServiceBinding,
65 type User,
66 type Viewer,
67} from "@g1t/contracts";
68
69type Env = {
70 DB: D1Database;
71 REPOS: ServiceBinding;
72 IDENTITY: ServiceBinding;
A project is an app or a library: libraries show their package and how to ship a release, not production73 PACKAGES: ServiceBinding;
74 DEPLOYMENTS: ServiceBinding;
Projects: what a workspace builds and runs, first on every page75};
76
77type Row = {
78 id: string;
79 workspace: string;
80 slug: string;
81 name: string;
Fast pages, required checks on the branch, self-hosted runners, honest incidents82 /** The project's own description; null while it follows its repository's. */
Projects: what a workspace builds and runs, first on every page83 description: string | null;
Fast pages, required checks on the branch, self-hosted runners, honest incidents84 /** Its repository's description, kept from repos. */
85 repo_description?: string | null;
Projects: what a workspace builds and runs, first on every page86 source_kind: string;
87 repo_id: string;
88 repo_namespace: string;
89 repo_name: string;
90 repo_private: number;
91 default_branch: string;
92 root_dir: string;
93 is_primary: number;
94 created_by: string;
95 created_at: string;
96 updated_at: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look97 /** Set while its repository is deleted; the project is hidden until it is restored. */
98 repo_deleted_at: string | null;
99 /** When its repository was archived; null while it is not. */
100 repo_archived_at?: string | null;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97101 /** What a person set it to be and where it runs; null left to detection (migrations/0010_kind_and_links.sql). */
102 kind_setting?: string | null;
103 runs_setting?: string | null;
104 production_url?: string | null;
105 /** Its own homepage; null follows `repo_website`. */
106 homepage?: string | null;
107 repo_website?: string | null;
108 docs_url?: string | null;
109 /** Its other links, as JSON (src/links.ts). */
110 links?: string | null;
A project is an app or a library: libraries show their package and how to ship a release, not production111 detected_kind?: string | null;
112 detected_detail?: string | null;
113 detected_ecosystem?: string | null;
114 /** The default branch's commit its files were read at; null until they have been. */
115 detected_commit?: string | null;
116 linked_package?: string | null;
117 deployments_on?: number | null;
Projects: when each was last pushed, and how active it is lately118 /** When its repository was last pushed to (migrations/0009_activity.sql). */
119 pushed_at?: string | null;
120 /** How active it is lately, as of `active_at` (src/activity.ts). */
121 activity?: number | null;
122 active_at?: string | null;
Projects: what a workspace builds and runs, first on every page123};
124
A project is an app or a library: libraries show their package and how to ship a release, not production125/** How a package its repository publishes is named in why a project is a library. */
126const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
127 container: "container image",
128 npm: "npm package",
129 composer: "Composer package",
130 cargo: "crate",
131 go: "Go module",
Projects: a library publishing a Maven, NuGet or RubyGems package says so132 maven: "Maven package",
133 nuget: "NuGet package",
134 rubygems: "gem",
A project is an app or a library: libraries show their package and how to ship a release, not production135};
136
137/** How many projects one listing reads the files of, in the background, before it has. */
138const DETECT_PER_LIST = 10;
139
Projects: what a workspace builds and runs, first on every page140const now = () => new Date().toISOString();
141
142function toProject(row: Row): Project {
Fast pages, required checks on the branch, self-hosted runners, honest incidents143 const { description, inherited } = effectiveDescription(row);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97144 const setting = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
145 const facts: Omit<KindFacts, "setting"> = {
A project is an app or a library: libraries show their package and how to ship a release, not production146 deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
147 linkedPackage: row.linked_package ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97148 detected: row.detected_commit == null ? null : { kind: kindSetting(row.detected_kind), detail: row.detected_detail ?? "" },
A project is an app or a library: libraries show their package and how to ship a release, not production149 };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97150 const { kind, reason, runs } = resolveKind({ setting, ...facts });
Projects: what a workspace builds and runs, first on every page151 return {
152 id: row.id,
153 workspace: row.workspace,
154 slug: row.slug,
155 name: row.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents156 description,
157 descriptionInherited: inherited,
Projects: what a workspace builds and runs, first on every page158 source: {
159 kind: "hosted",
160 repoId: row.repo_id,
161 repo: { namespace: row.repo_namespace, name: row.repo_name },
162 rootDir: row.root_dir,
163 defaultBranch: row.default_branch,
164 },
165 private: !!row.repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look166 archived: !!row.repo_archived_at,
Projects: what a workspace builds and runs, first on every page167 primary: !!row.is_primary,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97168 setting,
A project is an app or a library: libraries show their package and how to ship a release, not production169 kind,
170 kindReason: reason,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97171 runs,
172 productionUrl: row.production_url ?? null,
173 detected: detectedKind(facts),
A project is an app or a library: libraries show their package and how to ship a release, not production174 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97175 links: projectLinks(row),
Projects: what a workspace builds and runs, first on every page176 createdBy: row.created_by,
177 createdAt: row.created_at,
178 updatedAt: row.updated_at,
Projects: when each was last pushed, and how active it is lately179 pushedAt: row.pushed_at ?? null,
180 activity: decayed(row.activity ?? 0, row.active_at ?? null, now()),
Projects: what a workspace builds and runs, first on every page181 };
182}
183
184function isMember(viewer: Viewer, workspace: string): boolean {
185 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
186}
187
188class Projects {
A project is an app or a library: libraries show their package and how to ship a release, not production189 /** `defer` runs work after the answer is sent: the request's waitUntil. */
190 constructor(
191 private readonly env: Env,
192 private readonly defer: (work: Promise<unknown>) => void = () => {},
193 ) {}
Projects: what a workspace builds and runs, first on every page194
195 private get db() {
196 return this.env.DB;
197 }
198
199 private async workspaceActor(slug: string): Promise<User | null> {
200 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
201 if (!workspace) return null;
202 return {
203 id: workspace.id,
204 username: workspace.slug,
205 kind: "workspace",
206 verified: true,
207 workspaces: [{ slug: workspace.slug, role: "member" }],
208 };
209 }
210
211 /** A free slug for `wanted` in the workspace. */
212 private async freeSlug(workspace: string, wanted: string): Promise<string> {
213 const base = slugOf(wanted) || "project";
214 for (let n = 1; n < 100; n++) {
215 const slug = n === 1 ? base : `${base}-${n}`;
216 const taken = await this.db
217 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
218 .bind(workspace, slug)
219 .first();
220 if (!taken) return slug;
221 }
222 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
223 }
224
225 /** Gives a repository its own project, unless it has one. */
226 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
227 if (repo.forkOf) return;
228 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
229 if (existing) {
230 await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look231 .prepare(
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97232 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ?, repo_description = ?, repo_website = ? WHERE repo_id = ?",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look233 )
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97234 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.description ?? null, repo.website ?? null, repo.id)
Projects: what a workspace builds and runs, first on every page235 .run();
236 return;
237 }
238 const at = now();
239 await this.db
240 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents241 // No description of its own: it shows the repository's, as that changes.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97242 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_website, repo_id, repo_namespace, repo_name, repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look243 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97244 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
Projects: what a workspace builds and runs, first on every page245 ON CONFLICT (workspace, slug) DO NOTHING`,
246 )
247 .bind(
248 newId("prj"),
249 repo.namespace.toLowerCase(),
250 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
251 repo.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents252 repo.description ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97253 repo.website ?? null,
Projects: what a workspace builds and runs, first on every page254 repo.id,
255 repo.namespace,
256 repo.name,
257 repo.isPrivate ? 1 : 0,
258 repo.defaultBranch,
259 createdBy,
260 at,
261 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look262 repo.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page263 )
264 .run();
265 }
266
267 /** Projects for a workspace's repositories made before projects existed. Once. */
268 private async backfill(workspace: string): Promise<void> {
269 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
270 if (done) return;
271 const actor = await this.workspaceActor(workspace);
272 if (!actor) return;
273 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
274 for (const repo of list) {
275 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
276 }
277 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
278 }
279
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look280 /**
281 * Whether the viewer can read the project's repository. The workspace's
282 * own token sees all its projects, also one left building from a
283 * repository that moved to another workspace.
284 */
Projects: what a workspace builds and runs, first on every page285 private visible(row: Row, viewer: Viewer): boolean {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look286 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
287 return permission(viewer, repoRef(row)) != null;
288 }
289
290 /**
291 * Whether the actor may change the project: not found when they cannot
292 * read its repository, refused when their role there is too low.
293 */
294 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
295 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
296 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
297 return ok(true);
Projects: what a workspace builds and runs, first on every page298 }
299
300 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
301 const workspace = a.workspace.toLowerCase();
302 await this.backfill(workspace);
303 const rows = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look304 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
Projects: what a workspace builds and runs, first on every page305 .bind(workspace)
306 .all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production307 // Projects whose files have not been read yet are read after this answer,
308 // a few at a time; until then they show as apps, as before.
309 const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
310 if (unread.length) this.defer(Promise.all(unread.map((row) => this.detect(row).catch((error) => console.warn("detect", row.slug, error)))));
Projects: what a workspace builds and runs, first on every page311 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
312 }
313
314 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
315 const workspace = a.workspace.toLowerCase();
316 await this.backfill(workspace);
317 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look318 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page319 .bind(workspace, a.slug.toLowerCase())
320 .first<Row>();
321 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
A project is an app or a library: libraries show their package and how to ship a release, not production322 if (row.detected_commit == null) {
323 // Once per project: what its files say, read now so its first page is right.
324 const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
325 if (read) return ok(toProject(read));
326 }
Projects: what a workspace builds and runs, first on every page327 return ok(toProject(row));
328 }
329
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member330 /** How many projects a workspace shows: what deleting it would take with it. */
331 async count(a: { workspace: string }): Promise<number> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look332 const row = await this.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member333 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL")
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look334 .bind(a.workspace.toLowerCase())
335 .first<{ n: number }>();
336 return row?.n ?? 0;
337 }
338
339 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
340 private async repoById(repoId: string): Promise<Repo | null> {
Projects: what a workspace builds and runs, first on every page341 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
342 method: "POST",
343 headers: { "content-type": "application/json" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look344 body: JSON.stringify({ id: repoId }),
Projects: what a workspace builds and runs, first on every page345 });
346 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look347 if (!repoPath) return null;
Projects: what a workspace builds and runs, first on every page348 const actor = await this.workspaceActor(repoPath.namespace);
349 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look350 return repo?.ok ? repo.value : null;
351 }
352
353 async byRepo(a: { repoId: string }): Promise<Project[]> {
354 const rows = await this.db
355 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
356 .bind(a.repoId)
357 .all<Row>();
358 // A deleted repository's projects are hidden until it is restored.
359 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
360 // A repository from before projects: give it its own now.
361 const repo = await this.repoById(a.repoId);
362 if (!repo) return [];
363 await this.ensureFor(repo, "g1t");
364 const again = await this.db
365 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
366 .bind(a.repoId)
367 .all<Row>();
Projects: what a workspace builds and runs, first on every page368 return again.results.map(toProject);
369 }
370
371 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
372 const workspace = a.workspace.toLowerCase();
373 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
374 if (a.input.repo.namespace.toLowerCase() !== workspace) {
375 return fail("invalid", "A project builds from one of its own workspace's repositories.");
376 }
377 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
378 if (!repo.ok) return repo;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look379 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
Projects: what a workspace builds and runs, first on every page380 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
381 const name = a.input.name.trim();
382 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
383 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
384 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
385 const slug = slugOf(name);
386 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
387 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
388 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
389 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
390 const at = now();
391 const id = newId("prj");
392 await this.db
393 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents394 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look395 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
Fast pages, required checks on the branch, self-hosted runners, honest incidents396 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Projects: what a workspace builds and runs, first on every page397 )
398 .bind(
399 id,
400 workspace,
401 slug,
402 name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents403 ownDescription(null, a.input.description ?? null),
404 repo.value.description ?? null,
Projects: what a workspace builds and runs, first on every page405 repo.value.id,
406 repo.value.namespace,
407 repo.value.name,
408 repo.value.isPrivate ? 1 : 0,
409 repo.value.defaultBranch,
410 rootDir,
411 primary ? 1 : 0,
412 a.actor.username,
413 at,
414 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look415 repo.value.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page416 )
417 .run();
418 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
419 }
420
421 async update(a: {
422 actor: User;
423 workspace: string;
424 slug: string;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97425 changes: ProjectChanges;
Projects: what a workspace builds and runs, first on every page426 }): Promise<Result<Project>> {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97427 const changes = a.changes ?? {};
Projects: what a workspace builds and runs, first on every page428 const workspace = a.workspace.toLowerCase();
429 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look430 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page431 .bind(workspace, a.slug.toLowerCase())
432 .first<Row>();
433 if (!row) return fail("not_found", "There is no such project.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look434 const allowed = this.changeable(row, a.actor, NEEDS.update);
435 if (!allowed.ok) return allowed;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97436 const name = changes.name?.trim() || row.name;
Fast pages, required checks on the branch, self-hosted runners, honest incidents437 // Blank or null goes back to following the repository's description.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97438 const description = ownDescription(row.description, changes.description);
439 const rootDir = changes.rootDir === undefined ? row.root_dir : String(changes.rootDir).trim().replace(/^\/+|\/+$/g, "");
Projects: what a workspace builds and runs, first on every page440 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97441 const known = (value: unknown, allowed: readonly string[], what: string) =>
442 value === undefined || allowed.includes(value as string) ? null : fail("invalid", `${what} is one of ${allowed.join(", ")}.`);
443 const badKind = known(changes.kind, ["auto", "app", "library", "tool", "docs", "other"], "kind");
444 if (badKind) return badKind;
445 const badRuns = known(changes.runs, ["auto", "g1t", "elsewhere"], "runs");
446 if (badRuns) return badRuns;
447 const before = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
448 const setting = nextSetting(before, { kind: changes.kind as ProjectKind | "auto" | undefined, runs: changes.runs });
449 // Something that never runs, while Deployments run, would leave apps up
450 // that no page offers: a person turns them off first.
451 if (neverRuns(setting) && !neverRuns(before) && (row.deployments_on ?? (await this.deploymentsOn(row.id)))) {
A project is an app or a library: libraries show their package and how to ship a release, not production452 return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
453 }
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97454 const url = (value: string | null | undefined, current: string | null | undefined, what: string) =>
455 value === undefined ? { ok: true as const, value: current ?? null } : cleanUrl(value, what);
456 const production = url(changes.productionUrl, row.production_url, "The production address");
457 if (!production.ok) return fail("invalid", production.message);
458 const homepage = url(changes.homepage, row.homepage, "The homepage");
459 if (!homepage.ok) return fail("invalid", homepage.message);
460 const docs = url(changes.docsUrl, row.docs_url, "The docs address");
461 if (!docs.ok) return fail("invalid", docs.message);
462 let linksJson = row.links ?? null;
463 if (changes.links !== undefined) {
464 const links = cleanLinks(changes.links);
465 if (!links.ok) return fail("invalid", links.message);
466 linksJson = links.value.length ? JSON.stringify(links.value) : null;
467 }
Projects: what a workspace builds and runs, first on every page468 await this.db
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97469 .prepare(
470 `UPDATE projects SET name = ?, description = ?, root_dir = ?, kind_setting = ?, runs_setting = ?, production_url = ?,
471 homepage = ?, docs_url = ?, links = ?, updated_at = ? WHERE id = ?`,
472 )
473 .bind(
474 name.slice(0, 100),
475 description,
476 rootDir,
477 setting.kind,
478 setting.runs,
479 production.value,
480 homepage.value,
481 docs.value,
482 linksJson,
483 now(),
484 row.id,
485 )
Projects: what a workspace builds and runs, first on every page486 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production487 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
488 // Another root has other files: read them again.
489 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
490 return ok(toProject(saved));
Projects: what a workspace builds and runs, first on every page491 }
492
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97493 async publicLinks(a: { repos: { namespace: string; name: string }[] }): Promise<Record<string, string>> {
494 const keys = [...new Set((Array.isArray(a.repos) ? a.repos : []).slice(0, 100).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))];
495 if (keys.length === 0) return {};
496 const rows = await this.db
497 .prepare(
498 `SELECT * FROM projects WHERE is_primary = 1 AND repo_private = 0 AND repo_deleted_at IS NULL
499 AND lower(repo_namespace || '/' || repo_name) IN (${keys.map(() => "?").join(", ")})`,
500 )
501 .bind(...keys)
502 .all<Row>();
503 const out: Record<string, string> = {};
504 for (const row of rows.results) {
505 const project = toProject(row);
506 const link = (project.runs === "elsewhere" ? project.productionUrl : null) ?? project.links.homepage ?? project.links.docs;
507 if (link) out[`${row.repo_namespace}/${row.repo_name}`.toLowerCase()] = link;
508 }
509 return out;
510 }
511
A project is an app or a library: libraries show their package and how to ship a release, not production512 async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97513 // Turned on, it runs on g1t: one said to run elsewhere moved here.
514 await this.db
515 .prepare(
516 "UPDATE projects SET deployments_on = ?1, runs_setting = CASE WHEN ?1 = 1 AND runs_setting = 'elsewhere' THEN 'g1t' ELSE runs_setting END WHERE id = ?2",
517 )
518 .bind(a.enabled ? 1 : 0, a.projectId)
519 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production520 }
521
Projects: pinned and recent projects, per person per workspace522 // ---- Pinned and recent ---------------------------------------------------
523
524 /** The person's pins in the workspace, in order, as rows: hidden projects left out. */
525 private async pinRows(userId: string, workspace: string): Promise<Row[]> {
526 const rows = await this.db
527 .prepare(
528 `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id
529 WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`,
530 )
531 .bind(userId, workspace.toLowerCase())
532 .all<Row>();
533 return rows.results;
534 }
535
536 /** Writes the person's pins in the workspace in this order, by project id. */
537 private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> {
538 const at = now();
539 await this.db.batch([
540 ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)),
541 ...order.map((id, position) =>
542 this.db
543 .prepare(
544 `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?)
545 ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`,
546 )
547 .bind(userId, id, position, at),
548 ),
549 ]);
550 }
551
552 /** The person behind a call about their own pins, or why there is none. */
553 private person(viewer: Viewer): Result<User> {
554 if (!viewer) return fail("unauthenticated", "Sign in to pin projects.");
555 if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token.");
556 return ok(viewer);
557 }
558
559 /** A project the person can see, for pinning. */
560 private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> {
561 const row = await this.row(workspace, slug);
562 if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project.");
563 return ok(row);
564 }
565
566 async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> {
567 const who = this.person(a.viewer);
568 if (!who.ok) return { pinned: [], recent: [] };
569 const workspace = a.workspace.toLowerCase();
570 const [pinned, visited] = await Promise.all([
571 this.pinRows(who.value.id, workspace),
572 this.db
573 .prepare(
574 `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id
575 WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`,
576 )
577 .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT)
578 .all<Row>(),
579 ]);
580 const shown = (row: Row) => this.visible(row, who.value);
581 const pins = pinned.filter(shown);
582 return {
583 pinned: pins.map(toProject),
584 recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject),
585 };
586 }
587
588 async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> {
589 const who = this.person(a.actor);
590 if (!who.ok) return who;
591 const found = await this.pinnable(who.value, a.workspace, a.slug);
592 if (!found.ok) return found;
593 const current = await this.pinRows(who.value.id, found.value.workspace);
594 const order = placePin(
595 current.map((row) => row.id),
596 found.value.id,
597 a.position,
598 );
599 if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`);
600 await this.writePins(who.value.id, order);
601 return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
602 }
603
604 async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> {
605 const who = this.person(a.actor);
606 if (!who.ok) return who;
607 const row = await this.row(a.workspace, a.slug);
608 if (!row) return fail("not_found", "There is no such project.");
609 const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id);
610 await this.writePins(who.value.id, rest, [row.id]);
611 return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject));
612 }
613
614 async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> {
615 const who = this.person(a.actor);
616 if (!who.ok) return who;
617 if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order.");
618 const workspace = a.workspace.toLowerCase();
619 const current = await this.pinRows(who.value.id, workspace);
620 const idOf = new Map(current.map((row) => [row.slug, row.id]));
621 const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug));
622 const order = reorderPins(
623 current.map((row) => row.id),
624 wanted,
625 );
626 if (!order.ok) return fail("invalid", order.message);
627 await this.writePins(who.value.id, order.order);
628 return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
629 }
630
631 async visited(a: { actor: User; projectId: string }): Promise<void> {
632 const who = this.person(a.actor);
633 if (!who.ok || !a.projectId) return;
634 await this.db.batch([
635 this.db
636 .prepare(
637 `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?)
638 ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`,
639 )
640 .bind(who.value.id, a.projectId, now()),
641 // Only their latest few are kept.
642 this.db
643 .prepare(
644 `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN
645 (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`,
646 )
647 .bind(who.value.id, MAX_VISITS),
648 ]);
649 }
650
A project is an app or a library: libraries show their package and how to ship a release, not production651 // ---- App or library --------------------------------------------------
652
653 /**
654 * Reads what decides whether the project is a library: the manifests at
655 * its root at `commit` (the default branch's head when null), a package
656 * its repository publishes, and, the first time, whether Deployments are
657 * on. Files are read again only for a commit they were not read at.
658 * `packages` reads only the packages. Returns the row as it is now.
659 */
660 private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
661 const actor = await this.workspaceActor(row.workspace);
662 if (!actor) return row;
663 const repo = { namespace: row.repo_namespace, name: row.repo_name };
664 const sets: string[] = [];
665 const values: unknown[] = [];
666 const set = (column: string, value: unknown) => {
667 sets.push(`${column} = ?`);
668 values.push(value);
669 };
670 const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
671 const [files, linked, deploying] = await Promise.all([
672 filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
673 this.linkedPackage(row, actor),
674 row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
675 ]);
676 if (files) {
677 const found = files.commit ? detectKind(files.root) : null;
678 set("detected_kind", found?.kind ?? null);
679 set("detected_detail", found?.detail ?? null);
680 set("detected_ecosystem", found?.ecosystem ?? null);
681 set("detected_commit", files.commit);
682 }
683 if (linked !== undefined) set("linked_package", linked);
684 if (deploying != null) set("deployments_on", deploying ? 1 : 0);
685 if (sets.length === 0) return row;
686 await this.db
687 .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
688 .bind(...values, row.id)
689 .run();
690 return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
691 }
692
693 /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
694 private async readRoot(
695 repo: { namespace: string; name: string },
696 actor: User,
697 commit: string | null,
698 rootDir: string,
699 ): Promise<{ commit: string; root: RootFiles } | null> {
700 const client = reposClient(this.env.REPOS);
701 const empty = { commit: "", root: { entries: [], text: {} } };
702 const tree = await client.tree(repo, actor, commit, rootDir);
703 if (!tree.ok) return null;
704 const head = commit ?? tree.value.head?.hash ?? "";
705 if (!head) return empty;
706 const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
707 const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
708 const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
709 const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
710 const [texts, src, pub] = await Promise.all([
711 Promise.all(
712 names.map(async (name) => {
713 const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
714 return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
715 }),
716 ),
717 entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
718 entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
719 ]);
720 const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
721 return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
722 }
723
724 /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
725 private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
726 const listed = await packagesClient(this.env.PACKAGES)
727 .list(row.workspace, actor, { repoId: row.repo_id })
728 .catch(() => null);
729 if (!listed?.ok) return undefined;
730 // An image is how an app ships too; it says nothing about being a library.
731 const pkg = listed.value.find((p) => p.ecosystem !== "container");
732 if (!pkg) return null;
733 return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
734 }
735
736 /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
737 private async deploymentsOn(projectId: string): Promise<boolean | null> {
738 const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
739 method: "POST",
740 headers: { "content-type": "application/json" },
741 body: JSON.stringify({ projectId }),
742 }).catch(() => null);
743 if (!answer?.ok) return null;
744 const value = (await answer.json().catch(() => null)) as unknown;
745 return typeof value === "boolean" ? value : null;
746 }
Project dependencies: addresses, preview stacks, Affects, and agents who know747
748 private async row(workspace: string, slug: string): Promise<Row | null> {
749 return this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look750 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Project dependencies: addresses, preview stacks, Affects, and agents who know751 .bind(workspace.toLowerCase(), slug.toLowerCase())
752 .first<Row>();
753 }
754
Projects: when each was last pushed, and how active it is lately755 /** Something happened to a repository's code or work: its projects are that much more active. */
756 private async active(repoId: string, at: string, pushed: boolean): Promise<void> {
757 const rows = await this.db
758 .prepare("SELECT id, activity, active_at FROM projects WHERE repo_id = ?")
759 .bind(repoId)
760 .all<{ id: string; activity: number | null; active_at: string | null }>();
761 if (rows.results.length === 0) return;
762 await this.db.batch(
763 rows.results.map((row) => {
764 const next = raised(row.activity ?? 0, row.active_at, at);
765 return pushed
766 ? this.db
767 .prepare("UPDATE projects SET activity = ?, active_at = ?, pushed_at = MAX(COALESCE(pushed_at, ''), ?) WHERE id = ?")
768 .bind(next.score, next.at, at, row.id)
769 : this.db.prepare("UPDATE projects SET activity = ?, active_at = ? WHERE id = ?").bind(next.score, next.at, row.id);
770 }),
771 );
772 }
773
Projects: what a workspace builds and runs, first on every page774 async onEvent(event: G1tEvent): Promise<void> {
Projects: when each was last pushed, and how active it is lately775 if (ACTIVE.has(event.type)) {
776 const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
777 if (repoId) await this.active(repoId, event.time, event.type === "git.push");
778 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains779 if (event.type === "workspace.renamed") {
780 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
781 const statements = renameStatements(staleSlugs(event.data, current), current);
782 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
783 return;
784 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look785 const move = repoMove(event);
786 if (move) {
787 const current = await currentMovedPath(this.env.REPOS, move);
788 const stale = staleMovedPaths(move, current);
789 if (stale.length === 0) return;
790 const statements = moveStatements(stale, current, move.repoId);
791 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
792 // A project whose slug the destination already had moves under a free one.
793 const query = strandedQuery(stale, current, move.repoId);
794 if (!query) return;
795 const workspace = current.split("/")[0]!;
796 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
797 for (const row of stranded.results) {
798 const slug = await this.freeSlug(workspace, row.slug);
799 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
800 await this.db
801 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
802 .bind(workspace, slug, workspace, now(), row.id)
803 .run();
804 }
805 return;
806 }
807 if (event.type === "repo.deleted") {
808 // Hidden, not gone: a restore brings its projects back as they were.
809 await this.db
810 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
811 .bind(now(), event.data.repoId)
812 .run();
813 return;
814 }
815 if (event.type === "repo.restored") {
816 await this.db
817 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
818 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
819 .run();
820 return;
821 }
822 if (event.type === "repo.purged") {
823 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
824 await this.db.batch([
Projects: pinned and recent projects, per person per workspace825 // Pins and visits of what is gone go with it.
826 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
827 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look828 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
829 ]);
830 return;
831 }
832 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
833 // Who may see its projects follows who may see the repository.
834 await this.db
835 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
836 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
837 .run();
Fast pages, required checks on the branch, self-hosted runners, honest incidents838 if (event.type === "repo.updated") {
839 // Projects without a description of their own show the repository's.
840 // Asked of repos, so changes delivered out of order end the same.
841 const repo = await this.repoById(event.data.repoId);
842 if (repo) {
843 await this.db
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97844 .prepare("UPDATE projects SET repo_description = ?, repo_website = ? WHERE repo_id = ?")
845 .bind(repo.description ?? null, repo.website ?? null, event.data.repoId)
Fast pages, required checks on the branch, self-hosted runners, honest incidents846 .run();
847 }
848 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look849 return;
850 }
851 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
852 // Asked of repos, so changes delivered out of order end the same.
853 const repo = await this.repoById(event.data.repoId);
854 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
855 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
856 return;
857 }
858 if (event.type === "repo.default_branch_changed") {
859 // Asked of repos, so changes delivered out of order end the same.
860 const repo = await this.repoById(event.data.repoId);
861 await this.db
862 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
863 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
864 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production865 // Another branch has other files: they are read again from its head.
866 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
867 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
868 return;
869 }
870 if (
871 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
872 event.data.repoId
873 ) {
874 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
875 for (const row of rows.results) await this.detect(row, null, "packages");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look876 return;
877 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member878 if (event.type === "workspace.deleting") {
879 // Hidden, not gone: every project it shows, including any building
880 // from a repository it transferred away, until staff restore it or it
881 // is purged. Those already hidden stay as they were.
882 if (isProtectedWorkspace(event.data.slug)) return;
883 const at = now();
884 await this.db
885 .prepare(
886 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
887 )
888 .bind(at, event.data.slug.toLowerCase())
889 .run();
890 return;
891 }
892 if (event.type === "workspace.restored") {
893 await this.db
894 .prepare(
895 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
896 )
897 .bind(event.data.slug.toLowerCase())
898 .run();
899 return;
900 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look901 if (event.type === "workspace.deleted") {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member902 // Its own repositories' projects go with them (`repo.purged`); any
903 // building from a repository it transferred away goes now. It is not
904 // backfilled again.
905 const slug = event.data.slug.toLowerCase();
906 const ids = "SELECT id FROM projects WHERE workspace = ?1";
907 await this.db.batch([
Projects: pinned and recent projects, per person per workspace908 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
909 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member910 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
911 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
912 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look913 return;
914 }
Project dependencies: addresses, preview stacks, Affects, and agents who know915 if (event.type === "git.push" && event.data.defaultBranch) {
916 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production917 for (const row of rows.results) {
918 await this.detect(row, event.data.after);
919 }
Project dependencies: addresses, preview stacks, Affects, and agents who know920 return;
921 }
Projects: what a workspace builds and runs, first on every page922 if (event.type !== "repo.created") return;
923 const actor = await this.workspaceActor(event.data.namespace);
924 if (!actor) return;
925 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
926 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
927 }
928}
929
Fast pages, required checks on the branch, self-hosted runners, honest incidents930/** One RPC method's answer. */
931async function answer(service: Projects, method: string, args: any): Promise<Response> {
932 switch (method) {
933 case "list":
934 return Response.json(await service.list(args));
935 case "get":
936 return Response.json(await service.get(args));
937 case "by_repo":
938 return Response.json(await service.byRepo(args));
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member939 case "count":
940 return Response.json(await service.count(args));
Fast pages, required checks on the branch, self-hosted runners, honest incidents941 case "create":
942 return Response.json(await service.create(args));
943 case "update":
944 return Response.json(await service.update(args));
A project is an app or a library: libraries show their package and how to ship a release, not production945 case "deployments_changed":
946 await service.deploymentsChanged(args);
947 return Response.json(null);
Projects: pinned and recent projects, per person per workspace948 case "shortcuts":
949 return Response.json(await service.shortcuts(args));
950 case "pin":
951 return Response.json(await service.pin(args));
952 case "unpin":
953 return Response.json(await service.unpin(args));
954 case "reorder_pins":
955 return Response.json(await service.reorderPins(args));
956 case "visited":
957 await service.visited(args);
958 return Response.json(null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97959 case "public_links":
960 return Response.json(await service.publicLinks(args));
Fast pages, required checks on the branch, self-hosted runners, honest incidents961 default:
962 return new Response("Unknown method\n", { status: 404 });
963 }
964}
965
Projects: what a workspace builds and runs, first on every page966export default {
A project is an app or a library: libraries show their package and how to ship a release, not production967 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Projects: what a workspace builds and runs, first on every page968 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
969 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents970 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
971 const opened = openD1(env.DB, request);
A project is an app or a library: libraries show their package and how to ship a release, not production972 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page973 const args = (await request.json().catch(() => ({}))) as any;
Fast pages, required checks on the branch, self-hosted runners, honest incidents974 return opened.finish(await answer(service, match[1], args));
Projects: what a workspace builds and runs, first on every page975 },
976
977 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
978 const service = new Projects(env);
979 for (const message of batch.messages) {
980 try {
981 await service.onEvent(message.body);
982 message.ack();
983 } catch (error) {
984 console.error("projects could not handle", message.body.type, error);
985 message.retry();
986 }
987 }
988 },
989} satisfies ExportedHandler<Env, G1tEvent>;

This file's history is long; its oldest lines are credited to the oldest commit read.