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.

1,229 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, activeRepos, 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 for (const repoId of activeRepos(event.repoId, event.data)) await this.active(repoId, event.time, event.type === "git.push");
988 }
989 if (event.type === "deployment_status.created") {
990 await this.productionReported(event.data);
991 return;
992 }
993 if (event.type === "workspace.renamed") {
994 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
995 const statements = renameStatements(staleSlugs(event.data, current), current);
996 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
997 return;
998 }
999 const move = repoMove(event);
1000 if (move) {
1001 const current = await currentMovedPath(this.env.REPOS, move);
1002 const stale = staleMovedPaths(move, current);
1003 if (stale.length === 0) return;
1004 const statements = moveStatements(stale, current, move.repoId);
1005 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
1006 // A project whose slug the destination already had moves under a free one.
1007 const query = strandedQuery(stale, current, move.repoId);
1008 if (!query) return;
1009 const workspace = current.split("/")[0]!;
1010 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
1011 for (const row of stranded.results) {
1012 const slug = await this.freeSlug(workspace, row.slug);
1013 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
1014 await this.db
1015 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
1016 .bind(workspace, slug, workspace, now(), row.id)
1017 .run();
1018 }
1019 return;
1020 }
1021 if (event.type === "repo.deleted") {
1022 // Hidden, not gone: a restore brings its projects back as they were.
1023 await this.db
1024 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
1025 .bind(now(), event.data.repoId)
1026 .run();
1027 return;
1028 }
1029 if (event.type === "repo.restored") {
1030 await this.db
1031 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
1032 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
1033 .run();
1034 return;
1035 }
1036 if (event.type === "repo.purged") {
1037 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
1038 await this.db.batch([
1039 // Pins and visits of what is gone go with it.
1040 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
1041 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
1042 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
1043 ]);
1044 return;
1045 }
1046 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
1047 // Who may see its projects follows who may see the repository.
1048 await this.db
1049 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
1050 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
1051 .run();
1052 if (event.type === "repo.updated") {
1053 // Projects without a homepage of their own show the repository's website.
1054 // Asked of repos, so changes delivered out of order end the same.
1055 const repo = await this.repoById(event.data.repoId);
1056 if (repo) {
1057 await this.db
1058 .prepare("UPDATE projects SET repo_website = ? WHERE repo_id = ?")
1059 .bind(repo.website ?? null, event.data.repoId)
1060 .run();
1061 }
1062 }
1063 return;
1064 }
1065 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
1066 // Asked of repos, so changes delivered out of order end the same.
1067 const repo = await this.repoById(event.data.repoId);
1068 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
1069 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
1070 return;
1071 }
1072 if (event.type === "repo.default_branch_changed") {
1073 // Asked of repos, so changes delivered out of order end the same.
1074 const repo = await this.repoById(event.data.repoId);
1075 await this.db
1076 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
1077 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
1078 .run();
1079 // Another branch has other files: they are read again from its head.
1080 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1081 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
1082 return;
1083 }
1084 if (
1085 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
1086 event.data.repoId
1087 ) {
1088 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1089 for (const row of rows.results) await this.detect(row, null, "packages");
1090 return;
1091 }
1092 if (event.type === "workspace.deleting") {
1093 // Hidden, not gone: every project it shows, including any building
1094 // from a repository it transferred away, until staff restore it or it
1095 // is purged. Those already hidden stay as they were.
1096 if (isProtectedWorkspace(event.data.slug)) return;
1097 const at = now();
1098 await this.db
1099 .prepare(
1100 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
1101 )
1102 .bind(at, event.data.slug.toLowerCase())
1103 .run();
1104 return;
1105 }
1106 if (event.type === "workspace.restored") {
1107 await this.db
1108 .prepare(
1109 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
1110 )
1111 .bind(event.data.slug.toLowerCase())
1112 .run();
1113 return;
1114 }
1115 if (event.type === "workspace.deleted") {
1116 // Its own repositories' projects go with them (`repo.purged`); any
1117 // building from a repository it transferred away goes now. It is not
1118 // backfilled again.
1119 const slug = event.data.slug.toLowerCase();
1120 const ids = "SELECT id FROM projects WHERE workspace = ?1";
1121 await this.db.batch([
1122 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
1123 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug),
1124 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
1125 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
1126 ]);
1127 return;
1128 }
1129 if (event.type === "git.push" && event.data.defaultBranch) {
1130 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1131 for (const row of rows.results) {
1132 await this.detect(row, event.data.after);
1133 }
1134 return;
1135 }
1136 if (event.type !== "repo.created") return;
1137 const actor = await this.workspaceActor(event.data.namespace);
1138 if (!actor) return;
1139 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
1140 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
1141 }
1142}
1143
1144/** One RPC method's answer. */
1145async function answer(service: Projects, method: string, args: any): Promise<Response> {
1146 switch (method) {
1147 case "list":
1148 return Response.json(await service.list(args));
1149 case "get":
1150 return Response.json(await service.get(args));
1151 case "by_repo":
1152 return Response.json(await service.byRepo(args));
1153 case "descriptions":
1154 return Response.json(await service.descriptions(args));
1155 case "describe":
1156 return Response.json(await service.describe(args));
1157 case "count":
1158 return Response.json(await service.count(args));
1159 case "create":
1160 return Response.json(await service.create(args));
1161 case "update":
1162 return Response.json(await service.update(args));
1163 case "set_production_image":
1164 return Response.json(await service.setProductionImage(args));
1165 case "external_productions":
1166 return Response.json(await service.externalProductions());
1167 case "deployments_changed":
1168 await service.deploymentsChanged(args);
1169 return Response.json(null);
1170 case "shortcuts":
1171 return Response.json(await service.shortcuts(args));
1172 case "pin":
1173 return Response.json(await service.pin(args));
1174 case "unpin":
1175 return Response.json(await service.unpin(args));
1176 case "reorder_pins":
1177 return Response.json(await service.reorderPins(args));
1178 case "visited":
1179 await service.visited(args);
1180 return Response.json(null);
1181 case "public_links":
1182 return Response.json(await service.publicLinks(args));
1183 default:
1184 return new Response("Unknown method\n", { status: 404 });
1185 }
1186}
1187
1188/** `project.updated` for a project as it is now. */
1189function projectUpdated(row: Row, actor: string | null) {
1190 return {
1191 type: "project.updated" as const,
1192 source: "projects",
1193 repoId: row.repo_id,
1194 actor,
1195 data: {
1196 projectId: row.id,
1197 workspace: row.workspace,
1198 slug: row.slug,
1199 repoId: row.repo_id,
1200 primary: row.is_primary === 1,
1201 description: row.description,
1202 },
1203 };
1204}
1205
1206export default {
1207 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1208 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
1209 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1210 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1211 const opened = openD1(env.DB, request);
1212 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
1213 const args = (await request.json().catch(() => ({}))) as any;
1214 return opened.finish(await answer(service, match[1], args));
1215 },
1216
1217 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1218 const service = new Projects(env);
1219 for (const message of batch.messages) {
1220 try {
1221 await service.onEvent(message.body);
1222 message.ack();
1223 } catch (error) {
1224 console.error("projects could not handle", message.body.type, error);
1225 message.retry();
1226 }
1227 }
1228 },
1229} satisfies ExportedHandler<Env, G1tEvent>;