flagon-io/g1t

public

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

g1t/services/projects/src/index.ts

689 lines29,763 bytesCodeBlame
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 { parse as parseYaml } from "yaml";
16
17import { NEEDS, repoRef } from "./access";
18import { renameStatements } from "./rename";
19import { moveStatements, slugOf, strandedQuery } from "./transfer";
20
21import {
22 can,
23 currentMovedPath,
24 currentWorkspaceSlug,
25 fail,
26 identityClient,
27 staleSlugs,
28 needs,
29 newId,
30 ok,
31 permission,
32 repoMove,
33 reposClient,
34 staleMovedPaths,
35 type Dependencies,
36 type DependencyLink,
37 type G1tEvent,
38 type NewProject,
39 type Project,
40 type ProjectGraph,
41 type Repo,
42 type Result,
43 type ServiceBinding,
44 type User,
45 type Viewer,
46} from "@g1t/contracts";
47
48type Env = {
49 DB: D1Database;
50 REPOS: ServiceBinding;
51 IDENTITY: ServiceBinding;
52};
53
54type Row = {
55 id: string;
56 workspace: string;
57 slug: string;
58 name: string;
59 description: string | null;
60 source_kind: string;
61 repo_id: string;
62 repo_namespace: string;
63 repo_name: string;
64 repo_private: number;
65 default_branch: string;
66 root_dir: string;
67 is_primary: number;
68 created_by: string;
69 created_at: string;
70 updated_at: string;
71 /** Set while its repository is deleted; the project is hidden until it is restored. */
72 repo_deleted_at: string | null;
73 /** When its repository was archived; null while it is not. */
74 repo_archived_at?: string | null;
75};
76
77const now = () => new Date().toISOString();
78
79/** A variable's name for a dependency's address, spelled as secrets' names are. */
80const ALIAS = /^[A-Z_][A-Z0-9_]{0,99}$/;
81/** How many dependencies a project may declare. */
82const MAX_DEPENDENCIES = 50;
83
84type LinkRow = { slug: string; name: string; alias: string | null; source: "ui" | "file"; repo_id: string; repo_namespace: string; repo_private: number };
85type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
86
87function toProject(row: Row): Project {
88 return {
89 id: row.id,
90 workspace: row.workspace,
91 slug: row.slug,
92 name: row.name,
93 description: row.description,
94 source: {
95 kind: "hosted",
96 repoId: row.repo_id,
97 repo: { namespace: row.repo_namespace, name: row.repo_name },
98 rootDir: row.root_dir,
99 defaultBranch: row.default_branch,
100 },
101 private: !!row.repo_private,
102 archived: !!row.repo_archived_at,
103 primary: !!row.is_primary,
104 createdBy: row.created_by,
105 createdAt: row.created_at,
106 updatedAt: row.updated_at,
107 };
108}
109
110function isMember(viewer: Viewer, workspace: string): boolean {
111 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
112}
113
114class Projects {
115 constructor(private readonly env: Env) {}
116
117 private get db() {
118 return this.env.DB;
119 }
120
121 private async workspaceActor(slug: string): Promise<User | null> {
122 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
123 if (!workspace) return null;
124 return {
125 id: workspace.id,
126 username: workspace.slug,
127 kind: "workspace",
128 verified: true,
129 workspaces: [{ slug: workspace.slug, role: "member" }],
130 };
131 }
132
133 /** A free slug for `wanted` in the workspace. */
134 private async freeSlug(workspace: string, wanted: string): Promise<string> {
135 const base = slugOf(wanted) || "project";
136 for (let n = 1; n < 100; n++) {
137 const slug = n === 1 ? base : `${base}-${n}`;
138 const taken = await this.db
139 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
140 .bind(workspace, slug)
141 .first();
142 if (!taken) return slug;
143 }
144 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
145 }
146
147 /** Gives a repository its own project, unless it has one. */
148 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
149 if (repo.forkOf) return;
150 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
151 if (existing) {
152 await this.db
153 .prepare(
154 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ? WHERE repo_id = ?",
155 )
156 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.id)
157 .run();
158 return;
159 }
160 const at = now();
161 await this.db
162 .prepare(
163 `INSERT INTO projects (id, workspace, slug, name, description, repo_id, repo_namespace, repo_name, repo_private,
164 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
165 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
166 ON CONFLICT (workspace, slug) DO NOTHING`,
167 )
168 .bind(
169 newId("prj"),
170 repo.namespace.toLowerCase(),
171 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
172 repo.name,
173 repo.description,
174 repo.id,
175 repo.namespace,
176 repo.name,
177 repo.isPrivate ? 1 : 0,
178 repo.defaultBranch,
179 createdBy,
180 at,
181 at,
182 repo.archivedAt ?? null,
183 )
184 .run();
185 }
186
187 /** Projects for a workspace's repositories made before projects existed. Once. */
188 private async backfill(workspace: string): Promise<void> {
189 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
190 if (done) return;
191 const actor = await this.workspaceActor(workspace);
192 if (!actor) return;
193 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
194 for (const repo of list) {
195 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
196 }
197 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
198 }
199
200 /**
201 * Whether the viewer can read the project's repository. The workspace's
202 * own token sees all its projects, also one left building from a
203 * repository that moved to another workspace.
204 */
205 private visible(row: Row, viewer: Viewer): boolean {
206 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
207 return permission(viewer, repoRef(row)) != null;
208 }
209
210 /**
211 * Whether the actor may change the project: not found when they cannot
212 * read its repository, refused when their role there is too low.
213 */
214 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
215 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
216 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
217 return ok(true);
218 }
219
220 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
221 const workspace = a.workspace.toLowerCase();
222 await this.backfill(workspace);
223 const rows = await this.db
224 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
225 .bind(workspace)
226 .all<Row>();
227 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
228 }
229
230 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
231 const workspace = a.workspace.toLowerCase();
232 await this.backfill(workspace);
233 const row = await this.db
234 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
235 .bind(workspace, a.slug.toLowerCase())
236 .first<Row>();
237 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
238 return ok(toProject(row));
239 }
240
241 /**
242 * How many of a workspace's projects build from a repository in another
243 * workspace: those transferring its own repositories would not move, so
244 * they stand in the way of deleting it. One whose repository was deleted
245 * is hidden, and does not.
246 */
247 async held(a: { workspace: string }): Promise<number> {
248 const row = await this.db
249 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ?1 AND repo_namespace <> ?1 AND repo_deleted_at IS NULL")
250 .bind(a.workspace.toLowerCase())
251 .first<{ n: number }>();
252 return row?.n ?? 0;
253 }
254
255 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
256 private async repoById(repoId: string): Promise<Repo | null> {
257 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
258 method: "POST",
259 headers: { "content-type": "application/json" },
260 body: JSON.stringify({ id: repoId }),
261 });
262 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
263 if (!repoPath) return null;
264 const actor = await this.workspaceActor(repoPath.namespace);
265 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
266 return repo?.ok ? repo.value : null;
267 }
268
269 async byRepo(a: { repoId: string }): Promise<Project[]> {
270 const rows = await this.db
271 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
272 .bind(a.repoId)
273 .all<Row>();
274 // A deleted repository's projects are hidden until it is restored.
275 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
276 // A repository from before projects: give it its own now.
277 const repo = await this.repoById(a.repoId);
278 if (!repo) return [];
279 await this.ensureFor(repo, "g1t");
280 const again = await this.db
281 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
282 .bind(a.repoId)
283 .all<Row>();
284 return again.results.map(toProject);
285 }
286
287 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
288 const workspace = a.workspace.toLowerCase();
289 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
290 if (a.input.repo.namespace.toLowerCase() !== workspace) {
291 return fail("invalid", "A project builds from one of its own workspace's repositories.");
292 }
293 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
294 if (!repo.ok) return repo;
295 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
296 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
297 const name = a.input.name.trim();
298 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
299 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
300 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
301 const slug = slugOf(name);
302 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
303 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
304 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
305 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
306 const at = now();
307 const id = newId("prj");
308 await this.db
309 .prepare(
310 `INSERT INTO projects (id, workspace, slug, name, description, repo_id, repo_namespace, repo_name, repo_private,
311 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
312 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
313 )
314 .bind(
315 id,
316 workspace,
317 slug,
318 name,
319 a.input.description?.trim() || null,
320 repo.value.id,
321 repo.value.namespace,
322 repo.value.name,
323 repo.value.isPrivate ? 1 : 0,
324 repo.value.defaultBranch,
325 rootDir,
326 primary ? 1 : 0,
327 a.actor.username,
328 at,
329 at,
330 repo.value.archivedAt ?? null,
331 )
332 .run();
333 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
334 }
335
336 async update(a: {
337 actor: User;
338 workspace: string;
339 slug: string;
340 changes: { name?: string; description?: string | null; rootDir?: string };
341 }): Promise<Result<Project>> {
342 const workspace = a.workspace.toLowerCase();
343 const row = await this.db
344 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
345 .bind(workspace, a.slug.toLowerCase())
346 .first<Row>();
347 if (!row) return fail("not_found", "There is no such project.");
348 const allowed = this.changeable(row, a.actor, NEEDS.update);
349 if (!allowed.ok) return allowed;
350 const name = a.changes.name?.trim() || row.name;
351 const description = a.changes.description === undefined ? row.description : a.changes.description?.trim() || null;
352 const rootDir = a.changes.rootDir === undefined ? row.root_dir : a.changes.rootDir.trim().replace(/^\/+|\/+$/g, "");
353 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
354 await this.db
355 .prepare("UPDATE projects SET name = ?, description = ?, root_dir = ?, updated_at = ? WHERE id = ?")
356 .bind(name.slice(0, 100), description, rootDir, now(), row.id)
357 .run();
358 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!));
359 }
360
361 // ---- Dependencies ------------------------------------------------------
362
363 private async row(workspace: string, slug: string): Promise<Row | null> {
364 return this.db
365 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
366 .bind(workspace.toLowerCase(), slug.toLowerCase())
367 .first<Row>();
368 }
369
370 /** A project's dependencies; for a viewer, only the projects whose repositories they can read. */
371 private async links(projectId: string, viewer?: Viewer): Promise<Dependencies> {
372 const [out, into] = await Promise.all([
373 this.db
374 .prepare(
375 `SELECT p.slug, p.name, d.alias, d.source, p.repo_id, p.repo_namespace, p.repo_private FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
376 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL ORDER BY p.name COLLATE NOCASE`,
377 )
378 .bind(projectId)
379 .all<LinkRow>(),
380 this.db
381 .prepare(
382 `SELECT p.slug, p.name, d.alias, d.source, p.repo_id, p.repo_namespace, p.repo_private FROM dependencies d JOIN projects p ON p.id = d.project_id
383 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL ORDER BY p.name COLLATE NOCASE`,
384 )
385 .bind(projectId)
386 .all<LinkRow>(),
387 ]);
388 const link = (r: LinkRow): DependencyLink => ({ slug: r.slug, name: r.name, as: r.alias, source: r.source });
389 const shown = (r: LinkRow) => viewer === undefined || permission(viewer, repoRef(r)) != null;
390 return { dependsOn: out.results.filter(shown).map(link), usedBy: into.results.filter(shown).map(link) };
391 }
392
393 /** Whether `from` already reaches `to` through dependencies. */
394 private async reaches(from: string, to: string): Promise<boolean> {
395 const seen = new Set<string>([from]);
396 let frontier = [from];
397 while (frontier.length > 0) {
398 const marks = frontier.map(() => "?").join(", ");
399 const next = await this.db
400 .prepare(`SELECT depends_on_id AS id FROM dependencies WHERE project_id IN (${marks})`)
401 .bind(...frontier)
402 .all<{ id: string }>();
403 frontier = [];
404 for (const { id } of next.results) {
405 if (id === to) return true;
406 if (!seen.has(id)) {
407 seen.add(id);
408 frontier.push(id);
409 }
410 }
411 }
412 return false;
413 }
414
415 async dependencies(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Dependencies>> {
416 const row = await this.row(a.workspace, a.slug);
417 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
418 return ok(await this.links(row.id, a.viewer));
419 }
420
421 /** Records `row` using `target`, after the checks every way of declaring one shares. */
422 private async declare(row: Row, target: Row, alias: string | null, source: "ui" | "file", by: string): Promise<Result<true>> {
423 if (target.id === row.id) return fail("invalid", "A project cannot depend on itself.");
424 if (alias != null && !ALIAS.test(alias)) {
425 return fail("invalid", "The variable's name is capital letters, digits and underscores, such as API_URL.");
426 }
427 if (await this.reaches(target.id, row.id)) {
428 return fail("conflict", `${target.slug} already depends on ${row.slug}, directly or through others; that would be a cycle.`);
429 }
430 const count = await this.db
431 .prepare("SELECT COUNT(*) AS n FROM dependencies WHERE project_id = ?")
432 .bind(row.id)
433 .first<{ n: number }>();
434 if ((count?.n ?? 0) >= MAX_DEPENDENCIES) return fail("invalid", `A project can depend on at most ${MAX_DEPENDENCIES} others.`);
435 await this.db
436 .prepare(
437 `INSERT INTO dependencies (project_id, depends_on_id, alias, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)
438 ON CONFLICT (project_id, depends_on_id) DO UPDATE SET alias = excluded.alias, source = excluded.source`,
439 )
440 .bind(row.id, target.id, alias, source, by, now())
441 .run();
442 return ok(true);
443 }
444
445 async addDependency(a: { actor: User; workspace: string; slug: string; on: string; as: string | null }): Promise<Result<Dependencies>> {
446 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
447 if (!row) return fail("not_found", "There is no such project.");
448 const allowed = this.changeable(row, a.actor, NEEDS.addDependency);
449 if (!allowed.ok) return allowed;
450 // A project the actor cannot read is not there for them to depend on.
451 if (!target || !this.visible(target, a.actor)) return fail("not_found", `${a.workspace} has no project called ${a.on}.`);
452 const existing = await this.db
453 .prepare("SELECT source FROM dependencies WHERE project_id = ? AND depends_on_id = ?")
454 .bind(row.id, target.id)
455 .first<{ source: string }>();
456 if (existing?.source === "file") return fail("conflict", "This dependency is declared in .g1t/project.yml; change it there.");
457 const alias = a.as?.trim() ? a.as.trim().toUpperCase() : null;
458 const done = await this.declare(row, target, alias, "ui", a.actor.username);
459 if (!done.ok) return done;
460 return ok(await this.links(row.id, a.actor));
461 }
462
463 async removeDependency(a: { actor: User; workspace: string; slug: string; on: string }): Promise<Result<Dependencies>> {
464 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
465 if (!row) return fail("not_found", "There is no such project.");
466 const allowed = this.changeable(row, a.actor, NEEDS.removeDependency);
467 if (!allowed.ok) return allowed;
468 if (!target) return fail("not_found", "There is no such dependency.");
469 const removed = await this.db
470 .prepare("DELETE FROM dependencies WHERE project_id = ? AND depends_on_id = ? AND source = 'ui' RETURNING project_id")
471 .bind(row.id, target.id)
472 .first();
473 if (!removed) return fail("conflict", "This dependency is declared in .g1t/project.yml, or does not exist; change the file.");
474 return ok(await this.links(row.id, a.actor));
475 }
476
477 async graph(a: { projectId: string }): Promise<ProjectGraph> {
478 const [out, into] = await Promise.all([
479 this.db
480 .prepare(
481 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
482 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL`,
483 )
484 .bind(a.projectId)
485 .all<NodeRow>(),
486 this.db
487 .prepare(
488 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.project_id
489 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL`,
490 )
491 .bind(a.projectId)
492 .all<NodeRow>(),
493 ]);
494 const node = (r: NodeRow) => ({ id: r.id, slug: r.slug, workspace: r.workspace, as: r.alias });
495 return { dependsOn: out.results.map(node), usedBy: into.results.map(node) };
496 }
497
498 /** For the runner: each project on a repository, with what it uses and what uses it. */
499 async contextForRepo(a: { repoId: string }): Promise<{ slug: string; name: string; dependencies: Dependencies }[]> {
500 const rows = await this.db
501 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
502 .bind(a.repoId)
503 .all<Row>();
504 return Promise.all(rows.results.map(async (row) => ({ slug: row.slug, name: row.name, dependencies: await this.links(row.id) })));
505 }
506
507 /**
508 * A project's `.g1t/project.yml` at a commit of its default branch:
509 *
510 * dependsOn:
511 * - project: api
512 * as: API_URL
513 *
514 * Its dependencies replace the ones the file declared before. Ones that
515 * cannot be kept (an unknown project, a cycle) are left out.
516 */
517 private async syncFile(row: Row, commit: string): Promise<void> {
518 const actor = await this.workspaceActor(row.workspace);
519 if (!actor) return;
520 const path = row.root_dir ? `${row.root_dir}/.g1t/project.yml` : ".g1t/project.yml";
521 const blob = await reposClient(this.env.REPOS).blob({ namespace: row.repo_namespace, name: row.repo_name }, actor, commit, path);
522 if (!blob.ok || blob.value.text == null) {
523 // No file (any more): what it declared goes with it.
524 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
525 return;
526 }
527 let declared: unknown[] = [];
528 try {
529 const parsed = parseYaml(blob.value.text) as { dependsOn?: unknown } | null;
530 if (Array.isArray(parsed?.dependsOn)) declared = parsed.dependsOn;
531 } catch (error) {
532 console.error("could not read", path, "of", row.slug, error);
533 return;
534 }
535 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
536 for (const entry of declared.slice(0, MAX_DEPENDENCIES)) {
537 const item = entry as { project?: unknown; as?: unknown } | string;
538 const on = typeof item === "string" ? item : typeof item?.project === "string" ? item.project : null;
539 if (!on) continue;
540 const target = await this.row(row.workspace, on);
541 if (!target) continue;
542 const alias = typeof item === "object" && typeof item.as === "string" ? item.as.trim().toUpperCase() : null;
543 await this.declare(row, target, alias, "file", "g1t");
544 }
545 }
546
547 async onEvent(event: G1tEvent): Promise<void> {
548 if (event.type === "workspace.renamed") {
549 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
550 const statements = renameStatements(staleSlugs(event.data, current), current);
551 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
552 return;
553 }
554 const move = repoMove(event);
555 if (move) {
556 const current = await currentMovedPath(this.env.REPOS, move);
557 const stale = staleMovedPaths(move, current);
558 if (stale.length === 0) return;
559 const statements = moveStatements(stale, current, move.repoId);
560 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
561 // A project whose slug the destination already had moves under a free one.
562 const query = strandedQuery(stale, current, move.repoId);
563 if (!query) return;
564 const workspace = current.split("/")[0]!;
565 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
566 for (const row of stranded.results) {
567 const slug = await this.freeSlug(workspace, row.slug);
568 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
569 await this.db
570 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
571 .bind(workspace, slug, workspace, now(), row.id)
572 .run();
573 }
574 return;
575 }
576 if (event.type === "repo.deleted") {
577 // Hidden, not gone: a restore brings its projects back as they were.
578 await this.db
579 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
580 .bind(now(), event.data.repoId)
581 .run();
582 return;
583 }
584 if (event.type === "repo.restored") {
585 await this.db
586 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
587 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
588 .run();
589 return;
590 }
591 if (event.type === "repo.purged") {
592 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
593 await this.db.batch([
594 this.db
595 .prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`)
596 .bind(event.data.repoId),
597 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
598 ]);
599 return;
600 }
601 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
602 // Who may see its projects follows who may see the repository.
603 await this.db
604 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
605 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
606 .run();
607 return;
608 }
609 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
610 // Asked of repos, so changes delivered out of order end the same.
611 const repo = await this.repoById(event.data.repoId);
612 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
613 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
614 return;
615 }
616 if (event.type === "repo.default_branch_changed") {
617 // Asked of repos, so changes delivered out of order end the same.
618 const repo = await this.repoById(event.data.repoId);
619 await this.db
620 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
621 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
622 .run();
623 return;
624 }
625 if (event.type === "workspace.deleted") {
626 // Its projects went with its repositories; it is not backfilled again.
627 await this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(event.data.slug).run();
628 return;
629 }
630 if (event.type === "git.push" && event.data.defaultBranch) {
631 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
632 for (const row of rows.results) await this.syncFile(row, event.data.after);
633 return;
634 }
635 if (event.type !== "repo.created") return;
636 const actor = await this.workspaceActor(event.data.namespace);
637 if (!actor) return;
638 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
639 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
640 }
641}
642
643export default {
644 async fetch(request: Request, env: Env): Promise<Response> {
645 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
646 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
647 const service = new Projects(env);
648 const args = (await request.json().catch(() => ({}))) as any;
649 switch (match[1]) {
650 case "list":
651 return Response.json(await service.list(args));
652 case "get":
653 return Response.json(await service.get(args));
654 case "by_repo":
655 return Response.json(await service.byRepo(args));
656 case "held":
657 return Response.json(await service.held(args));
658 case "create":
659 return Response.json(await service.create(args));
660 case "update":
661 return Response.json(await service.update(args));
662 case "dependencies":
663 return Response.json(await service.dependencies(args));
664 case "add_dependency":
665 return Response.json(await service.addDependency(args));
666 case "remove_dependency":
667 return Response.json(await service.removeDependency(args));
668 case "graph":
669 return Response.json(await service.graph(args));
670 case "context_for_repo":
671 return Response.json(await service.contextForRepo(args));
672 default:
673 return new Response("Unknown method\n", { status: 404 });
674 }
675 },
676
677 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
678 const service = new Projects(env);
679 for (const message of batch.messages) {
680 try {
681 await service.onEvent(message.body);
682 message.ack();
683 } catch (error) {
684 console.error("projects could not handle", message.body.type, error);
685 message.retry();
686 }
687 }
688 },
689} satisfies ExportedHandler<Env, G1tEvent>;