Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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