g1t/services/og/src/resolve.ts

392 lines12,740 bytesCodeBlame
1/**
2 * Which card a page of g1t.sh gets, and what it says.
3 *
4 * Everything here is looked up with no viewer, so a card can only ever show
5 * what an anonymous visitor to the page could see. A private repository, a
6 * page that does not exist, or anything that fails to load gets the generic
7 * card: never a name or a title that a link preview could leak.
8 */
9import type { IdentityApi, Issue, Project, PullStatus, ReposApi, WorkApi, ProjectsApi } from "@g1t/contracts";
10
11// The one list of Soon pages, shared with the site so the two never disagree.
12import { roadmapItem } from "../../../apps/web/app/lib/roadmap.ts";
13
14export type IssueState = "open" | "completed" | "not_planned";
15
16export type Card =
17 /**
18 * The brand card: the lockup and the tagline. `failed` when a service
19 * could not be reached, so the card is not kept for long.
20 */
21 | { kind: "brand"; failed?: true }
22 /** A page of the site that says what g1t is. */
23 | { kind: "page"; address: string; eyebrow: string; title: string; description: string }
24 | {
25 kind: "workspace";
26 slug: string;
27 name: string;
28 description: string | null;
29 projects: number;
30 /** The uploaded icon's hash, if it has one. */
31 avatar?: string | null;
32 /** That icon as a data URI, once loaded; see `index.ts`. */
33 icon?: string;
34 }
35 | {
36 /** A person's profile, at `/u/<username>`. */
37 kind: "person";
38 username: string;
39 name: string | null;
40 bio: string | null;
41 /** Over public repositories only. */
42 pullsMerged: number;
43 pullsOpen: number;
44 issues: number;
45 avatar?: string | null;
46 icon?: string;
47 }
48 | {
49 kind: "project";
50 owner: string;
51 repo: string;
52 name: string;
53 description: string | null;
54 issues: number;
55 pulls: number;
56 }
57 | {
58 kind: "issue";
59 owner: string;
60 repo: string;
61 number: number;
62 title: string;
63 state: IssueState;
64 author: string;
65 /** For one g1t's agent filed: the person it was working for. */
66 requestedBy: string | null;
67 }
68 | {
69 kind: "pull";
70 owner: string;
71 repo: string;
72 number: number;
73 title: string;
74 state: PullStatus;
75 /** `g1t` for a change g1t made. */
76 author: string;
77 /** For a change g1t made: who asked for it. */
78 requestedBy: string | null;
79 }
80 | { kind: "soon"; owner: string; repo: string; title: string; summary: string; section: string }
81 | { kind: "docs"; title: string; section: string | null; description: string | null };
82
83export const BRAND: Card = { kind: "brand" };
84
85/** The services a card is looked up in: only the calls it needs. */
86export type Sources = {
87 identity: Pick<IdentityApi, "getWorkspace" | "profile">;
88 repos: Pick<ReposApi, "get">;
89 work: Pick<WorkApi, "counts" | "getIssue" | "getPull" | "byAuthor">;
90 projects: Pick<ProjectsApi, "get" | "list">;
91};
92
93/**
94 * The site's own pages, which no workspace can be named. Those that say
95 * what g1t is get a card of their own; the rest (sign-in, settings and the
96 * like) get the brand card.
97 */
98const PAGES: Record<string, Card> = {
99 "": BRAND,
100 pricing: {
101 kind: "page",
102 address: "g1t.sh/pricing",
103 eyebrow: "Pricing",
104 title: "What it costs us, plus a markup",
105 description:
106 "The forge is free. One plan, $20 a month per workspace with $10 of usage included, and usage at what it costs g1t plus 20%. No seats.",
107 },
108 explore: {
109 kind: "page",
110 address: "g1t.sh/explore",
111 eyebrow: "Explore",
112 title: "Public projects on g1t",
113 description: "Recently active and new public projects, by language and topic.",
114 },
115 search: {
116 kind: "page",
117 address: "g1t.sh/search",
118 eyebrow: "Search",
119 title: "Search all of g1t",
120 description: "Repositories, code, issues, pull requests and people, in one search.",
121 },
122 policies: {
123 kind: "page",
124 address: "g1t.sh/policies",
125 eyebrow: "Policies",
126 title: "The rules we both play by",
127 description: "Terms of Service, Privacy Policy, Acceptable Use, refunds and subprocessors, in plain language.",
128 },
129 "policies/terms": {
130 kind: "page",
131 address: "g1t.sh/policies/terms",
132 eyebrow: "Policies",
133 title: "Terms of Service",
134 description: "The agreement between you and Flagon, Inc. when you use g1t.",
135 },
136 "policies/privacy": {
137 kind: "page",
138 address: "g1t.sh/policies/privacy",
139 eyebrow: "Policies",
140 title: "Privacy Policy",
141 description: "What g1t collects, why, where it goes, and how to see, export or delete it.",
142 },
143 "policies/acceptable-use": {
144 kind: "page",
145 address: "g1t.sh/policies/acceptable-use",
146 eyebrow: "Policies",
147 title: "Acceptable Use Policy",
148 description: "What g1t may not be used for, and what happens when it is.",
149 },
150 "policies/refunds": {
151 kind: "page",
152 address: "g1t.sh/policies/refunds",
153 eyebrow: "Policies",
154 title: "Refunds and Cancellation",
155 description: "Ending the plan, accidental overages, goodwill credits and refunds.",
156 },
157 "policies/subprocessors": {
158 kind: "page",
159 address: "g1t.sh/policies/subprocessors",
160 eyebrow: "Policies",
161 title: "Subprocessors",
162 description: "The companies that process data for g1t, and what each receives.",
163 },
164 security: {
165 kind: "page",
166 address: "g1t.sh/security",
167 eyebrow: "Security",
168 title: "How g1t keeps your code and accounts safe",
169 description: "Per-run credentials, the audit log, guardrails, isolated sandboxes, and how to report a vulnerability.",
170 },
171 support: {
172 kind: "page",
173 address: "g1t.sh/support",
174 eyebrow: "Support",
175 title: "Get help with g1t",
176 description: "The documentation, the status page, and who to write to.",
177 },
178 status: {
179 kind: "page",
180 address: "status.g1t.sh",
181 eyebrow: "Status",
182 title: "g1t status",
183 description: "Whether each part of g1t is working right now.",
184 },
185 register: {
186 kind: "page",
187 address: "g1t.sh/register",
188 eyebrow: "Get started",
189 title: "Hand off the outcome. A team of agents ships it.",
190 description: "Create an account on g1t, where people and agents ship software together.",
191 },
192};
193
194const RESERVED = new Set([
195 "login",
196 "register",
197 "logout",
198 "verify",
199 "forgot",
200 "reset",
201 "device",
202 "oauth",
203 "new",
204 "settings",
205 "explore",
206 "pricing",
207 "search",
208 "workspaces",
209 "policies",
210 "security",
211 "support",
212 "status",
213 "brand",
214 "avatars",
215 "llms.txt",
216 "favicon.ico",
217 "favicon.svg",
218]);
219
220/** A name as it may appear in an address: no slashes, dots only inside. */
221const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
222
223/** The path's segments, decoded; null if it is not a path on the site. */
224export function segments(path: string): string[] | null {
225 if (!path.startsWith("/") || path.startsWith("//")) return null;
226 const bare = path.split(/[?#]/, 1)[0];
227 try {
228 return bare
229 .split("/")
230 .filter((part) => part !== "")
231 .map((part) => decodeURIComponent(part));
232 } catch {
233 return null;
234 }
235}
236
237/** The card for a page of g1t.sh, as an anonymous visitor would see it. */
238export async function resolve(path: string, sources: Sources): Promise<Card> {
239 const parts = segments(path);
240 if (!parts) return BRAND;
241 try {
242 return (await lookUp(parts, sources)) ?? BRAND;
243 } catch {
244 // A service that is down must not break a link preview.
245 return { kind: "brand", failed: true };
246 }
247}
248
249async function lookUp(parts: string[], sources: Sources): Promise<Card | null> {
250 const { identity, repos, work, projects } = sources;
251 const [owner, repo, section, item] = parts;
252 if (owner === undefined) return PAGES[""];
253 // A person, under `u`. Counted with no viewer: public repositories only.
254 if (owner.toLowerCase() === "u") {
255 if (repo === undefined || parts.length !== 2 || !NAME.test(repo)) return null;
256 const profile = await identity.profile(repo.toLowerCase());
257 if (!profile) return null;
258 const authored = await work.byAuthor(profile.username, null, { limit: 1 }).catch(() => null);
259 const counts = authored?.ok ? authored.value.counts : null;
260 return {
261 kind: "person",
262 username: profile.username,
263 name: profile.name,
264 bio: profile.bio,
265 pullsMerged: counts?.pullsMerged ?? 0,
266 pullsOpen: counts?.pullsOpen ?? 0,
267 issues: counts?.issues ?? 0,
268 avatar: profile.avatar,
269 };
270 }
271 if (RESERVED.has(owner.toLowerCase())) {
272 if (parts.length === 1) return PAGES[owner.toLowerCase()] ?? null;
273 // Each policy has a card of its own.
274 if (parts.length === 2) return PAGES[`${owner.toLowerCase()}/${parts[1].toLowerCase()}`] ?? null;
275 return null;
276 }
277 if (!NAME.test(owner)) return null;
278
279 // A workspace, or one of its own pages under `-`.
280 if (repo === undefined || repo === "-") {
281 const workspace = await identity.getWorkspace(owner.toLowerCase());
282 if (!workspace) return null;
283 const listed = await projects.list(workspace.slug, null).catch(() => null);
284 return {
285 kind: "workspace",
286 slug: workspace.slug,
287 name: workspace.name || workspace.slug,
288 description: workspace.description,
289 projects: listed?.ok ? listed.value.filter((p) => !p.private).length : 0,
290 avatar: workspace.avatar ?? null,
291 };
292 }
293
294 // A project, through its repository: nothing is shown unless the
295 // repository is public.
296 if (!NAME.test(repo)) return null;
297 const path = { namespace: owner, name: repo };
298 const found = await repos.get(path, null);
299 if (!found.ok || found.value.isPrivate) return null;
300 const { namespace, name } = found.value;
301 const canonical = { namespace, name };
302
303 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) {
304 const issue = await work.getIssue(canonical, Number(item), null);
305 if (issue.ok) {
306 return {
307 kind: "issue",
308 owner: namespace,
309 repo: name,
310 number: issue.value.issue.number,
311 title: issue.value.issue.title,
312 state: issueState(issue.value.issue),
313 author: issue.value.issue.author.username,
314 requestedBy: issue.value.issue.requestedBy?.username ?? null,
315 };
316 }
317 }
318
319 if (section === "pull" && item !== undefined && isNumber(item)) {
320 const pull = await work.getPull(canonical, Number(item), null);
321 if (pull.ok) {
322 return {
323 kind: "pull",
324 owner: namespace,
325 repo: name,
326 number: pull.value.pull.number,
327 title: pull.value.pull.title,
328 state: pull.value.pull.status,
329 author: pull.value.pull.author.username,
330 requestedBy: pull.value.pull.requestedBy?.username ?? null,
331 };
332 }
333 }
334
335 if (section === "soon" && item !== undefined && parts.length === 4) {
336 const feature = roadmapItem(item);
337 if (feature) {
338 return {
339 kind: "soon",
340 owner: namespace,
341 repo: name,
342 title: feature.title,
343 summary: feature.summary,
344 section: feature.section,
345 };
346 }
347 }
348
349 const [project, counts] = await Promise.all([
350 projects.get(namespace, name.toLowerCase(), null).catch(() => null),
351 work.counts(canonical, null).catch(() => null),
352 ]);
353 const shown: Project | null = project?.ok && !project.value.private ? project.value : null;
354 return {
355 kind: "project",
356 owner: namespace,
357 repo: name,
358 name: shown?.name ?? name,
359 // Its own description, else the repository's as it is now.
360 description: (shown && !shown.descriptionInherited ? shown.description : null) ?? found.value.description,
361 issues: counts?.ok ? counts.value.issues : 0,
362 pulls: counts?.ok ? counts.value.pulls : 0,
363 };
364}
365
366function isNumber(value: string): boolean {
367 return /^[1-9][0-9]{0,8}$/.test(value);
368}
369
370function issueState(issue: Issue): IssueState {
371 if (issue.state === "open") return "open";
372 return issue.reason === "not_planned" ? "not_planned" : "completed";
373}
374
375/** The card for a page of the docs, from what the page says about itself. */
376export function docsCard(query: URLSearchParams): Card {
377 const title = clean(query.get("title"), 160);
378 if (!title) return { kind: "docs", title: "g1t docs", section: null, description: clean(query.get("description"), 300) };
379 return {
380 kind: "docs",
381 title,
382 section: clean(query.get("section"), 60),
383 description: clean(query.get("description"), 300),
384 };
385}
386
387/** Text from a query string: trimmed, single-spaced and bounded. */
388export function clean(value: string | null, max: number): string | null {
389 const text = (value ?? "").replace(/\s+/g, " ").trim();
390 if (!text) return null;
391 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
392}