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.

566 linesCodeBlameRaw
1/**
2 * A workspace's pages and where the sidebar finds them. `g1t.sh/<workspace>`
3 * is the workspace's own page, its header and overview; Repositories
4 * (Code's, at `-/code/repositories`), Packages, Teams, People, Insights and
5 * Settings are pages of their own under `-/`,
6 * each reached from the sidebar and each with its own heading. The sidebar
7 * is always the current workspace's.
8 */
9
10/** The workspace's pages a person moves between, and which the sidebar lists. */
11export type WorkspacePageKey = "overview" | "code/repositories" | "packages" | "teams" | "people" | "insights";
12
13/** Those pages under `-/`: the overview is the workspace's own address, and Repositories is Code's. */
14export const WORKSPACE_PAGES = ["code/repositories", "packages", "teams", "people", "insights"] as const;
15
16/** Code's list of a workspace's repositories, with an optional query (without its `?`). */
17export function repositoriesPath(slug: string, query?: string): string {
18 return `/${slug}/-/code/repositories${query ? `?${query}` : ""}`;
19}
20
21/**
22 * Where an address of Code's that moved is now, keeping its query: Code
23 * called repositories projects, at `-/projects`, until Work's projects took
24 * the word. Null for anything else.
25 */
26export function movedCodePath(pathname: string, search = ""): string | null {
27 const parts = pagePath(pathname).split("/").filter(Boolean);
28 if (parts.length !== 3 || parts[1] !== "-" || parts[2]!.toLowerCase() !== "projects") return null;
29 const params = new URLSearchParams(search);
30 params.delete("_routes");
31 return repositoriesPath(parts[0]!, params.toString());
32}
33
34/**
35 * Which of those pages a path is, under `/<workspace>`: null for the
36 * workspace's other pages (settings, Usage and the rest), for
37 * one package's or one team's page, and for anything else.
38 */
39export function workspacePage(pathname: string, slug: string): WorkspacePageKey | null {
40 const parts = pagePath(pathname).split("/").filter(Boolean);
41 if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
42 if (parts.length === 1) return "overview";
43 if (parts[1] !== "-") return null;
44 const rest = parts.slice(2).join("/");
45 return (WORKSPACE_PAGES as readonly string[]).includes(rest) ? (rest as WorkspacePageKey) : null;
46}
47
48/**
49 * Workspace mode's pages: the workspace's settings, every one under
50 * `-/settings/`, laid out as GitHub lays out an organization's. The
51 * Profile on top, then sections under headings. A page's `pages` are its
52 * sub-pages, which the sidebar shows under it while you are on one of them.
53 * `owner` marks the pages only owners open; the sidebar shows members the
54 * rest. `path` is under `-/`.
55 */
56export type WorkspaceSettingsPage = { key: string; label: string; path: string; owner?: boolean; pages?: readonly WorkspaceSettingsPage[] };
57
58/** A section of them; the first, the Profile's, has no heading. */
59export type WorkspaceSettingsGroup = { key: string; title: string | null; pages: readonly WorkspaceSettingsPage[] };
60
61export const WORKSPACE_SETTINGS_GROUPS: readonly WorkspaceSettingsGroup[] = [
62 {
63 key: "profile",
64 title: null,
65 pages: [{ key: "profile", label: "Profile", path: "settings/profile" }],
66 },
67 {
68 key: "access",
69 title: "Access",
70 pages: [
71 {
72 key: "billing",
73 label: "Billing and plans",
74 path: "settings/billing",
75 pages: [
76 { key: "usage", label: "Usage", path: "settings/usage" },
77 { key: "provider-costs", label: "Provider costs", path: "settings/provider-costs" },
78 ],
79 },
80 {
81 key: "members",
82 label: "Members",
83 path: "settings/members",
84 pages: [{ key: "invitations", label: "Invitations", path: "settings/members/invitations", owner: true }],
85 },
86 { key: "permissions", label: "Permissions", path: "settings/permissions", owner: true },
87 ],
88 },
89 {
90 key: "automation",
91 title: "Code, planning, and automation",
92 pages: [
93 {
94 key: "repositories",
95 label: "Repositories",
96 path: "settings/repositories",
97 pages: [{ key: "rules", label: "Rules", path: "settings/rules" }],
98 },
99 { key: "work-settings", label: "Work", path: "settings/work" },
100 {
101 key: "ai",
102 label: "AI",
103 path: "settings/ai",
104 pages: [{ key: "guardrails", label: "Guardrails", path: "settings/guardrails" }],
105 },
106 {
107 key: "actions",
108 label: "Actions",
109 path: "settings/actions",
110 pages: [{ key: "runners", label: "Runners", path: "settings/runners", owner: true }],
111 },
112 { key: "webhooks", label: "Webhooks", path: "settings/webhooks" },
113 {
114 key: "chat-settings",
115 label: "Chat",
116 path: "settings/chat",
117 pages: [{ key: "emoji", label: "Emoji", path: "settings/emoji" }],
118 },
119 ],
120 },
121 {
122 key: "security",
123 title: "Security",
124 pages: [
125 {
126 key: "authentication",
127 label: "Authentication security",
128 path: "settings/authentication",
129 owner: true,
130 pages: [{ key: "sso", label: "Single sign-on", path: "settings/sso", owner: true }],
131 },
132 { key: "security-settings", label: "Code security", path: "settings/security" },
133 { key: "secrets", label: "Secrets and variables", path: "settings/secrets" },
134 ],
135 },
136 {
137 key: "third-party",
138 title: "Third-party access",
139 pages: [
140 { key: "tokens", label: "Access tokens", path: "settings/tokens" },
141 { key: "personal-access-tokens", label: "Personal access tokens", path: "settings/personal-access-tokens", owner: true },
142 ],
143 },
144 {
145 key: "integrations",
146 title: "Integrations",
147 pages: [{ key: "integrations", label: "Integrations", path: "settings/integrations" }],
148 },
149 {
150 key: "archive",
151 title: "Archive",
152 pages: [{ key: "audit", label: "Audit log", path: "settings/audit" }],
153 },
154];
155
156/**
157 * Which of Workspace mode's pages `pathname` is, under the workspace
158 * `slug`: its section, the page, and the page the sidebar lists it under
159 * (`parent`, itself for a top page). Null for anything else. A page's own
160 * subpages (one ruleset, a receipt) count as the page; the deepest match
161 * wins, so Invitations is its own page and not a subpage of Members.
162 */
163export function workspaceSettingsPage(pathname: string, slug: string): { group: string; page: string; parent: string } | null {
164 const parts = pagePath(pathname).split("/").filter(Boolean);
165 if (parts[0]?.toLowerCase() !== slug.toLowerCase() || parts[1] !== "-") return null;
166 const rest = parts.slice(2).join("/");
167 let found: { group: string; page: string; parent: string; depth: number } | null = null;
168 for (const group of WORKSPACE_SETTINGS_GROUPS) {
169 for (const top of group.pages) {
170 for (const page of [top, ...(top.pages ?? [])]) {
171 if (rest === page.path || rest.startsWith(`${page.path}/`)) {
172 const depth = page.path.split("/").length;
173 if (!found || depth > found.depth) found = { group: group.key, page: page.key, parent: top.key, depth };
174 }
175 }
176 }
177 }
178 return found ? { group: found.group, page: found.page, parent: found.parent } : null;
179}
180
181/** The workspace's settings: its Profile, where `-/settings` opens. */
182export function settingsPath(slug: string, page = "profile"): string {
183 return `/${slug}/-/settings/${page}`;
184}
185
186/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
187const TAB_WORDS: Record<string, string> = {
188 overview: "",
189 projects: "-/code/repositories",
190 repositories: "-/code/repositories",
191 repos: "-/code/repositories",
192 packages: "-/packages",
193 people: "-/people",
194 members: "-/members",
195 teams: "-/teams",
196 insights: "-/insights",
197 settings: "-/settings/profile",
198};
199
200/** Workspace pages that moved, by their old name under `-/`; a new address may carry a query of its own. */
201const MOVED: Record<string, string> = {
202 // Home was called Today for a while.
203 today: "-/home",
204 "soon/teams": "-/teams",
205 "soon/insights": "-/insights",
206};
207
208/** Where the page `rest` (under `-/`) is now, or null when it has not moved. */
209function movedTo(rest: string): string | null {
210 return MOVED[rest] ?? null;
211}
212
213/** `to` with `search` merged into any query of its own, as a path under the workspace. */
214function withQuery(to: string, search: string): string {
215 const [path = "", own = ""] = to.split("?", 2);
216 const params = new URLSearchParams(search);
217 for (const [key, value] of new URLSearchParams(own)) params.set(key, value);
218 const query = params.toString();
219 return `${path ? `/${path}` : ""}${query ? `?${query}` : ""}`;
220}
221
222/** The page a request is for: a click's data request ends in `.data`. */
223export function pagePath(pathname: string): string {
224 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
225}
226
227/**
228 * The page at `pathname` under the workspace `slug` instead of its first
229 * segment, keeping its query: where an old name or an alias leads. A click
230 * asks for the page's data at `<path>.data?_routes=…`; the address given is
231 * the page's own, which the browser then asks for as usual.
232 */
233export function underWorkspace(pathname: string, search: string, slug: string): string {
234 const params = new URLSearchParams(search);
235 params.delete("_routes");
236 const query = params.toString();
237 const segments = pagePath(pathname).split("/");
238 // segments[0] is the empty string before the leading slash.
239 segments[1] = slug;
240 return `${segments.join("/")}${query ? `?${query}` : ""}`;
241}
242
243/**
244 * Where an old address of a workspace's pages is now, keeping its query;
245 * null when it has not moved. `/<workspace>?tab=repositories` and the like
246 * open that page.
247 */
248export function workspaceRedirect(pathname: string, search = ""): string | null {
249 // A click asks for the page's data at `<path>.data?_routes=…`; the page
250 // is the same one a full load asks for.
251 const params = new URLSearchParams(search);
252 params.delete("_routes");
253 search = params.toString();
254 const parts = pagePath(pathname).split("/").filter(Boolean);
255 const slug = parts[0];
256 if (!slug) return null;
257 if (parts.length === 1 && params.has("tab")) {
258 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
259 if (to === undefined) return null;
260 params.delete("tab");
261 const rest = params.toString();
262 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
263 }
264 if (parts[1] !== "-") return null;
265 const to = movedTo(parts.slice(2).join("/"));
266 if (to === null) return null;
267 return `/${slug}${withQuery(to, search)}`;
268}
269
270/**
271 * The rail's modes. Home is the front page; Work is the workspace's work
272 * items and the sessions working on them, for every member;
273 * Chat, Code and Artifacts are where work happens; Notifications
274 * spans them; People is who belongs; Workspace is the workspace itself,
275 * its settings area: who has access, money, policies and how each part is
276 * set up; Apps is everything installed that you
277 * can use. `account` is your own settings, under the avatar. `site` is
278 * g1t's own pages (policies, support, a new workspace): no workspace's, so
279 * no mode is lit and no mode's sidebar sits beside them. `browse` is g1t
280 * beyond your workspaces (Explore, search, a profile, another workspace
281 * and its public repositories): the rail lights Explore, and Explore's sidebar
282 * sits beside them (`browsingOf`). Each mode has a sidebar of its own, or
283 * none, and which one is lit follows the address.
284 */
285export type ModeKey =
286 | "home"
287 | "work"
288 | "chat"
289 | "notifications"
290 | "code"
291 | "artifacts"
292 | "routines"
293 | "people"
294 | "workspace"
295 | "apps"
296 | "account"
297 | "site"
298 | "browse";
299
300/** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
301const PAGE_MODES: Record<string, ModeKey> = {
302 home: "home",
303 work: "work",
304 chat: "chat",
305 artifacts: "artifacts",
306 // Artifacts are the Library's (`-/library/artifacts`).
307 library: "artifacts",
308 routines: "routines",
309 apps: "apps",
310 people: "people",
311 members: "workspace",
312 permissions: "workspace",
313 sso: "workspace",
314 teams: "people",
315 usage: "workspace",
316 billing: "workspace",
317 "provider-costs": "workspace",
318 integrations: "workspace",
319 ai: "workspace",
320 guardrails: "workspace",
321 rules: "workspace",
322 audit: "workspace",
323 settings: "workspace",
324 repositories: "workspace",
325 tokens: "workspace",
326 "personal-access-tokens": "workspace",
327 secrets: "workspace",
328 actions: "workspace",
329 runners: "workspace",
330 webhooks: "workspace",
331 emoji: "workspace",
332};
333
334/** First segments that are g1t's own pages, never a workspace: none is any mode's. */
335const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]);
336
337/**
338 * The mode a path is in, for the workspace `slug`. A repository (anyone's)
339 * and a new repository are Code's; g1t's own pages (Explore, search, a
340 * profile) are `site`: public, and no workspace's mode.
341 */
342export function modeOf(pathname: string, slug: string | null): ModeKey {
343 const path = pagePath(pathname);
344 if (path === "/") return "home";
345 if (path === "/notifications" || path.startsWith("/notifications/")) return "notifications";
346 if (path === "/settings" || path.startsWith("/settings/")) return "account";
347 const parts = path.split("/").filter(Boolean);
348 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
349 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
350 // The workspace's own page is Code's front page, as an organization's is on GitHub.
351 if (parts.length === 1) return "code";
352 if (parts[1] === "-") {
353 // The workspace's integrations are its apps: Apps is lit on them, beside the settings' sidebar.
354 if (parts[2] === "settings" && parts[3] === "integrations") return "apps";
355 return PAGE_MODES[parts[2] ?? ""] ?? "code";
356 }
357 }
358 // Another workspace's chat, where the viewer is a guest: Chat all the same.
359 if (parts[1] === "-" && parts[2] === "chat") return "chat";
360 return "code";
361}
362
363/** Whether `pathname` is one of the workspace `slug`'s integrations pages: Apps's mode, with the settings' sidebar. */
364export function isIntegrationsPath(pathname: string, slug: string): boolean {
365 const parts = pagePath(pathname).split("/").filter(Boolean);
366 return parts[0]?.toLowerCase() === slug.toLowerCase() && parts[1] === "-" && parts[2] === "settings" && parts[3] === "integrations";
367}
368
369/** Where each mode's button in the rail goes, in the workspace `slug`. */
370export function modeHome(mode: ModeKey, slug: string): string {
371 switch (mode) {
372 case "home":
373 return `/${slug}/-/home`;
374 case "work":
375 return `/${slug}/-/work`;
376 case "notifications":
377 return "/notifications";
378 case "chat":
379 return `/${slug}/-/chat`;
380 case "artifacts":
381 return `/${slug}/-/library/artifacts`;
382 case "routines":
383 return `/${slug}/-/routines`;
384 case "code":
385 return `/${slug}`;
386 case "people":
387 return `/${slug}/-/people`;
388 case "workspace":
389 return settingsPath(slug);
390 case "apps":
391 return `/${slug}/-/apps`;
392 case "account":
393 return "/settings";
394 case "site":
395 case "browse":
396 return "/explore";
397 }
398}
399
400/** First segments that are g1t's own pages and never anyone's name (packages/contracts names.ts). */
401const NOT_AN_OWNER = new Set([
402 "-",
403 "api",
404 "mcp",
405 "login",
406 "logout",
407 "register",
408 "new",
409 "settings",
410 "search",
411 "admin",
412 "auth",
413 "integrations",
414 "pulls",
415 "issues",
416 "verify",
417 "confirm-email",
418 "forgot",
419 "reset",
420 "device",
421 "workspaces",
422 "u",
423 "oauth",
424 "assets",
425 "avatars",
426 "docs",
427 "explore",
428 "about",
429 "pricing",
430 "policies",
431 "security",
432 "support",
433 "status",
434 "terms",
435 "privacy",
436 "help",
437 "blog",
438 "invite",
439 "invites",
440 "waitlist",
441 "invitations",
442 "v2",
443 "inbox",
444 "notifications",
445 "download",
446 "downloads",
447 "install",
448]);
449
450/**
451 * Whose g1t a browsed page is: a workspace (its overview, its pages, or
452 * one of its projects, `repo`) or a person (their profile).
453 */
454export type BrowsedOwner = { kind: "workspace"; name: string; repo: string | null } | { kind: "person"; name: string };
455
456/** What the shell knows of the viewer, to tell their own pages from the rest of g1t. */
457export type Viewer = {
458 /** The workspace the shell is about. */
459 slug: string | null;
460 /** Every workspace they belong to, by slug. */
461 memberships: readonly string[];
462 /** Repositories shared with them in workspaces they do not belong to: Code lists them as theirs. */
463 shared?: readonly { namespace: string; name: string }[];
464 /** Their own username: their profile is theirs, not someone's being browsed. */
465 username?: string | null;
466};
467
468/**
469 * Whether `pathname` is g1t beyond the viewer's own workspaces, and whose
470 * (`owner`, null for Explore, search and their own profile). Null for
471 * anything of theirs. A workspace they belong to is never browsed, even
472 * when it is not the one they are in: its pages and repositories switch the
473 * shell to it (lib/workspace-choice.ts `workspaceFor`), so they are that
474 * workspace's modes. A repository shared with them is Code's, whose sidebar
475 * lists it. Without a workspace there is no rail, and nothing is browsed.
476 */
477export function browsingOf(pathname: string, viewer: Viewer): { owner: BrowsedOwner | null } | null {
478 if (!viewer.slug) return null;
479 const parts = pagePath(pathname).split("/").filter(Boolean);
480 const first = parts[0]?.toLowerCase();
481 if (!first) return null;
482 if (first === "explore" || first === "search") return { owner: null };
483 if (first === "u") {
484 const name = parts[1];
485 if (!name) return null;
486 const own = viewer.username != null && name.toLowerCase() === viewer.username.toLowerCase();
487 return { owner: own ? null : { kind: "person", name } };
488 }
489 // g1t's own pages, and files such as robots.txt.
490 if (NOT_AN_OWNER.has(first) || first.includes(".")) return null;
491 if (first === viewer.slug.toLowerCase() || viewer.memberships.some((slug) => slug.toLowerCase() === first)) return null;
492 // Another workspace's channels, as a guest in them, are Chat's (`modeOf`), not Explore's.
493 if (parts[1] === "-" && parts[2] === "chat") return null;
494 const repo = parts[1] && parts[1] !== "-" ? parts[1] : null;
495 if (repo && (viewer.shared ?? []).some((r) => r.namespace.toLowerCase() === first && r.name.toLowerCase() === repo.toLowerCase())) return null;
496 return { owner: { kind: "workspace", name: parts[0]!, repo } };
497}
498
499/** The mode a path is in for this viewer: `browse` beyond their workspaces (`browsingOf`), else `modeOf`'s. */
500export function shellMode(pathname: string, viewer: Viewer): ModeKey {
501 return browsingOf(pathname, viewer) ? "browse" : modeOf(pathname, viewer.slug);
502}
503
504/** A workspace's front page: Home, the first tab, on every device. */
505export function homePagePath(slug: string): string {
506 return `/${slug}/-/home`;
507}
508
509/**
510 * Where g1t's front door (`/`) and a workspace's own address lead someone
511 * in the workspace `slug`: Home, or Code's Overview when the query asks
512 * for its panels (a tab or a sort).
513 */
514export function homePath(slug: string, search = ""): string {
515 const params = new URLSearchParams(search);
516 params.delete("_routes");
517 params.delete("index");
518 const query = params.toString();
519 return homePagePath(slug);
520}
521
522/** The page a member without Code access sees in place of anything of Code's. */
523export function codeAccessPath(slug: string, from?: string): string {
524 return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`;
525}
526
527/** A repository's addresses that only send the browser on to Work or to what a reference names. */
528const WORK_REDIRECTS = new Set(["issues", "labels", "milestones", "ref"]);
529
530/** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
531const CODE_PAGES = new Set(["code", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]);
532
533/** The workspace's settings pages (under `-/settings/`) that are Code's, closed as Code's pages are. */
534const CODE_SETTINGS = new Set(["repositories", "rules", "runners", "actions", "security"]);
535
536/**
537 * Where a member without Code access goes instead of `pathname`, or null
538 * when the page is open to them. `noCode` is the workspaces (by slug) where
539 * they lack it; `chosen` is the one they are in. g1t's front door (`/`) is
540 * Home in that workspace, and so is the workspace's own address; its
541 * Code pages and every repository page is the page that says to ask an
542 * owner.
543 */
544export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null {
545 if (noCode.length === 0) return null;
546 const path = pagePath(pathname);
547 if (path === "/") {
548 const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
549 return slug ? homePagePath(slug) : null;
550 }
551 const parts = path.split("/").filter(Boolean);
552 const slug = parts[0]?.toLowerCase();
553 if (!slug || !noCode.includes(slug)) return null;
554 const from = `${path}${search && search !== "?" ? search : ""}`;
555 // The workspace's own page is Home.
556 if (parts.length === 1) return homePagePath(slug);
557 if (parts[1] === "-") {
558 const code = parts[2] === "settings" ? CODE_SETTINGS.has(parts[3] ?? "") : CODE_PAGES.has(parts[2] ?? "");
559 return code ? codeAccessPath(slug, from) : null;
560 }
561 // An old link to a repository's issues, labels or milestones leads to
562 // Work, which is every member's; a reference (`ref`) leads to what it names.
563 if (WORK_REDIRECTS.has(parts[2] ?? "")) return null;
564 // A repository, and everything in it.
565 return codeAccessPath(slug, from);
566}