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

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