Skip to content
1,118 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.

Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1/**
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
15
16import { NEEDS, repoRef } from "./access";
17import { 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";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays34import { renameStatements } from "./rename";
35import { moveStatements, slugOf, strandedQuery } from "./transfer";
Merge branch 'worktree-agent-a5b0d9e2733e9e382'36import { checkProductionImage, productionImageKey, productionImageOf, sha256, today, wantsScreenshot } from "./production-image";
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays37
38import {
39 can,
40 currentMovedPath,
41 currentWorkspaceSlug,
42 fail,
43 identityClient,
44 staleSlugs,
45 needs,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member46 isProtectedWorkspace,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays47 newId,
48 ok,
49 openD1,
A project is an app or a library: libraries show their package and how to ship a release, not production50 packagesClient,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays51 permission,
52 repoMove,
53 reposClient,
54 staleMovedPaths,
A project is an app or a library: libraries show their package and how to ship a release, not production55 type Ecosystem,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays56 type G1tEvent,
57 type NewProject,
58 type Project,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9759 type ProjectChanges,
Merge branch 'worktree-agent-a5b0d9e2733e9e382'60 type ProductionImageChange,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9761 type ProjectKind,
A project is an app or a library: libraries show their package and how to ship a release, not production62 type ProjectEcosystem,
Projects: pinned and recent projects, per person per workspace63 type ProjectShortcuts,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays64 type Repo,
65 type Result,
66 type ServiceBinding,
67 type User,
68 type Viewer,
69} from "@g1t/contracts";
70
71type Env = {
72 DB: D1Database;
73 REPOS: ServiceBinding;
74 IDENTITY: ServiceBinding;
A project is an app or a library: libraries show their package and how to ship a release, not production75 PACKAGES: ServiceBinding;
76 DEPLOYMENTS: ServiceBinding;
Merge branch 'worktree-agent-a5b0d9e2733e9e382'77 /** The avatars namespace: uploaded production images, under `production/<sha256>`, which the usercontent origin serves. */
78 AVATARS: KVNamespace;
79 /**
80 * The og service's `Screenshots` entrypoint: a screenshot of a production
81 * deployed elsewhere, taken when its address is set or changed and when
82 * a production deployment is reported. Absent: none is asked for.
83 */
84 SCREENSHOTS?: { captureExternal(input: { url: string; commit: string }): Promise<boolean> };
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays85};
86
87type Row = {
88 id: string;
89 workspace: string;
90 slug: string;
91 name: string;
92 /** The project's own description; null while it follows its repository's. */
93 description: string | null;
94 /** Its repository's description, kept from repos. */
95 repo_description?: string | null;
96 source_kind: string;
97 repo_id: string;
98 repo_namespace: string;
99 repo_name: string;
100 repo_private: number;
101 default_branch: string;
102 root_dir: string;
103 is_primary: number;
104 created_by: string;
105 created_at: string;
106 updated_at: string;
107 /** Set while its repository is deleted; the project is hidden until it is restored. */
108 repo_deleted_at: string | null;
109 /** When its repository was archived; null while it is not. */
110 repo_archived_at?: string | null;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97111 /** What a person set it to be and where it runs; null left to detection (migrations/0010_kind_and_links.sql). */
112 kind_setting?: string | null;
113 runs_setting?: string | null;
114 production_url?: string | null;
Merge branch 'worktree-agent-a5b0d9e2733e9e382'115 /** An uploaded image of production, by its SHA-256 (migrations/0012_production_image.sql). */
116 production_image?: string | null;
117 production_image_type?: string | null;
118 production_image_preferred?: number | null;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97119 /** Its own homepage; null follows `repo_website`. */
120 homepage?: string | null;
121 repo_website?: string | null;
122 docs_url?: string | null;
123 /** Its other links, as JSON (src/links.ts). */
124 links?: string | null;
A project is an app or a library: libraries show their package and how to ship a release, not production125 detected_kind?: string | null;
126 detected_detail?: string | null;
127 detected_ecosystem?: string | null;
128 /** The default branch's commit its files were read at; null until they have been. */
129 detected_commit?: string | null;
130 linked_package?: string | null;
131 deployments_on?: number | null;
Projects: when each was last pushed, and how active it is lately132 /** When its repository was last pushed to (migrations/0009_activity.sql). */
133 pushed_at?: string | null;
134 /** How active it is lately, as of `active_at` (src/activity.ts). */
135 activity?: number | null;
136 active_at?: string | null;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays137};
138
A project is an app or a library: libraries show their package and how to ship a release, not production139/** How a package its repository publishes is named in why a project is a library. */
140const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
141 container: "container image",
142 npm: "npm package",
143 composer: "Composer package",
144 cargo: "crate",
145 go: "Go module",
Projects: a library publishing a Maven, NuGet or RubyGems package says so146 maven: "Maven package",
147 nuget: "NuGet package",
148 rubygems: "gem",
A project is an app or a library: libraries show their package and how to ship a release, not production149};
150
151/** How many projects one listing reads the files of, in the background, before it has. */
152const DETECT_PER_LIST = 10;
153
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays154const now = () => new Date().toISOString();
155
156function toProject(row: Row): Project {
157 const { description, inherited } = effectiveDescription(row);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97158 const setting = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
159 const facts: Omit<KindFacts, "setting"> = {
A project is an app or a library: libraries show their package and how to ship a release, not production160 deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
161 linkedPackage: row.linked_package ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97162 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 production163 };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97164 const { kind, reason, runs } = resolveKind({ setting, ...facts });
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays165 return {
166 id: row.id,
167 workspace: row.workspace,
168 slug: row.slug,
169 name: row.name,
170 description,
171 descriptionInherited: inherited,
172 source: {
173 kind: "hosted",
174 repoId: row.repo_id,
175 repo: { namespace: row.repo_namespace, name: row.repo_name },
176 rootDir: row.root_dir,
177 defaultBranch: row.default_branch,
178 },
179 private: !!row.repo_private,
180 archived: !!row.repo_archived_at,
181 primary: !!row.is_primary,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97182 setting,
A project is an app or a library: libraries show their package and how to ship a release, not production183 kind,
184 kindReason: reason,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97185 runs,
186 productionUrl: row.production_url ?? null,
Merge branch 'worktree-agent-a5b0d9e2733e9e382'187 productionImage: productionImageOf(row),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97188 detected: detectedKind(facts),
A project is an app or a library: libraries show their package and how to ship a release, not production189 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97190 links: projectLinks(row),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays191 createdBy: row.created_by,
192 createdAt: row.created_at,
193 updatedAt: row.updated_at,
Projects: when each was last pushed, and how active it is lately194 pushedAt: row.pushed_at ?? null,
195 activity: decayed(row.activity ?? 0, row.active_at ?? null, now()),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays196 };
197}
198
199function isMember(viewer: Viewer, workspace: string): boolean {
200 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
201}
202
203class Projects {
A project is an app or a library: libraries show their package and how to ship a release, not production204 /** `defer` runs work after the answer is sent: the request's waitUntil. */
205 constructor(
206 private readonly env: Env,
207 private readonly defer: (work: Promise<unknown>) => void = () => {},
208 ) {}
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays209
210 private get db() {
211 return this.env.DB;
212 }
213
214 private async workspaceActor(slug: string): Promise<User | null> {
215 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
216 if (!workspace) return null;
217 return {
218 id: workspace.id,
219 username: workspace.slug,
220 kind: "workspace",
221 verified: true,
222 workspaces: [{ slug: workspace.slug, role: "member" }],
223 };
224 }
225
226 /** A free slug for `wanted` in the workspace. */
227 private async freeSlug(workspace: string, wanted: string): Promise<string> {
228 const base = slugOf(wanted) || "project";
229 for (let n = 1; n < 100; n++) {
230 const slug = n === 1 ? base : `${base}-${n}`;
231 const taken = await this.db
232 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
233 .bind(workspace, slug)
234 .first();
235 if (!taken) return slug;
236 }
237 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
238 }
239
240 /** Gives a repository its own project, unless it has one. */
241 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
242 if (repo.forkOf) return;
243 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
244 if (existing) {
245 await this.db
246 .prepare(
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97247 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ?, repo_description = ?, repo_website = ? WHERE repo_id = ?",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays248 )
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97249 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.description ?? null, repo.website ?? null, repo.id)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays250 .run();
251 return;
252 }
253 const at = now();
254 await this.db
255 .prepare(
256 // No description of its own: it shows the repository's, as that changes.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97257 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_website, repo_id, repo_namespace, repo_name, repo_private,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays258 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97259 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays260 ON CONFLICT (workspace, slug) DO NOTHING`,
261 )
262 .bind(
263 newId("prj"),
264 repo.namespace.toLowerCase(),
265 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
266 repo.name,
267 repo.description ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97268 repo.website ?? null,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays269 repo.id,
270 repo.namespace,
271 repo.name,
272 repo.isPrivate ? 1 : 0,
273 repo.defaultBranch,
274 createdBy,
275 at,
276 at,
277 repo.archivedAt ?? null,
278 )
279 .run();
280 }
281
282 /** Projects for a workspace's repositories made before projects existed. Once. */
283 private async backfill(workspace: string): Promise<void> {
284 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
285 if (done) return;
286 const actor = await this.workspaceActor(workspace);
287 if (!actor) return;
288 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
289 for (const repo of list) {
290 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
291 }
292 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
293 }
294
295 /**
296 * Whether the viewer can read the project's repository. The workspace's
297 * own token sees all its projects, also one left building from a
298 * repository that moved to another workspace.
299 */
300 private visible(row: Row, viewer: Viewer): boolean {
301 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
302 return permission(viewer, repoRef(row)) != null;
303 }
304
305 /**
306 * Whether the actor may change the project: not found when they cannot
307 * read its repository, refused when their role there is too low.
308 */
309 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
310 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
311 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
312 return ok(true);
313 }
314
315 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
316 const workspace = a.workspace.toLowerCase();
317 await this.backfill(workspace);
318 const rows = await this.db
319 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
320 .bind(workspace)
321 .all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production322 // Projects whose files have not been read yet are read after this answer,
323 // a few at a time; until then they show as apps, as before.
324 const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
325 if (unread.length) this.defer(Promise.all(unread.map((row) => this.detect(row).catch((error) => console.warn("detect", row.slug, error)))));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays326 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
327 }
328
329 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
330 const workspace = a.workspace.toLowerCase();
331 await this.backfill(workspace);
332 const row = await this.db
333 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
334 .bind(workspace, a.slug.toLowerCase())
335 .first<Row>();
336 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 production337 if (row.detected_commit == null) {
338 // Once per project: what its files say, read now so its first page is right.
339 const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
340 if (read) return ok(toProject(read));
341 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays342 return ok(toProject(row));
343 }
344
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member345 /** How many projects a workspace shows: what deleting it would take with it. */
346 async count(a: { workspace: string }): Promise<number> {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays347 const row = await this.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member348 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL")
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays349 .bind(a.workspace.toLowerCase())
350 .first<{ n: number }>();
351 return row?.n ?? 0;
352 }
353
354 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
355 private async repoById(repoId: string): Promise<Repo | null> {
356 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
357 method: "POST",
358 headers: { "content-type": "application/json" },
359 body: JSON.stringify({ id: repoId }),
360 });
361 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
362 if (!repoPath) return null;
363 const actor = await this.workspaceActor(repoPath.namespace);
364 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
365 return repo?.ok ? repo.value : null;
366 }
367
368 async byRepo(a: { repoId: string }): Promise<Project[]> {
369 const rows = await this.db
370 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
371 .bind(a.repoId)
372 .all<Row>();
373 // A deleted repository's projects are hidden until it is restored.
374 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
375 // A repository from before projects: give it its own now.
376 const repo = await this.repoById(a.repoId);
377 if (!repo) return [];
378 await this.ensureFor(repo, "g1t");
379 const again = await this.db
380 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
381 .bind(a.repoId)
382 .all<Row>();
383 return again.results.map(toProject);
384 }
385
386 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
387 const workspace = a.workspace.toLowerCase();
388 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
389 if (a.input.repo.namespace.toLowerCase() !== workspace) {
390 return fail("invalid", "A project builds from one of its own workspace's repositories.");
391 }
392 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
393 if (!repo.ok) return repo;
394 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
395 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
396 const name = a.input.name.trim();
397 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
398 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
399 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
400 const slug = slugOf(name);
401 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
402 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
403 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
404 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
405 const at = now();
406 const id = newId("prj");
407 await this.db
408 .prepare(
409 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
410 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
411 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
412 )
413 .bind(
414 id,
415 workspace,
416 slug,
417 name,
418 ownDescription(null, a.input.description ?? null),
419 repo.value.description ?? null,
420 repo.value.id,
421 repo.value.namespace,
422 repo.value.name,
423 repo.value.isPrivate ? 1 : 0,
424 repo.value.defaultBranch,
425 rootDir,
426 primary ? 1 : 0,
427 a.actor.username,
428 at,
429 at,
430 repo.value.archivedAt ?? null,
431 )
432 .run();
433 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
434 }
435
436 async update(a: {
437 actor: User;
438 workspace: string;
439 slug: string;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97440 changes: ProjectChanges;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays441 }): Promise<Result<Project>> {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97442 const changes = a.changes ?? {};
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays443 const workspace = a.workspace.toLowerCase();
444 const row = await this.db
445 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
446 .bind(workspace, a.slug.toLowerCase())
447 .first<Row>();
448 if (!row) return fail("not_found", "There is no such project.");
449 const allowed = this.changeable(row, a.actor, NEEDS.update);
450 if (!allowed.ok) return allowed;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97451 const name = changes.name?.trim() || row.name;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays452 // Blank or null goes back to following the repository's description.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97453 const description = ownDescription(row.description, changes.description);
454 const rootDir = changes.rootDir === undefined ? row.root_dir : String(changes.rootDir).trim().replace(/^\/+|\/+$/g, "");
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays455 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97456 const known = (value: unknown, allowed: readonly string[], what: string) =>
457 value === undefined || allowed.includes(value as string) ? null : fail("invalid", `${what} is one of ${allowed.join(", ")}.`);
458 const badKind = known(changes.kind, ["auto", "app", "library", "tool", "docs", "other"], "kind");
459 if (badKind) return badKind;
460 const badRuns = known(changes.runs, ["auto", "g1t", "elsewhere"], "runs");
461 if (badRuns) return badRuns;
462 const before = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
463 const setting = nextSetting(before, { kind: changes.kind as ProjectKind | "auto" | undefined, runs: changes.runs });
464 // Something that never runs, while Deployments run, would leave apps up
465 // that no page offers: a person turns them off first.
466 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 production467 return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
468 }
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97469 const url = (value: string | null | undefined, current: string | null | undefined, what: string) =>
470 value === undefined ? { ok: true as const, value: current ?? null } : cleanUrl(value, what);
471 const production = url(changes.productionUrl, row.production_url, "The production address");
472 if (!production.ok) return fail("invalid", production.message);
473 const homepage = url(changes.homepage, row.homepage, "The homepage");
474 if (!homepage.ok) return fail("invalid", homepage.message);
475 const docs = url(changes.docsUrl, row.docs_url, "The docs address");
476 if (!docs.ok) return fail("invalid", docs.message);
477 let linksJson = row.links ?? null;
478 if (changes.links !== undefined) {
479 const links = cleanLinks(changes.links);
480 if (!links.ok) return fail("invalid", links.message);
481 linksJson = links.value.length ? JSON.stringify(links.value) : null;
482 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays483 await this.db
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97484 .prepare(
485 `UPDATE projects SET name = ?, description = ?, root_dir = ?, kind_setting = ?, runs_setting = ?, production_url = ?,
486 homepage = ?, docs_url = ?, links = ?, updated_at = ? WHERE id = ?`,
487 )
488 .bind(
489 name.slice(0, 100),
490 description,
491 rootDir,
492 setting.kind,
493 setting.runs,
494 production.value,
495 homepage.value,
496 docs.value,
497 linksJson,
498 now(),
499 row.id,
500 )
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays501 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production502 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
503 // Another root has other files: read them again.
504 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
Merge branch 'worktree-agent-a5b0d9e2733e9e382'505 const project = toProject(saved);
506 // Production elsewhere given an address, or a new one: its screenshot, now.
507 const was = toProject(row);
508 if (wantsScreenshot(project) && (project.productionUrl !== was.productionUrl || was.runs !== "elsewhere")) {
509 this.screenshot(project.productionUrl!, today());
510 }
511 return ok(project);
512 }
513
514 /**
515 * Asks og for a screenshot of a production deployed elsewhere (og takes
516 * it in the background). Never fails: the overview and the daily
517 * refresh take it later anyway.
518 */
519 private async capture(url: string, commit: string): Promise<void> {
520 await this.env.SCREENSHOTS?.captureExternal({ url, commit }).catch((error) => console.warn("could not ask for a screenshot", url, error));
521 }
522
523 /** The same, after the answer is sent. */
524 private screenshot(url: string, commit: string): void {
525 this.defer(this.capture(url, commit));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays526 }
527
Merge branch 'worktree-agent-a5b0d9e2733e9e382'528 async setProductionImage(a: {
529 actor: User;
530 workspace: string;
531 slug: string;
532 change: ProductionImageChange;
533 }): Promise<Result<Project>> {
534 const change = a.change ?? {};
535 const row = await this.db
536 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
537 .bind(a.workspace.toLowerCase(), a.slug.toLowerCase())
538 .first<Row>();
539 if (!row) return fail("not_found", "There is no such project.");
540 // Whoever may change its production address.
541 const allowed = this.changeable(row, a.actor, NEEDS.update);
542 if (!allowed.ok) return allowed;
543 let file = row.production_image ?? null;
544 let type = row.production_image_type ?? null;
545 let preferred = !!row.production_image_preferred;
546 if (change.image === null) {
547 file = null;
548 type = null;
549 preferred = false;
550 } else if (change.image !== undefined) {
551 const checked = checkProductionImage(change.image);
552 if (!checked.ok) return fail("invalid", checked.message);
553 file = await sha256(checked.bytes);
554 type = checked.contentType;
555 // Kept by its hash, so the same image stored twice is one file; its
556 // type is the one read from its bytes (usercontent serves only that).
557 await this.env.AVATARS.put(productionImageKey(file), checked.bytes, { metadata: { contentType: type } });
558 }
559 if (change.preferred !== undefined) {
560 if (change.preferred && !file) return fail("invalid", "Upload an image first.");
561 preferred = !!change.preferred;
562 }
563 await this.db
564 .prepare("UPDATE projects SET production_image = ?, production_image_type = ?, production_image_preferred = ?, updated_at = ? WHERE id = ?")
565 .bind(file, type, preferred ? 1 : 0, now(), row.id)
566 .run();
567 // The image it replaced goes, unless another project shows it too.
568 const old = row.production_image;
569 if (old && old !== file) {
570 const used = await this.db.prepare("SELECT 1 FROM projects WHERE production_image = ? LIMIT 1").bind(old).first();
571 if (!used) this.defer(this.env.AVATARS.delete(productionImageKey(old)).catch((error) => console.warn("could not delete an image", error)));
572 }
573 const saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
574 const project = toProject(saved);
575 // No longer shown instead of a screenshot: one is taken, if none is fresh.
576 if (row.production_image_preferred && !preferred && wantsScreenshot(project)) this.screenshot(project.productionUrl!, today());
577 return ok(project);
578 }
579
580 /** Every production address deployed elsewhere that shows a screenshot, once each. */
581 async externalProductions(): Promise<string[]> {
582 const rows = await this.db
583 .prepare(
584 `SELECT * FROM projects WHERE production_url IS NOT NULL AND repo_deleted_at IS NULL
585 AND (runs_setting IS NULL OR runs_setting = 'elsewhere') AND production_image_preferred = 0`,
586 )
587 .all<Row>();
588 const urls = rows.results.map(toProject).filter(wantsScreenshot).map((project) => project.productionUrl!);
589 return [...new Set(urls)];
590 }
591
592 /**
593 * A production deployment reported from outside g1t.page succeeded: a
594 * screenshot of each of its repository's projects deployed elsewhere,
595 * at the address the deployment gives, else the project's own.
596 */
597 private async productionReported(data: Extract<G1tEvent, { type: "deployment_status.created" }>["data"]): Promise<void> {
598 const { deployment, deploymentStatus } = data;
599 if (deploymentStatus.state !== "success" || !deployment.production_environment || deployment.source === "g1t_page") return;
600 const rows = await this.db
601 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL ORDER BY is_primary DESC")
602 .bind(data.repoId)
603 .all<Row>();
604 // The repository's own project first: the overview shows the reported
605 // production on it, as running elsewhere when nobody has said otherwise.
606 const project = rows.results
607 .map(toProject)
608 .find((one) => one.runs === "elsewhere" || (one.runs === null && one.kind === "app"));
609 if (!project || project.productionImage?.preferred) return;
610 const url = deploymentStatus.environment_url ?? deployment.environment_url ?? project.productionUrl;
611 if (url) await this.capture(url, deployment.sha);
612 }
613
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97614 async publicLinks(a: { repos: { namespace: string; name: string }[] }): Promise<Record<string, string>> {
615 const keys = [...new Set((Array.isArray(a.repos) ? a.repos : []).slice(0, 100).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))];
616 if (keys.length === 0) return {};
617 const rows = await this.db
618 .prepare(
619 `SELECT * FROM projects WHERE is_primary = 1 AND repo_private = 0 AND repo_deleted_at IS NULL
620 AND lower(repo_namespace || '/' || repo_name) IN (${keys.map(() => "?").join(", ")})`,
621 )
622 .bind(...keys)
623 .all<Row>();
624 const out: Record<string, string> = {};
625 for (const row of rows.results) {
626 const project = toProject(row);
627 const link = (project.runs === "elsewhere" ? project.productionUrl : null) ?? project.links.homepage ?? project.links.docs;
628 if (link) out[`${row.repo_namespace}/${row.repo_name}`.toLowerCase()] = link;
629 }
630 return out;
631 }
632
A project is an app or a library: libraries show their package and how to ship a release, not production633 async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97634 // Turned on, it runs on g1t: one said to run elsewhere moved here.
635 await this.db
636 .prepare(
637 "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",
638 )
639 .bind(a.enabled ? 1 : 0, a.projectId)
640 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production641 }
642
Projects: pinned and recent projects, per person per workspace643 // ---- Pinned and recent ---------------------------------------------------
644
645 /** The person's pins in the workspace, in order, as rows: hidden projects left out. */
646 private async pinRows(userId: string, workspace: string): Promise<Row[]> {
647 const rows = await this.db
648 .prepare(
649 `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id
650 WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`,
651 )
652 .bind(userId, workspace.toLowerCase())
653 .all<Row>();
654 return rows.results;
655 }
656
657 /** Writes the person's pins in the workspace in this order, by project id. */
658 private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> {
659 const at = now();
660 await this.db.batch([
661 ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)),
662 ...order.map((id, position) =>
663 this.db
664 .prepare(
665 `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?)
666 ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`,
667 )
668 .bind(userId, id, position, at),
669 ),
670 ]);
671 }
672
673 /** The person behind a call about their own pins, or why there is none. */
674 private person(viewer: Viewer): Result<User> {
675 if (!viewer) return fail("unauthenticated", "Sign in to pin projects.");
676 if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token.");
677 return ok(viewer);
678 }
679
680 /** A project the person can see, for pinning. */
681 private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> {
682 const row = await this.row(workspace, slug);
683 if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project.");
684 return ok(row);
685 }
686
687 async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> {
688 const who = this.person(a.viewer);
689 if (!who.ok) return { pinned: [], recent: [] };
690 const workspace = a.workspace.toLowerCase();
691 const [pinned, visited] = await Promise.all([
692 this.pinRows(who.value.id, workspace),
693 this.db
694 .prepare(
695 `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id
696 WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`,
697 )
698 .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT)
699 .all<Row>(),
700 ]);
701 const shown = (row: Row) => this.visible(row, who.value);
702 const pins = pinned.filter(shown);
703 return {
704 pinned: pins.map(toProject),
705 recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject),
706 };
707 }
708
709 async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> {
710 const who = this.person(a.actor);
711 if (!who.ok) return who;
712 const found = await this.pinnable(who.value, a.workspace, a.slug);
713 if (!found.ok) return found;
714 const current = await this.pinRows(who.value.id, found.value.workspace);
715 const order = placePin(
716 current.map((row) => row.id),
717 found.value.id,
718 a.position,
719 );
720 if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`);
721 await this.writePins(who.value.id, order);
722 return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
723 }
724
725 async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> {
726 const who = this.person(a.actor);
727 if (!who.ok) return who;
728 const row = await this.row(a.workspace, a.slug);
729 if (!row) return fail("not_found", "There is no such project.");
730 const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id);
731 await this.writePins(who.value.id, rest, [row.id]);
732 return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject));
733 }
734
735 async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> {
736 const who = this.person(a.actor);
737 if (!who.ok) return who;
738 if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order.");
739 const workspace = a.workspace.toLowerCase();
740 const current = await this.pinRows(who.value.id, workspace);
741 const idOf = new Map(current.map((row) => [row.slug, row.id]));
742 const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug));
743 const order = reorderPins(
744 current.map((row) => row.id),
745 wanted,
746 );
747 if (!order.ok) return fail("invalid", order.message);
748 await this.writePins(who.value.id, order.order);
749 return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
750 }
751
752 async visited(a: { actor: User; projectId: string }): Promise<void> {
753 const who = this.person(a.actor);
754 if (!who.ok || !a.projectId) return;
755 await this.db.batch([
756 this.db
757 .prepare(
758 `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?)
759 ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`,
760 )
761 .bind(who.value.id, a.projectId, now()),
762 // Only their latest few are kept.
763 this.db
764 .prepare(
765 `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN
766 (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`,
767 )
768 .bind(who.value.id, MAX_VISITS),
769 ]);
770 }
771
A project is an app or a library: libraries show their package and how to ship a release, not production772 // ---- App or library --------------------------------------------------
773
774 /**
775 * Reads what decides whether the project is a library: the manifests at
776 * its root at `commit` (the default branch's head when null), a package
777 * its repository publishes, and, the first time, whether Deployments are
778 * on. Files are read again only for a commit they were not read at.
779 * `packages` reads only the packages. Returns the row as it is now.
780 */
781 private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
782 const actor = await this.workspaceActor(row.workspace);
783 if (!actor) return row;
784 const repo = { namespace: row.repo_namespace, name: row.repo_name };
785 const sets: string[] = [];
786 const values: unknown[] = [];
787 const set = (column: string, value: unknown) => {
788 sets.push(`${column} = ?`);
789 values.push(value);
790 };
791 const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
792 const [files, linked, deploying] = await Promise.all([
793 filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
794 this.linkedPackage(row, actor),
795 row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
796 ]);
797 if (files) {
798 const found = files.commit ? detectKind(files.root) : null;
799 set("detected_kind", found?.kind ?? null);
800 set("detected_detail", found?.detail ?? null);
801 set("detected_ecosystem", found?.ecosystem ?? null);
802 set("detected_commit", files.commit);
803 }
804 if (linked !== undefined) set("linked_package", linked);
805 if (deploying != null) set("deployments_on", deploying ? 1 : 0);
806 if (sets.length === 0) return row;
807 await this.db
808 .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
809 .bind(...values, row.id)
810 .run();
811 return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
812 }
813
814 /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
815 private async readRoot(
816 repo: { namespace: string; name: string },
817 actor: User,
818 commit: string | null,
819 rootDir: string,
820 ): Promise<{ commit: string; root: RootFiles } | null> {
821 const client = reposClient(this.env.REPOS);
822 const empty = { commit: "", root: { entries: [], text: {} } };
823 const tree = await client.tree(repo, actor, commit, rootDir);
824 if (!tree.ok) return null;
825 const head = commit ?? tree.value.head?.hash ?? "";
826 if (!head) return empty;
827 const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
828 const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
829 const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
830 const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
831 const [texts, src, pub] = await Promise.all([
832 Promise.all(
833 names.map(async (name) => {
834 const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
835 return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
836 }),
837 ),
838 entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
839 entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
840 ]);
841 const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
842 return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
843 }
844
845 /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
846 private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
847 const listed = await packagesClient(this.env.PACKAGES)
848 .list(row.workspace, actor, { repoId: row.repo_id })
849 .catch(() => null);
850 if (!listed?.ok) return undefined;
851 // An image is how an app ships too; it says nothing about being a library.
852 const pkg = listed.value.find((p) => p.ecosystem !== "container");
853 if (!pkg) return null;
854 return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
855 }
856
857 /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
858 private async deploymentsOn(projectId: string): Promise<boolean | null> {
859 const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
860 method: "POST",
861 headers: { "content-type": "application/json" },
862 body: JSON.stringify({ projectId }),
863 }).catch(() => null);
864 if (!answer?.ok) return null;
865 const value = (await answer.json().catch(() => null)) as unknown;
866 return typeof value === "boolean" ? value : null;
867 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays868
869 private async row(workspace: string, slug: string): Promise<Row | null> {
870 return this.db
871 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
872 .bind(workspace.toLowerCase(), slug.toLowerCase())
873 .first<Row>();
874 }
875
Projects: when each was last pushed, and how active it is lately876 /** Something happened to a repository's code or work: its projects are that much more active. */
877 private async active(repoId: string, at: string, pushed: boolean): Promise<void> {
878 const rows = await this.db
879 .prepare("SELECT id, activity, active_at FROM projects WHERE repo_id = ?")
880 .bind(repoId)
881 .all<{ id: string; activity: number | null; active_at: string | null }>();
882 if (rows.results.length === 0) return;
883 await this.db.batch(
884 rows.results.map((row) => {
885 const next = raised(row.activity ?? 0, row.active_at, at);
886 return pushed
887 ? this.db
888 .prepare("UPDATE projects SET activity = ?, active_at = ?, pushed_at = MAX(COALESCE(pushed_at, ''), ?) WHERE id = ?")
889 .bind(next.score, next.at, at, row.id)
890 : this.db.prepare("UPDATE projects SET activity = ?, active_at = ? WHERE id = ?").bind(next.score, next.at, row.id);
891 }),
892 );
893 }
894
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays895 async onEvent(event: G1tEvent): Promise<void> {
Projects: when each was last pushed, and how active it is lately896 if (ACTIVE.has(event.type)) {
897 const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
898 if (repoId) await this.active(repoId, event.time, event.type === "git.push");
899 }
Merge branch 'worktree-agent-a5b0d9e2733e9e382'900 if (event.type === "deployment_status.created") {
901 await this.productionReported(event.data);
902 return;
903 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays904 if (event.type === "workspace.renamed") {
905 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
906 const statements = renameStatements(staleSlugs(event.data, current), current);
907 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
908 return;
909 }
910 const move = repoMove(event);
911 if (move) {
912 const current = await currentMovedPath(this.env.REPOS, move);
913 const stale = staleMovedPaths(move, current);
914 if (stale.length === 0) return;
915 const statements = moveStatements(stale, current, move.repoId);
916 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
917 // A project whose slug the destination already had moves under a free one.
918 const query = strandedQuery(stale, current, move.repoId);
919 if (!query) return;
920 const workspace = current.split("/")[0]!;
921 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
922 for (const row of stranded.results) {
923 const slug = await this.freeSlug(workspace, row.slug);
924 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
925 await this.db
926 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
927 .bind(workspace, slug, workspace, now(), row.id)
928 .run();
929 }
930 return;
931 }
932 if (event.type === "repo.deleted") {
933 // Hidden, not gone: a restore brings its projects back as they were.
934 await this.db
935 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
936 .bind(now(), event.data.repoId)
937 .run();
938 return;
939 }
940 if (event.type === "repo.restored") {
941 await this.db
942 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
943 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
944 .run();
945 return;
946 }
947 if (event.type === "repo.purged") {
948 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
949 await this.db.batch([
Projects: pinned and recent projects, per person per workspace950 // Pins and visits of what is gone go with it.
951 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
952 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays953 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
954 ]);
955 return;
956 }
957 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
958 // Who may see its projects follows who may see the repository.
959 await this.db
960 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
961 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
962 .run();
963 if (event.type === "repo.updated") {
964 // Projects without a description of their own show the repository's.
965 // Asked of repos, so changes delivered out of order end the same.
966 const repo = await this.repoById(event.data.repoId);
967 if (repo) {
968 await this.db
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97969 .prepare("UPDATE projects SET repo_description = ?, repo_website = ? WHERE repo_id = ?")
970 .bind(repo.description ?? null, repo.website ?? null, event.data.repoId)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays971 .run();
972 }
973 }
974 return;
975 }
976 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
977 // Asked of repos, so changes delivered out of order end the same.
978 const repo = await this.repoById(event.data.repoId);
979 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
980 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
981 return;
982 }
983 if (event.type === "repo.default_branch_changed") {
984 // Asked of repos, so changes delivered out of order end the same.
985 const repo = await this.repoById(event.data.repoId);
986 await this.db
987 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
988 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
989 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production990 // Another branch has other files: they are read again from its head.
991 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
992 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
993 return;
994 }
995 if (
996 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
997 event.data.repoId
998 ) {
999 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1000 for (const row of rows.results) await this.detect(row, null, "packages");
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1001 return;
1002 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1003 if (event.type === "workspace.deleting") {
1004 // Hidden, not gone: every project it shows, including any building
1005 // from a repository it transferred away, until staff restore it or it
1006 // is purged. Those already hidden stay as they were.
1007 if (isProtectedWorkspace(event.data.slug)) return;
1008 const at = now();
1009 await this.db
1010 .prepare(
1011 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
1012 )
1013 .bind(at, event.data.slug.toLowerCase())
1014 .run();
1015 return;
1016 }
1017 if (event.type === "workspace.restored") {
1018 await this.db
1019 .prepare(
1020 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
1021 )
1022 .bind(event.data.slug.toLowerCase())
1023 .run();
1024 return;
1025 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1026 if (event.type === "workspace.deleted") {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1027 // Its own repositories' projects go with them (`repo.purged`); any
1028 // building from a repository it transferred away goes now. It is not
1029 // backfilled again.
1030 const slug = event.data.slug.toLowerCase();
1031 const ids = "SELECT id FROM projects WHERE workspace = ?1";
1032 await this.db.batch([
Projects: pinned and recent projects, per person per workspace1033 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
1034 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 member1035 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
1036 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
1037 ]);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1038 return;
1039 }
1040 if (event.type === "git.push" && event.data.defaultBranch) {
1041 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 production1042 for (const row of rows.results) {
1043 await this.detect(row, event.data.after);
1044 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1045 return;
1046 }
1047 if (event.type !== "repo.created") return;
1048 const actor = await this.workspaceActor(event.data.namespace);
1049 if (!actor) return;
1050 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
1051 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
1052 }
1053}
1054
1055/** One RPC method's answer. */
1056async function answer(service: Projects, method: string, args: any): Promise<Response> {
1057 switch (method) {
1058 case "list":
1059 return Response.json(await service.list(args));
1060 case "get":
1061 return Response.json(await service.get(args));
1062 case "by_repo":
1063 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 member1064 case "count":
1065 return Response.json(await service.count(args));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1066 case "create":
1067 return Response.json(await service.create(args));
1068 case "update":
1069 return Response.json(await service.update(args));
Merge branch 'worktree-agent-a5b0d9e2733e9e382'1070 case "set_production_image":
1071 return Response.json(await service.setProductionImage(args));
1072 case "external_productions":
1073 return Response.json(await service.externalProductions());
A project is an app or a library: libraries show their package and how to ship a release, not production1074 case "deployments_changed":
1075 await service.deploymentsChanged(args);
1076 return Response.json(null);
Projects: pinned and recent projects, per person per workspace1077 case "shortcuts":
1078 return Response.json(await service.shortcuts(args));
1079 case "pin":
1080 return Response.json(await service.pin(args));
1081 case "unpin":
1082 return Response.json(await service.unpin(args));
1083 case "reorder_pins":
1084 return Response.json(await service.reorderPins(args));
1085 case "visited":
1086 await service.visited(args);
1087 return Response.json(null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971088 case "public_links":
1089 return Response.json(await service.publicLinks(args));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1090 default:
1091 return new Response("Unknown method\n", { status: 404 });
1092 }
1093}
1094
1095export default {
A project is an app or a library: libraries show their package and how to ship a release, not production1096 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1097 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
1098 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1099 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1100 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 production1101 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1102 const args = (await request.json().catch(() => ({}))) as any;
1103 return opened.finish(await answer(service, match[1], args));
1104 },
1105
1106 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1107 const service = new Projects(env);
1108 for (const message of batch.messages) {
1109 try {
1110 await service.onEvent(message.body);
1111 message.ack();
1112 } catch (error) {
1113 console.error("projects could not handle", message.body.type, error);
1114 message.retry();
1115 }
1116 }
1117 },
1118} satisfies ExportedHandler<Env, G1tEvent>;

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