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