Skip to content
420 linesCodeBlameRaw
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/**
238 * The page whose card `path` shows, asking no one: the parts of an address
239 * a card does not read are dropped, and an address no card is for becomes
240 * `/`, the brand card. `/acme/web/tree/main/src` and `/acme/web?tab=1` are
241 * both `/acme/web`. The cache key and the lookup both use it, so made-up
242 * addresses cannot make the service draw the same card again and again.
243 */
244export function cardPath(path: string): string {
245 const parts = segments(path);
246 if (!parts || parts.length === 0) return "/";
247 const join = (...kept: string[]) => `/${kept.map(encodeURIComponent).join("/")}`;
248 const [owner, repo, section, item] = parts;
249 const lower = owner.toLowerCase();
250 if (lower === "u") return repo !== undefined && parts.length === 2 && NAME.test(repo) ? join("u", repo.toLowerCase()) : "/";
251 if (RESERVED.has(lower)) {
252 if (parts.length === 1) return lower in PAGES ? join(lower) : "/";
253 const page = `${lower}/${parts[1].toLowerCase()}`;
254 return parts.length === 2 && page in PAGES ? join(lower, parts[1].toLowerCase()) : "/";
255 }
256 if (!NAME.test(owner)) return "/";
257 if (repo === undefined || repo === "-") return join(lower);
258 if (!NAME.test(repo)) return "/";
259 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) return join(owner, repo, "issues", item);
260 if (section === "pull" && item !== undefined && isNumber(item)) return join(owner, repo, "pull", item);
261 if (section === "soon" && item !== undefined && parts.length === 4 && roadmapItem(item)) return join(owner, repo, "soon", item);
262 return join(owner, repo);
263}
264
265/** The card for a page of g1t.sh, as an anonymous visitor would see it. */
266export async function resolve(path: string, sources: Sources): Promise<Card> {
267 const parts = segments(path);
268 if (!parts) return BRAND;
269 try {
270 return (await lookUp(parts, sources)) ?? BRAND;
271 } catch {
272 // A service that is down must not break a link preview.
273 return { kind: "brand", failed: true };
274 }
275}
276
277async function lookUp(parts: string[], sources: Sources): Promise<Card | null> {
278 const { identity, repos, work, projects } = sources;
279 const [owner, repo, section, item] = parts;
280 if (owner === undefined) return PAGES[""];
281 // A person, under `u`. Counted with no viewer: public repositories only.
282 if (owner.toLowerCase() === "u") {
283 if (repo === undefined || parts.length !== 2 || !NAME.test(repo)) return null;
284 const profile = await identity.profile(repo.toLowerCase());
285 if (!profile) return null;
286 const authored = await work.byAuthor(profile.username, null, { limit: 1 }).catch(() => null);
287 const counts = authored?.ok ? authored.value.counts : null;
288 return {
289 kind: "person",
290 username: profile.username,
291 name: profile.name,
292 bio: profile.bio,
293 pullsMerged: counts?.pullsMerged ?? 0,
294 pullsOpen: counts?.pullsOpen ?? 0,
295 issues: counts?.issues ?? 0,
296 avatar: profile.avatar,
297 };
298 }
299 if (RESERVED.has(owner.toLowerCase())) {
300 if (parts.length === 1) return PAGES[owner.toLowerCase()] ?? null;
301 // Each policy has a card of its own.
302 if (parts.length === 2) return PAGES[`${owner.toLowerCase()}/${parts[1].toLowerCase()}`] ?? null;
303 return null;
304 }
305 if (!NAME.test(owner)) return null;
306
307 // A workspace, or one of its own pages under `-`.
308 if (repo === undefined || repo === "-") {
309 const workspace = await identity.getWorkspace(owner.toLowerCase());
310 if (!workspace) return null;
311 const listed = await projects.list(workspace.slug, null).catch(() => null);
312 return {
313 kind: "workspace",
314 slug: workspace.slug,
315 name: workspace.name || workspace.slug,
316 description: workspace.description,
317 projects: listed?.ok ? listed.value.filter((p) => !p.private).length : 0,
318 avatar: workspace.avatar ?? null,
319 };
320 }
321
322 // A project, through its repository: nothing is shown unless the
323 // repository is public.
324 if (!NAME.test(repo)) return null;
325 const path = { namespace: owner, name: repo };
326 const found = await repos.get(path, null);
327 if (!found.ok || found.value.isPrivate) return null;
328 const { namespace, name } = found.value;
329 const canonical = { namespace, name };
330
331 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) {
332 const issue = await work.getIssue(canonical, Number(item), null);
333 if (issue.ok) {
334 return {
335 kind: "issue",
336 owner: namespace,
337 repo: name,
338 number: issue.value.issue.number,
339 title: issue.value.issue.title,
340 state: issueState(issue.value.issue),
341 author: issue.value.issue.author.username,
342 requestedBy: issue.value.issue.requestedBy?.username ?? null,
343 };
344 }
345 }
346
347 if (section === "pull" && item !== undefined && isNumber(item)) {
348 const pull = await work.getPull(canonical, Number(item), null);
349 if (pull.ok) {
350 return {
351 kind: "pull",
352 owner: namespace,
353 repo: name,
354 number: pull.value.pull.number,
355 title: pull.value.pull.title,
356 state: pull.value.pull.status,
357 author: pull.value.pull.author.username,
358 requestedBy: pull.value.pull.requestedBy?.username ?? null,
359 };
360 }
361 }
362
363 if (section === "soon" && item !== undefined && parts.length === 4) {
364 const feature = roadmapItem(item);
365 if (feature) {
366 return {
367 kind: "soon",
368 owner: namespace,
369 repo: name,
370 title: feature.title,
371 summary: feature.summary,
372 section: feature.section,
373 };
374 }
375 }
376
377 const [project, counts] = await Promise.all([
378 projects.get(namespace, name.toLowerCase(), null).catch(() => null),
379 work.counts(canonical, null).catch(() => null),
380 ]);
381 const shown: Project | null = project?.ok && !project.value.private ? project.value : null;
382 return {
383 kind: "project",
384 owner: namespace,
385 repo: name,
386 name: shown?.name ?? name,
387 // Its own description, else the repository's as it is now.
388 description: (shown && !shown.descriptionInherited ? shown.description : null) ?? found.value.description,
389 issues: counts?.ok ? counts.value.issues : 0,
390 pulls: counts?.ok ? counts.value.pulls : 0,
391 };
392}
393
394function isNumber(value: string): boolean {
395 return /^[1-9][0-9]{0,8}$/.test(value);
396}
397
398function issueState(issue: Issue): IssueState {
399 if (issue.state === "open") return "open";
400 return issue.reason === "not_planned" ? "not_planned" : "completed";
401}
402
403/** The card for a page of the docs, from what the page says about itself. */
404export function docsCard(query: URLSearchParams): Card {
405 const title = clean(query.get("title"), 160);
406 if (!title) return { kind: "docs", title: "g1t docs", section: null, description: clean(query.get("description"), 300) };
407 return {
408 kind: "docs",
409 title,
410 section: clean(query.get("section"), 60),
411 description: clean(query.get("description"), 300),
412 };
413}
414
415/** Text from a query string: trimmed, single-spaced and bounded. */
416export function clean(value: string | null, max: number): string | null {
417 const text = (value ?? "").replace(/\s+/g, " ").trim();
418 if (!text) return null;
419 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
420}