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.

436 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, Project, PullStatus, ReposApi, WorkApi, WorkItem, 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 ItemState = "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 items: 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 /** Open work items linked to the repository. */
55 items: number;
56 pulls: number;
57 }
58 | {
59 /** A work item, at `/<workspace>/-/work/<number>`. */
60 kind: "item";
61 workspace: string;
62 number: number;
63 title: string;
64 state: ItemState;
65 author: string;
66 /** For one g1t's agent filed: the person it was working for. */
67 requestedBy: string | null;
68 }
69 | {
70 kind: "pull";
71 owner: string;
72 repo: string;
73 number: number;
74 title: string;
75 state: PullStatus;
76 /** `g1t` for a change g1t made. */
77 author: string;
78 /** For a change g1t made: who asked for it. */
79 requestedBy: string | null;
80 }
81 | { kind: "soon"; owner: string; repo: string; title: string; summary: string; section: string }
82 | { kind: "docs"; title: string; section: string | null; description: string | null };
83
84export const BRAND: Card = { kind: "brand" };
85
86/** The services a card is looked up in: only the calls it needs. */
87export type Sources = {
88 identity: Pick<IdentityApi, "getWorkspace" | "profile">;
89 repos: Pick<ReposApi, "get">;
90 work: Pick<WorkApi, "counts" | "getWorkItem" | "resolveRef" | "getPull" | "byAuthor">;
91 projects: Pick<ProjectsApi, "get" | "list">;
92};
93
94/**
95 * The site's own pages, which no workspace can be named. Those that say
96 * what g1t is get a card of their own; the rest (sign-in, settings and the
97 * like) get the brand card.
98 */
99const PAGES: Record<string, Card> = {
100 "": BRAND,
101 pricing: {
102 kind: "page",
103 address: "g1t.sh/pricing",
104 eyebrow: "Pricing",
105 title: "What it costs us, plus a markup",
106 description: "The forge is free. One plan, $21 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, work items, 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: "Build and ship software, with the agents you already use.",
190 description: "Create an account, then bring your code and the coding agents you already use.",
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 === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) return join(lower, "-", "work", item);
258 if (repo === undefined || repo === "-") return join(lower);
259 if (!NAME.test(repo)) return "/";
260 // An old issue's address: the work item it became.
261 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) return join(owner, repo, "issues", item);
262 if (section === "pull" && item !== undefined && isNumber(item)) return join(owner, repo, "pull", item);
263 if (section === "soon" && item !== undefined && parts.length === 4 && roadmapItem(item)) return join(owner, repo, "soon", item);
264 return join(owner, repo);
265}
266
267/** The card for a page of g1t.sh, as an anonymous visitor would see it. */
268export async function resolve(path: string, sources: Sources): Promise<Card> {
269 const parts = segments(path);
270 if (!parts) return BRAND;
271 try {
272 return (await lookUp(parts, sources)) ?? BRAND;
273 } catch {
274 // A service that is down must not break a link preview.
275 return { kind: "brand", failed: true };
276 }
277}
278
279async function lookUp(parts: string[], sources: Sources): Promise<Card | null> {
280 const { identity, repos, work, projects } = sources;
281 const [owner, repo, section, item] = parts;
282 if (owner === undefined) return PAGES[""];
283 // A person, under `u`. Counted with no viewer: public repositories only.
284 if (owner.toLowerCase() === "u") {
285 if (repo === undefined || parts.length !== 2 || !NAME.test(repo)) return null;
286 const profile = await identity.profile(repo.toLowerCase());
287 if (!profile) return null;
288 const authored = await work.byAuthor(profile.username, null, { limit: 1 }).catch(() => null);
289 const counts = authored?.ok ? authored.value.counts : null;
290 return {
291 kind: "person",
292 username: profile.username,
293 name: profile.name,
294 bio: profile.bio,
295 pullsMerged: counts?.pullsMerged ?? 0,
296 pullsOpen: counts?.pullsOpen ?? 0,
297 items: counts?.items ?? 0,
298 avatar: profile.avatar,
299 };
300 }
301 if (RESERVED.has(owner.toLowerCase())) {
302 if (parts.length === 1) return PAGES[owner.toLowerCase()] ?? null;
303 // Each policy has a card of its own.
304 if (parts.length === 2) return PAGES[`${owner.toLowerCase()}/${parts[1].toLowerCase()}`] ?? null;
305 return null;
306 }
307 if (!NAME.test(owner)) return null;
308
309 // A work item: only one filed in a public repository is anyone's to see.
310 if (repo === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) {
311 return itemCard(work, owner.toLowerCase(), Number(item));
312 }
313
314 // A workspace, or one of its own pages under `-`.
315 if (repo === undefined || repo === "-") {
316 const workspace = await identity.getWorkspace(owner.toLowerCase());
317 if (!workspace) return null;
318 const listed = await projects.list(workspace.slug, null).catch(() => null);
319 return {
320 kind: "workspace",
321 slug: workspace.slug,
322 name: workspace.name || workspace.slug,
323 description: workspace.description,
324 projects: listed?.ok ? listed.value.filter((p) => !p.private).length : 0,
325 avatar: workspace.avatar ?? null,
326 };
327 }
328
329 // A project, through its repository: nothing is shown unless the
330 // repository is public.
331 if (!NAME.test(repo)) return null;
332 const path = { namespace: owner, name: repo };
333 const found = await repos.get(path, null);
334 if (!found.ok || found.value.isPrivate) return null;
335 const { namespace, name } = found.value;
336 const canonical = { namespace, name };
337
338 // An old issue's address names the work item it became.
339 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) {
340 const target = await work.resolveRef(null, { repo: canonical }, Number(item));
341 if (target.kind === "work_item") {
342 const card = await itemCard(work, target.workspace, target.number);
343 if (card) return card;
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 // Only projects have descriptions; a private project keeps its own to itself.
388 description: shown?.description ?? null,
389 items: counts?.ok ? counts.value.items : 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
398/** A work item's card, looked up with no viewer. */
399async function itemCard(work: Sources["work"], workspace: string, number: number): Promise<Card | null> {
400 const found = await work.getWorkItem(workspace, number, null);
401 if (!found.ok) return null;
402 const item = found.value.item;
403 return {
404 kind: "item",
405 workspace: item.workspace,
406 number: item.number,
407 title: item.title,
408 state: itemState(item),
409 author: item.author.username,
410 requestedBy: item.requestedBy?.username ?? null,
411 };
412}
413
414function itemState(item: WorkItem): ItemState {
415 if (item.state === "open") return "open";
416 return item.reason === "not_planned" ? "not_planned" : "completed";
417}
418
419/** The card for a page of the docs, from what the page says about itself. */
420export function docsCard(query: URLSearchParams): Card {
421 const title = clean(query.get("title"), 160);
422 if (!title) return { kind: "docs", title: "g1t docs", section: null, description: clean(query.get("description"), 300) };
423 return {
424 kind: "docs",
425 title,
426 section: clean(query.get("section"), 60),
427 description: clean(query.get("description"), 300),
428 };
429}
430
431/** Text from a query string: trimmed, single-spaced and bounded. */
432export function clean(value: string | null, max: number): string | null {
433 const text = (value ?? "").replace(/\s+/g, " ").trim();
434 if (!text) return null;
435 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
436}