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.

437 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:
107 "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.",
108 },
109 explore: {
110 kind: "page",
111 address: "g1t.sh/explore",
112 eyebrow: "Explore",
113 title: "Public projects on g1t",
114 description: "Recently active and new public projects, by language and topic.",
115 },
116 search: {
117 kind: "page",
118 address: "g1t.sh/search",
119 eyebrow: "Search",
120 title: "Search all of g1t",
121 description: "Repositories, code, work items, pull requests and people, in one search.",
122 },
123 policies: {
124 kind: "page",
125 address: "g1t.sh/policies",
126 eyebrow: "Policies",
127 title: "The rules we both play by",
128 description: "Terms of Service, Privacy Policy, Acceptable Use, refunds and subprocessors, in plain language.",
129 },
130 "policies/terms": {
131 kind: "page",
132 address: "g1t.sh/policies/terms",
133 eyebrow: "Policies",
134 title: "Terms of Service",
135 description: "The agreement between you and Flagon, Inc. when you use g1t.",
136 },
137 "policies/privacy": {
138 kind: "page",
139 address: "g1t.sh/policies/privacy",
140 eyebrow: "Policies",
141 title: "Privacy Policy",
142 description: "What g1t collects, why, where it goes, and how to see, export or delete it.",
143 },
144 "policies/acceptable-use": {
145 kind: "page",
146 address: "g1t.sh/policies/acceptable-use",
147 eyebrow: "Policies",
148 title: "Acceptable Use Policy",
149 description: "What g1t may not be used for, and what happens when it is.",
150 },
151 "policies/refunds": {
152 kind: "page",
153 address: "g1t.sh/policies/refunds",
154 eyebrow: "Policies",
155 title: "Refunds and Cancellation",
156 description: "Ending the plan, accidental overages, goodwill credits and refunds.",
157 },
158 "policies/subprocessors": {
159 kind: "page",
160 address: "g1t.sh/policies/subprocessors",
161 eyebrow: "Policies",
162 title: "Subprocessors",
163 description: "The companies that process data for g1t, and what each receives.",
164 },
165 security: {
166 kind: "page",
167 address: "g1t.sh/security",
168 eyebrow: "Security",
169 title: "How g1t keeps your code and accounts safe",
170 description: "Per-run credentials, the audit log, guardrails, isolated sandboxes, and how to report a vulnerability.",
171 },
172 support: {
173 kind: "page",
174 address: "g1t.sh/support",
175 eyebrow: "Support",
176 title: "Get help with g1t",
177 description: "The documentation, the status page, and who to write to.",
178 },
179 status: {
180 kind: "page",
181 address: "status.g1t.sh",
182 eyebrow: "Status",
183 title: "g1t status",
184 description: "Whether each part of g1t is working right now.",
185 },
186 register: {
187 kind: "page",
188 address: "g1t.sh/register",
189 eyebrow: "Get started",
190 title: "Build and ship software, with the agents you already use.",
191 description: "Create an account, then bring your code and the coding agents you already use.",
192 },
193};
194
195const RESERVED = new Set([
196 "login",
197 "register",
198 "logout",
199 "verify",
200 "forgot",
201 "reset",
202 "device",
203 "oauth",
204 "new",
205 "settings",
206 "explore",
207 "pricing",
208 "search",
209 "workspaces",
210 "policies",
211 "security",
212 "support",
213 "status",
214 "brand",
215 "avatars",
216 "llms.txt",
217 "favicon.ico",
218 "favicon.svg",
219]);
220
221/** A name as it may appear in an address: no slashes, dots only inside. */
222const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
223
224/** The path's segments, decoded; null if it is not a path on the site. */
225export function segments(path: string): string[] | null {
226 if (!path.startsWith("/") || path.startsWith("//")) return null;
227 const bare = path.split(/[?#]/, 1)[0];
228 try {
229 return bare
230 .split("/")
231 .filter((part) => part !== "")
232 .map((part) => decodeURIComponent(part));
233 } catch {
234 return null;
235 }
236}
237
238/**
239 * The page whose card `path` shows, asking no one: the parts of an address
240 * a card does not read are dropped, and an address no card is for becomes
241 * `/`, the brand card. `/acme/web/tree/main/src` and `/acme/web?tab=1` are
242 * both `/acme/web`. The cache key and the lookup both use it, so made-up
243 * addresses cannot make the service draw the same card again and again.
244 */
245export function cardPath(path: string): string {
246 const parts = segments(path);
247 if (!parts || parts.length === 0) return "/";
248 const join = (...kept: string[]) => `/${kept.map(encodeURIComponent).join("/")}`;
249 const [owner, repo, section, item] = parts;
250 const lower = owner.toLowerCase();
251 if (lower === "u") return repo !== undefined && parts.length === 2 && NAME.test(repo) ? join("u", repo.toLowerCase()) : "/";
252 if (RESERVED.has(lower)) {
253 if (parts.length === 1) return lower in PAGES ? join(lower) : "/";
254 const page = `${lower}/${parts[1].toLowerCase()}`;
255 return parts.length === 2 && page in PAGES ? join(lower, parts[1].toLowerCase()) : "/";
256 }
257 if (!NAME.test(owner)) return "/";
258 if (repo === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) return join(lower, "-", "work", item);
259 if (repo === undefined || repo === "-") return join(lower);
260 if (!NAME.test(repo)) return "/";
261 // An old issue's address: the work item it became.
262 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) return join(owner, repo, "issues", item);
263 if (section === "pull" && item !== undefined && isNumber(item)) return join(owner, repo, "pull", item);
264 if (section === "soon" && item !== undefined && parts.length === 4 && roadmapItem(item)) return join(owner, repo, "soon", item);
265 return join(owner, repo);
266}
267
268/** The card for a page of g1t.sh, as an anonymous visitor would see it. */
269export async function resolve(path: string, sources: Sources): Promise<Card> {
270 const parts = segments(path);
271 if (!parts) return BRAND;
272 try {
273 return (await lookUp(parts, sources)) ?? BRAND;
274 } catch {
275 // A service that is down must not break a link preview.
276 return { kind: "brand", failed: true };
277 }
278}
279
280async function lookUp(parts: string[], sources: Sources): Promise<Card | null> {
281 const { identity, repos, work, projects } = sources;
282 const [owner, repo, section, item] = parts;
283 if (owner === undefined) return PAGES[""];
284 // A person, under `u`. Counted with no viewer: public repositories only.
285 if (owner.toLowerCase() === "u") {
286 if (repo === undefined || parts.length !== 2 || !NAME.test(repo)) return null;
287 const profile = await identity.profile(repo.toLowerCase());
288 if (!profile) return null;
289 const authored = await work.byAuthor(profile.username, null, { limit: 1 }).catch(() => null);
290 const counts = authored?.ok ? authored.value.counts : null;
291 return {
292 kind: "person",
293 username: profile.username,
294 name: profile.name,
295 bio: profile.bio,
296 pullsMerged: counts?.pullsMerged ?? 0,
297 pullsOpen: counts?.pullsOpen ?? 0,
298 items: counts?.items ?? 0,
299 avatar: profile.avatar,
300 };
301 }
302 if (RESERVED.has(owner.toLowerCase())) {
303 if (parts.length === 1) return PAGES[owner.toLowerCase()] ?? null;
304 // Each policy has a card of its own.
305 if (parts.length === 2) return PAGES[`${owner.toLowerCase()}/${parts[1].toLowerCase()}`] ?? null;
306 return null;
307 }
308 if (!NAME.test(owner)) return null;
309
310 // A work item: only one filed in a public repository is anyone's to see.
311 if (repo === "-" && section === "work" && item !== undefined && isNumber(item) && parts.length === 4) {
312 return itemCard(work, owner.toLowerCase(), Number(item));
313 }
314
315 // A workspace, or one of its own pages under `-`.
316 if (repo === undefined || repo === "-") {
317 const workspace = await identity.getWorkspace(owner.toLowerCase());
318 if (!workspace) return null;
319 const listed = await projects.list(workspace.slug, null).catch(() => null);
320 return {
321 kind: "workspace",
322 slug: workspace.slug,
323 name: workspace.name || workspace.slug,
324 description: workspace.description,
325 projects: listed?.ok ? listed.value.filter((p) => !p.private).length : 0,
326 avatar: workspace.avatar ?? null,
327 };
328 }
329
330 // A project, through its repository: nothing is shown unless the
331 // repository is public.
332 if (!NAME.test(repo)) return null;
333 const path = { namespace: owner, name: repo };
334 const found = await repos.get(path, null);
335 if (!found.ok || found.value.isPrivate) return null;
336 const { namespace, name } = found.value;
337 const canonical = { namespace, name };
338
339 // An old issue's address names the work item it became.
340 if (section === "issues" && item !== undefined && isNumber(item) && parts.length === 4) {
341 const target = await work.resolveRef(null, { repo: canonical }, Number(item));
342 if (target.kind === "work_item") {
343 const card = await itemCard(work, target.workspace, target.number);
344 if (card) return card;
345 }
346 }
347
348 if (section === "pull" && item !== undefined && isNumber(item)) {
349 const pull = await work.getPull(canonical, Number(item), null);
350 if (pull.ok) {
351 return {
352 kind: "pull",
353 owner: namespace,
354 repo: name,
355 number: pull.value.pull.number,
356 title: pull.value.pull.title,
357 state: pull.value.pull.status,
358 author: pull.value.pull.author.username,
359 requestedBy: pull.value.pull.requestedBy?.username ?? null,
360 };
361 }
362 }
363
364 if (section === "soon" && item !== undefined && parts.length === 4) {
365 const feature = roadmapItem(item);
366 if (feature) {
367 return {
368 kind: "soon",
369 owner: namespace,
370 repo: name,
371 title: feature.title,
372 summary: feature.summary,
373 section: feature.section,
374 };
375 }
376 }
377
378 const [project, counts] = await Promise.all([
379 projects.get(namespace, name.toLowerCase(), null).catch(() => null),
380 work.counts(canonical, null).catch(() => null),
381 ]);
382 const shown: Project | null = project?.ok && !project.value.private ? project.value : null;
383 return {
384 kind: "project",
385 owner: namespace,
386 repo: name,
387 name: shown?.name ?? name,
388 // Only projects have descriptions; a private project keeps its own to itself.
389 description: shown?.description ?? null,
390 items: counts?.ok ? counts.value.items : 0,
391 pulls: counts?.ok ? counts.value.pulls : 0,
392 };
393}
394
395function isNumber(value: string): boolean {
396 return /^[1-9][0-9]{0,8}$/.test(value);
397}
398
399/** A work item's card, looked up with no viewer. */
400async function itemCard(work: Sources["work"], workspace: string, number: number): Promise<Card | null> {
401 const found = await work.getWorkItem(workspace, number, null);
402 if (!found.ok) return null;
403 const item = found.value.item;
404 return {
405 kind: "item",
406 workspace: item.workspace,
407 number: item.number,
408 title: item.title,
409 state: itemState(item),
410 author: item.author.username,
411 requestedBy: item.requestedBy?.username ?? null,
412 };
413}
414
415function itemState(item: WorkItem): ItemState {
416 if (item.state === "open") return "open";
417 return item.reason === "not_planned" ? "not_planned" : "completed";
418}
419
420/** The card for a page of the docs, from what the page says about itself. */
421export function docsCard(query: URLSearchParams): Card {
422 const title = clean(query.get("title"), 160);
423 if (!title) return { kind: "docs", title: "g1t docs", section: null, description: clean(query.get("description"), 300) };
424 return {
425 kind: "docs",
426 title,
427 section: clean(query.get("section"), 60),
428 description: clean(query.get("description"), 300),
429 };
430}
431
432/** Text from a query string: trimmed, single-spaced and bounded. */
433export function clean(value: string | null, max: number): string | null {
434 const text = (value ?? "").replace(/\s+/g, " ").trim();
435 if (!text) return null;
436 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
437}