Skip to content
243 linesCodeBlameRaw
1import { data } from "react-router";
2
3import type { DocEditTarget, DocMove, DocPageChange, DocRole, DocSpaceChange, NewDocPage, NewDocSpace, Result, User } 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 if (q.has("stale")) return docs.stalePages(slug, viewer, { repo: q.get("stale") || null });
34 // Repositories to cite, describe or show the docs of: the workspace's that the viewer can read.
35 if (q.has("repos")) return repositories(slug, viewer);
36 // A citation's commit: the default branch's head, once the path is there.
37 if (q.get("cite")) return cite(viewer, q.get("cite")!, q.get("path") ?? "");
38 // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on a page.
39 if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100));
40 // A member's key (`user:<id>`) by username, for adding them to a space.
41 if (q.get("person")) {
42 const user = await identity.userByUsername(q.get("person")!.toLowerCase());
43 return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } };
44 }
45 return docs.sidebar(slug, viewer);
46 });
47 return Response.json(answer, { headers: { "cache-control": "no-store" } });
48}
49
50type Sent = {
51 intent?: string;
52 page_id?: string;
53 space_id?: string;
54 suggestion_id?: string;
55 version_id?: string;
56 template_id?: string;
57 member?: string;
58 role?: DocRole | null;
59 decision?: "accept" | "reject";
60 on?: boolean;
61 page?: NewDocPage;
62 change?: DocPageChange;
63 move?: DocMove;
64 space?: NewDocSpace;
65 space_change?: DocSpaceChange;
66 name?: string;
67 description?: string | null;
68 target?: DocEditTarget;
69 repo?: string;
70 id?: string;
71};
72
73export async function action({ params, context, request }: Route.ActionArgs) {
74 assertSameOrigin(request);
75 const viewer = requireUser(context, request);
76 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
77 const slug = params.owner.toLowerCase();
78 const sent = (await request.json().catch(() => ({}))) as Sent;
79 const page = sent.page_id ?? "";
80 const answer = await safely(async (): Promise<Result<unknown>> => {
81 switch (sent.intent) {
82 case "create_page":
83 return docs.createPage(slug, viewer, sent.page ?? ({ space_id: sent.space_id ?? "" } as NewDocPage));
84 case "update_page":
85 return docs.updatePage(slug, page, viewer, sent.change ?? {});
86 case "move_page":
87 return docs.movePage(slug, page, viewer, sent.move ?? { parent_id: null });
88 case "duplicate_page":
89 return docs.duplicatePage(slug, page, viewer);
90 case "archive_page":
91 return docs.archivePage(slug, page, viewer);
92 case "restore_page":
93 return docs.restorePage(slug, page, viewer);
94 case "delete_page":
95 return docs.deletePage(slug, page, viewer);
96 case "favorite":
97 return docs.favorite(slug, page, viewer, !!sent.on);
98 case "restore_version":
99 return docs.restoreVersion(slug, page, sent.version_id ?? "", viewer);
100 case "decide":
101 return docs.decideSuggestion(slug, sent.suggestion_id ?? "", viewer, sent.decision === "accept" ? "accept" : "reject");
102 case "accept_all":
103 return docs.acceptAll(slug, page, viewer);
104 case "save_template":
105 return docs.saveTemplate(slug, viewer, { page_id: page, name: sent.name ?? "", description: sent.description ?? null });
106 case "delete_template":
107 return docs.deleteTemplate(slug, sent.template_id ?? "", viewer);
108 case "create_space":
109 return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace));
110 case "update_space":
111 return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {});
112 case "set_member":
113 return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null);
114 case "mark_current":
115 return docs.markCurrent(slug, page, viewer);
116 case "add_repo_space":
117 return docs.addRepoSpace(slug, viewer, sent.repo ?? "");
118 case "remove_repo_space":
119 return docs.removeRepoSpace(slug, viewer, sent.id ?? "");
120 default:
121 return { ok: false, error: { code: "invalid", message: "Unknown request." } };
122 }
123 });
124 return Response.json(answer, { headers: { "cache-control": "no-store" } });
125}
126
127async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> {
128 try {
129 return await run();
130 } catch (error) {
131 console.error("docs api:", error);
132 return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again in a moment." } };
133 }
134}
135
136/**
137 * The card for a g1t address embedded in a page, as this reader may see
138 * it: an issue or pull request with its state, a channel, a project, a
139 * page. Anything they can't read is not found, exactly as one that does
140 * not exist.
141 */
142async function embed(slug: string, address: string, viewer: Parameters<typeof docs.page>[2], origin: string): Promise<Result<unknown>> {
143 let path: string;
144 try {
145 const url = new URL(address, origin);
146 if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } };
147 path = url.pathname;
148 } catch {
149 return { ok: false, error: { code: "invalid", message: "That isn't an address." } };
150 }
151 const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
152 const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } };
153 // /<owner>/-/docs/<space>/<page>
154 if (parts[1] === "-" && parts[2] === "docs" && parts[4]) {
155 if (parts[0]!.toLowerCase() !== slug) return missing;
156 const id = pageIdOf(parts[4]);
157 if (!id) return missing;
158 const found = await docs.page(slug, id, viewer);
159 if (!found.ok) return missing;
160 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 } };
161 }
162 // /<owner>/-/chat/<channel>
163 if (parts[1] === "-" && parts[2] === "chat" && parts[3]) {
164 return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } };
165 }
166 if (parts.length < 2 || parts[1] === "-") return missing;
167 const repo = { namespace: parts[0]!, name: parts[1]! };
168 // /<owner>/<repo>/issues/<n> and /pull/<n>
169 const number = Number(parts[3]);
170 if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) {
171 if (parts[2] === "issues") {
172 const found = await work.getIssue(repo, number, viewer);
173 if (!found.ok) return missing;
174 return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } };
175 }
176 const found = await work.getPull(repo, number, viewer);
177 if (!found.ok) return missing;
178 const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null };
179 const state = pull.mergedAt ? "merged" : String(pull.status ?? "open");
180 return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } };
181 }
182 const found = await repos.get(repo, viewer);
183 if (!found.ok) return missing;
184 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}` } };
185}
186
187/** The workspace's repositories the viewer can read, newest first: `owner/name` and its default branch. */
188async function repositories(slug: string, viewer: User): Promise<Result<{ repo: string; default_branch: string; private: boolean }[]>> {
189 const found = await repos.list(viewer, { namespace: slug });
190 return {
191 ok: true,
192 value: found
193 .filter((r) => !r.forkOf)
194 .slice(0, 300)
195 .map((r) => ({ repo: `${r.namespace}/${r.name}`.toLowerCase(), default_branch: r.defaultBranch, private: r.isPrivate })),
196 };
197}
198
199/**
200 * A citation for `path` in `repo`, as the viewer can read it: pinned to
201 * the default branch's head commit, once the file or folder is there (a
202 * glob is taken as written).
203 */
204async function cite(viewer: User, repo: string, rawPath: string): Promise<Result<{ repo: string; path: string; ref: string | null; kind: "file" | "folder" | "glob" }>> {
205 const [namespace, name, ...rest] = repo.trim().toLowerCase().split("/");
206 const missing = { ok: false as const, error: { code: "not_found" as const, message: "No such repository, or you can't read it." } };
207 if (!namespace || !name || rest.length) return missing;
208 const path = rawPath
209 .trim()
210 .replace(/\\/g, "/")
211 .split("/")
212 .filter((p) => p && p !== ".")
213 .join("/");
214 if (!path || path.split("/").includes("..")) return { ok: false, error: { code: "invalid", message: "Name a file, a folder or a pattern in the repository." } };
215 const where = { namespace, name };
216 const root = await repos.tree(where, viewer, null, "");
217 if (!root.ok) return missing;
218 const head = root.value.head?.hash ?? null;
219 if (/[*?]/.test(path)) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "glob" } };
220 if (!head) return { ok: false, error: { code: "not_found", message: "That repository has no commits yet." } };
221 const [blob, tree] = await Promise.all([repos.blob(where, viewer, head, path).catch(() => null), repos.tree(where, viewer, head, path).catch(() => null)]);
222 if (blob?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "file" } };
223 if (tree?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "folder" } };
224 return { ok: false, error: { code: "not_found", message: `There is no ${path} on ${root.value.repo.defaultBranch}.` } };
225}
226
227/** Names and faces for member keys, as the workspace knows them. */
228async function who(slug: string, viewer: Parameters<typeof docs.page>[2], keys: string[]): Promise<Result<unknown>> {
229 const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5));
230 const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6)));
231 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)]);
232 const out = keys.map((key) => {
233 if (key.startsWith("agent:")) {
234 const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined;
235 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 };
236 }
237 const username = names[key.slice(5)]?.toLowerCase();
238 const person = username ? people.find((p) => p.name === username) : undefined;
239 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 };
240 });
241 return { ok: true, value: out };
242}
243