flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/og/src/resolve.ts

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