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 if (parts.length === 1) return "home";
351 if (parts[1] === "-") {
352 // The workspace's integrations are its apps: Apps is lit on them, beside the settings' sidebar.
353 if (parts[2] === "settings" && parts[3] === "integrations") return "apps";
354 return PAGE_MODES[parts[2] ?? ""] ?? "code";
355 }
356 }
357 // Another workspace's chat, where the viewer is a guest: Chat all the same.
358 if (parts[1] === "-" && parts[2] === "chat") return "chat";
359 return "code";
360}
361
362/** Whether `pathname` is one of the workspace `slug`'s integrations pages: Apps's mode, with the settings' sidebar. */
363export function isIntegrationsPath(pathname: string, slug: string): boolean {
364 const parts = pagePath(pathname).split("/").filter(Boolean);
365 return parts[0]?.toLowerCase() === slug.toLowerCase() && parts[1] === "-" && parts[2] === "settings" && parts[3] === "integrations";
366}
367
368/** Where each mode's button in the rail goes, in the workspace `slug`. */
369export function modeHome(mode: ModeKey, slug: string): string {
370 switch (mode) {
371 case "home":
372 return `/${slug}/-/home`;
373 case "work":
374 return `/${slug}/-/work`;
375 case "notifications":
376 return "/notifications";
377 case "chat":
378 return `/${slug}/-/chat`;
379 case "artifacts":
380 return `/${slug}/-/library/artifacts`;
381 case "routines":
382 return `/${slug}/-/routines`;
383 case "code":
384 return `/${slug}/-/overview`;
385 case "people":
386 return `/${slug}/-/people`;
387 case "workspace":
388 return settingsPath(slug);
389 case "apps":
390 return `/${slug}/-/apps`;
391 case "account":
392 return "/settings";
393 case "site":
394 case "browse":
395 return "/explore";
396 }
397}
398
399/** First segments that are g1t's own pages and never anyone's name (packages/contracts names.ts). */
400const NOT_AN_OWNER = new Set([
401 "-",
402 "api",
403 "mcp",
404 "login",
405 "logout",
406 "register",
407 "new",
408 "settings",
409 "search",
410 "admin",
411 "auth",
412 "integrations",
413 "pulls",
414 "issues",
415 "verify",
416 "confirm-email",
417 "forgot",
418 "reset",
419 "device",
420 "workspaces",
421 "u",
422 "oauth",
423 "assets",
424 "avatars",
425 "docs",
426 "explore",
427 "about",
428 "pricing",
429 "policies",
430 "security",
431 "support",
432 "status",
433 "terms",
434 "privacy",
435 "help",
436 "blog",
437 "invite",
438 "invites",
439 "waitlist",
440 "invitations",
441 "v2",
442 "inbox",
443 "notifications",
444 "download",
445 "downloads",
446 "install",
447]);
448
449/**
450 * Whose g1t a browsed page is: a workspace (its overview, its pages, or
451 * one of its projects, `repo`) or a person (their profile).
452 */
453export type BrowsedOwner = { kind: "workspace"; name: string; repo: string | null } | { kind: "person"; name: string };
454
455/** What the shell knows of the viewer, to tell their own pages from the rest of g1t. */
456export type Viewer = {
457 /** The workspace the shell is about. */
458 slug: string | null;
459 /** Every workspace they belong to, by slug. */
460 memberships: readonly string[];
461 /** Repositories shared with them in workspaces they do not belong to: Code lists them as theirs. */
462 shared?: readonly { namespace: string; name: string }[];
463 /** Their own username: their profile is theirs, not someone's being browsed. */
464 username?: string | null;
465};
466
467/**
468 * Whether `pathname` is g1t beyond the viewer's own workspaces, and whose
469 * (`owner`, null for Explore, search and their own profile). Null for
470 * anything of theirs. A workspace they belong to is never browsed, even
471 * when it is not the one they are in: its pages and repositories switch the
472 * shell to it (lib/workspace-choice.ts `workspaceFor`), so they are that
473 * workspace's modes. A repository shared with them is Code's, whose sidebar
474 * lists it. Without a workspace there is no rail, and nothing is browsed.
475 */
476export function browsingOf(pathname: string, viewer: Viewer): { owner: BrowsedOwner | null } | null {
477 if (!viewer.slug) return null;
478 const parts = pagePath(pathname).split("/").filter(Boolean);
479 const first = parts[0]?.toLowerCase();
480 if (!first) return null;
481 if (first === "explore" || first === "search") return { owner: null };
482 if (first === "u") {
483 const name = parts[1];
484 if (!name) return null;
485 const own = viewer.username != null && name.toLowerCase() === viewer.username.toLowerCase();
486 return { owner: own ? null : { kind: "person", name } };
487 }
488 // g1t's own pages, and files such as robots.txt.
489 if (NOT_AN_OWNER.has(first) || first.includes(".")) return null;
490 if (first === viewer.slug.toLowerCase() || viewer.memberships.some((slug) => slug.toLowerCase() === first)) return null;
491 // Another workspace's channels, as a guest in them, are Chat's (`modeOf`), not Explore's.
492 if (parts[1] === "-" && parts[2] === "chat") return null;
493 const repo = parts[1] && parts[1] !== "-" ? parts[1] : null;
494 if (repo && (viewer.shared ?? []).some((r) => r.namespace.toLowerCase() === first && r.name.toLowerCase() === repo.toLowerCase())) return null;
495 return { owner: { kind: "workspace", name: parts[0]!, repo } };
496}
497
498/** The mode a path is in for this viewer: `browse` beyond their workspaces (`browsingOf`), else `modeOf`'s. */
499export function shellMode(pathname: string, viewer: Viewer): ModeKey {
500 return browsingOf(pathname, viewer) ? "browse" : modeOf(pathname, viewer.slug);
501}
502
503/** A workspace's front page: Home, the first tab, on every device. */
504export function homePagePath(slug: string): string {
505 return `/${slug}/-/home`;
506}
507
508/**
509 * Where g1t's front door (`/`) and a workspace's own address lead someone
510 * in the workspace `slug`: Home, or Code's Overview when the query asks
511 * for its panels (a tab or a sort).
512 */
513export function homePath(slug: string, search = ""): string {
514 const params = new URLSearchParams(search);
515 params.delete("_routes");
516 params.delete("index");
517 const query = params.toString();
518 if (params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
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(["overview", "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}