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.

174 linesCodeBlameRaw
1/**
2 * Folios that cite code a change touched become possibly out of date, as
3 * Docs' pages do (src/staleness.ts, which calls this with the same change
4 * after recording pages). Once per folio and commit; the owner hears of
5 * it (which change, only if they can read the repository); open rooms
6 * ask their readers to look again; `folio.stale` goes out with a title
7 * only when the whole workspace can read the folio.
8 */
9import { identityClient, notifyClient, reposClient, type User } from "@g1t/contracts";
10
11import { touchedPaths } from "../citations.ts";
12import type { Change, StaleEnv } from "../staleness.ts";
13import { slugOf } from "./list.ts";
14import { workspaceReadable } from "./access-store.ts";
15import { publishFolioEvent } from "./events.ts";
16import type { FolioRoom } from "./room.ts";
17
18export type FolioStaleEnv = StaleEnv & { FOLIOS?: DurableObjectNamespace<FolioRoom> };
19
20/** Whether any live folio cites the repository. */
21export async function foliosCite(db: D1Database, repo: string): Promise<boolean> {
22 const row = await db
23 .prepare("SELECT 1 AS yes FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL LIMIT 1")
24 .bind(repo)
25 .first<{ yes: number }>();
26 return !!row;
27}
28
29/** Records a change against every folio whose citations it touches; returns the folios newly made stale. */
30export async function recordFolioChanges(env: FolioStaleEnv, change: Change, now = new Date()): Promise<string[]> {
31 const db = env.DB;
32 const cited = (
33 await db
34 .prepare("SELECT c.folio_id, c.path FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL")
35 .bind(change.repo)
36 .all<{ folio_id: string; path: string }>()
37 ).results;
38 const byFolio = new Map<string, { path: string }[]>();
39 for (const row of cited) byFolio.set(row.folio_id, [...(byFolio.get(row.folio_id) ?? []), row]);
40 const fresh: string[] = [];
41 const hits = new Map<string, string[]>();
42 for (const [folioId, rows] of byFolio) {
43 const touched = touchedPaths(rows, change.changed);
44 if (!touched.length) continue;
45 hits.set(folioId, touched);
46 const [inserted] = await db.batch<{ folio_id: string }>([
47 db
48 .prepare(
49 "INSERT INTO folio_changes (folio_id, repo, repo_id, commit_sha, pull_number, pull_title, paths, detected_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (folio_id, repo, commit_sha) DO NOTHING RETURNING folio_id",
50 )
51 .bind(
52 folioId,
53 change.repo,
54 change.repo_id,
55 change.commit,
56 change.pull?.number ?? null,
57 change.pull?.title ?? null,
58 JSON.stringify(touched),
59 now.toISOString(),
60 ),
61 ...(change.pull
62 ? [
63 db
64 .prepare("UPDATE folio_changes SET pull_number = ?, pull_title = ? WHERE folio_id = ? AND repo = ? AND commit_sha = ?")
65 .bind(change.pull.number, change.pull.title, folioId, change.repo, change.commit),
66 ]
67 : []),
68 ]);
69 if (inserted?.results.length) fresh.push(folioId);
70 }
71 if (fresh.length) await tellOf(env, change, fresh, hits);
72 return fresh;
73}
74
75type Row = { id: string; workspace_id: string; kind: "doc" | "slides" | "design" | "dashboard"; space_id: string | null; title: string; owner: string };
76
77async function tellOf(env: FolioStaleEnv, change: Change, ids: string[], hits: Map<string, string[]>): Promise<void> {
78 const db = env.DB;
79 const rows = (
80 await db
81 .prepare("SELECT id, workspace_id, kind, space_id, title, owner FROM folios WHERE id IN (SELECT value FROM json_each(?))")
82 .bind(JSON.stringify(ids))
83 .all<Row>()
84 ).results;
85 const slugs = await identityClient(env.IDENTITY)
86 .usernames([...new Set(rows.map((r) => r.workspace_id))])
87 .catch(() => ({}) as Record<string, string>);
88 const ownerIds = [
89 ...new Set(
90 rows
91 .map((r) => r.owner)
92 .filter((k) => k.startsWith("user:"))
93 .map((k) => k.slice(5)),
94 ),
95 ];
96 const people = ownerIds.length
97 ? await identityClient(env.IDENTITY)
98 .usersForAudience(ownerIds)
99 .catch(() => [] as User[])
100 : [];
101 const [namespace, name] = change.repo.split("/") as [string, string];
102 const canRead = new Set<string>();
103 if (env.REPOS) {
104 await Promise.all(
105 people.map(async (person) => {
106 const found = await reposClient(env.REPOS!)
107 .get({ namespace, name }, person)
108 .catch(() => null);
109 if (found?.ok) canRead.add(person.id);
110 }),
111 );
112 }
113 const what = change.pull ? `${change.repo}#${change.pull.number}` : `${change.repo}@${change.commit.slice(0, 7)}`;
114 const notify = env.NOTIFY ? notifyClient(env.NOTIFY) : null;
115 for (const row of rows) {
116 const slug = slugs[row.workspace_id];
117 if (!slug) continue;
118 const href = `/${slug}/-/artifacts/${slugOf(row.title, row.id)}`;
119 const paths = hits.get(row.id) ?? [];
120 const work: Promise<unknown>[] = [];
121 if (notify && row.owner.startsWith("user:")) {
122 const id = row.owner.slice(5);
123 const known = canRead.has(id);
124 work.push(
125 notify
126 .notify(
127 { user_id: id },
128 {
129 id: `folio-stale:${row.id}:${change.commit}:${id}`,
130 kind: "inbox",
131 workspace: slug,
132 title: `${row.title || "Untitled"} may be out of date`,
133 body: known
134 ? `${what} changed ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ` and ${paths.length - 3} more` : ""}`
135 : "A change to code this cites was merged.",
136 href,
137 actor: { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null },
138 created_at: new Date().toISOString(),
139 },
140 )
141 .catch(() => undefined),
142 );
143 }
144 if (env.FOLIOS) {
145 work.push(
146 env.FOLIOS.get(env.FOLIOS.idFromName(row.id))
147 .notice({ type: "folio.staleness" })
148 .catch(() => undefined),
149 );
150 }
151 const open = await workspaceReadable(db, row.id).catch(() => false);
152 work.push(
153 publishFolioEvent(
154 env.EVENTS,
155 "folio.stale",
156 {
157 workspace: slug,
158 workspaceId: row.workspace_id,
159 folioId: row.id,
160 kind: row.kind,
161 spaceId: row.space_id,
162 title: open ? row.title : null,
163 repo: change.repo,
164 commit: change.commit,
165 pull: change.pull?.number ?? null,
166 paths,
167 owners: [row.owner],
168 },
169 change.actor ? `user:${change.actor}` : null,
170 ),
171 );
172 await Promise.all(work);
173 }
174}