Skip to content
190 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Docs: a workspace knowledge base people and agents write together1import { data } from "react-router";
2
3import type { DocEditTarget, DocMove, DocPageChange, DocRole, DocSpaceChange, NewDocPage, NewDocSpace, Result } from "@g1t/contracts";
4
5import type { Route } from "./+types/api";
6import { workspacePeople } from "../../../lib/chat.server";
7import { docs, identity, repos, work } from "../../../lib/services.server";
8import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
9import { pageIdOf } from "../../../lib/docs";
10
11/**
12 * What Docs pages ask for as they run, as JSON: page search for links and
13 * the sidebar's search box, a page's history and one version, cards for
14 * embedded g1t things; and everything they change: pages, spaces, members,
15 * suggestions, templates. Every answer is `{ ok, value }` or
16 * `{ ok: false, error }`, the services' own shape.
17 */
18export async function loader({ params, context, request }: Route.LoaderArgs) {
19 const viewer = requireUser(context, request);
20 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21 const slug = params.owner.toLowerCase();
22 const url = new URL(request.url);
23 const q = url.searchParams;
24 const answer = await safely<unknown>(async () => {
25 if (q.has("search")) return docs.search(slug, viewer, { query: q.get("search") ?? "", space_id: q.get("space"), project: q.get("project"), limit: Number(q.get("limit")) || 10 });
26 if (q.get("versions")) return docs.versions(slug, q.get("versions")!, viewer);
27 if (q.get("version") && q.get("page")) return docs.version(slug, q.get("page")!, q.get("version")!, viewer);
28 if (q.get("suggestions")) return docs.suggestions(slug, q.get("suggestions")!, viewer);
29 if (q.get("threads")) return docs.threads(slug, q.get("threads")!, viewer);
30 if (q.get("space")) return docs.space(slug, q.get("space")!, viewer);
31 if (q.get("embed")) return embed(slug, q.get("embed")!, viewer, url.origin);
32 if (q.has("templates")) return docs.templates(slug, viewer);
33 // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on a page.
34 if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100));
35 // A member's key (`user:<id>`) by username, for adding them to a space.
36 if (q.get("person")) {
37 const user = await identity.userByUsername(q.get("person")!.toLowerCase());
38 return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } };
39 }
40 return docs.sidebar(slug, viewer);
41 });
42 return Response.json(answer, { headers: { "cache-control": "no-store" } });
43}
44
45type Sent = {
46 intent?: string;
47 page_id?: string;
48 space_id?: string;
49 suggestion_id?: string;
50 version_id?: string;
51 template_id?: string;
52 member?: string;
53 role?: DocRole | null;
54 decision?: "accept" | "reject";
55 on?: boolean;
56 page?: NewDocPage;
57 change?: DocPageChange;
58 move?: DocMove;
59 space?: NewDocSpace;
60 space_change?: DocSpaceChange;
61 name?: string;
62 description?: string | null;
63 target?: DocEditTarget;
64};
65
66export async function action({ params, context, request }: Route.ActionArgs) {
67 assertSameOrigin(request);
68 const viewer = requireUser(context, request);
69 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
70 const slug = params.owner.toLowerCase();
71 const sent = (await request.json().catch(() => ({}))) as Sent;
72 const page = sent.page_id ?? "";
73 const answer = await safely(async (): Promise<Result<unknown>> => {
74 switch (sent.intent) {
75 case "create_page":
76 return docs.createPage(slug, viewer, sent.page ?? ({ space_id: sent.space_id ?? "" } as NewDocPage));
77 case "update_page":
78 return docs.updatePage(slug, page, viewer, sent.change ?? {});
79 case "move_page":
80 return docs.movePage(slug, page, viewer, sent.move ?? { parent_id: null });
81 case "duplicate_page":
82 return docs.duplicatePage(slug, page, viewer);
83 case "archive_page":
84 return docs.archivePage(slug, page, viewer);
85 case "restore_page":
86 return docs.restorePage(slug, page, viewer);
87 case "delete_page":
88 return docs.deletePage(slug, page, viewer);
89 case "favorite":
90 return docs.favorite(slug, page, viewer, !!sent.on);
91 case "restore_version":
92 return docs.restoreVersion(slug, page, sent.version_id ?? "", viewer);
93 case "decide":
94 return docs.decideSuggestion(slug, sent.suggestion_id ?? "", viewer, sent.decision === "accept" ? "accept" : "reject");
95 case "accept_all":
96 return docs.acceptAll(slug, page, viewer);
97 case "save_template":
98 return docs.saveTemplate(slug, viewer, { page_id: page, name: sent.name ?? "", description: sent.description ?? null });
99 case "delete_template":
100 return docs.deleteTemplate(slug, sent.template_id ?? "", viewer);
101 case "create_space":
102 return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace));
103 case "update_space":
104 return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {});
105 case "set_member":
106 return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null);
107 default:
108 return { ok: false, error: { code: "invalid", message: "Unknown request." } };
109 }
110 });
111 return Response.json(answer, { headers: { "cache-control": "no-store" } });
112}
113
114async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> {
115 try {
116 return await run();
117 } catch (error) {
118 console.error("docs api:", error);
119 return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again in a moment." } };
120 }
121}
122
123/**
124 * The card for a g1t address embedded in a page, as this reader may see
125 * it: an issue or pull request with its state, a channel, a project, a
126 * page. Anything they can't read is not found, exactly as one that does
127 * not exist.
128 */
129async function embed(slug: string, address: string, viewer: Parameters<typeof docs.page>[2], origin: string): Promise<Result<unknown>> {
130 let path: string;
131 try {
132 const url = new URL(address, origin);
133 if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } };
134 path = url.pathname;
135 } catch {
136 return { ok: false, error: { code: "invalid", message: "That isn't an address." } };
137 }
138 const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
139 const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } };
140 // /<owner>/-/docs/<space>/<page>
141 if (parts[1] === "-" && parts[2] === "docs" && parts[4]) {
142 if (parts[0]!.toLowerCase() !== slug) return missing;
143 const id = pageIdOf(parts[4]);
144 if (!id) return missing;
145 const found = await docs.page(slug, id, viewer);
146 if (!found.ok) return missing;
147 return { ok: true, value: { kind: "page", title: `${found.value.page.icon ? `${found.value.page.icon} ` : ""}${found.value.page.title || "Untitled"}`, subtitle: found.value.space.name, state: null, href: found.value.page.path } };
148 }
149 // /<owner>/-/chat/<channel>
150 if (parts[1] === "-" && parts[2] === "chat" && parts[3]) {
151 return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } };
152 }
153 if (parts.length < 2 || parts[1] === "-") return missing;
154 const repo = { namespace: parts[0]!, name: parts[1]! };
155 // /<owner>/<repo>/issues/<n> and /pull/<n>
156 const number = Number(parts[3]);
157 if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) {
158 if (parts[2] === "issues") {
159 const found = await work.getIssue(repo, number, viewer);
160 if (!found.ok) return missing;
161 return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } };
162 }
163 const found = await work.getPull(repo, number, viewer);
164 if (!found.ok) return missing;
165 const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null };
166 const state = pull.mergedAt ? "merged" : String(pull.status ?? "open");
167 return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } };
168 }
169 const found = await repos.get(repo, viewer);
170 if (!found.ok) return missing;
171 return { ok: true, value: { kind: "project", title: `${repo.namespace}/${repo.name}`, subtitle: (found.value as { description?: string | null }).description ?? "Project", state: null, href: `/${repo.namespace}/${repo.name}` } };
172}
173
174/** Names and faces for member keys, as the workspace knows them. */
175async function who(slug: string, viewer: Parameters<typeof docs.page>[2], keys: string[]): Promise<Result<unknown>> {
176 const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5));
177 const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6)));
178 const [names, { people, agents }] = await Promise.all([userIds.length ? identity.usernames(userIds).catch(() => ({}) as Record<string, string>) : Promise.resolve({} as Record<string, string>), workspacePeople(slug, viewer)]);
179 const out = keys.map((key) => {
180 if (key.startsWith("agent:")) {
181 const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined;
182 return { key, kind: "agent", id: key.slice(6), name: agent?.handle ?? "agent", display_name: agent?.display_name ?? "Former agent", avatar: agent?.avatar ?? null, avatar_seed: agent?.avatar_seed ?? null };
183 }
184 const username = names[key.slice(5)]?.toLowerCase();
185 const person = username ? people.find((p) => p.name === username) : undefined;
186 return { key, kind: "user", id: key.slice(5), name: username ?? "ghost", display_name: person?.display_name ?? username ?? "Former member", avatar: person?.avatar ?? null, avatar_seed: null };
187 });
188 return { ok: true, value: out };
189}
190