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.

g1t.sh
148 linesCodeBlameRaw
1/**
2 * Pages that are tabs of one page keep their tab in the path, not in
3 * `?tab=`: `/u/ada/pulls`, `/acme/web/pull/12/files`,
4 * `/acme/web/branches/stale`, `/acme/web/settings/rules/insights`,
5 * `/notifications/saved`. What narrows a list (state, q, sort, a cursor)
6 * stays in the query.
7 *
8 * Addresses from before still say `?tab=`: `movedTab` is where one goes
9 * now, the rest of its query kept, so each page's loader sends it on (301).
10 * No Workers or DOM imports, so it is tested under Node.
11 */
12
13/** Where an address that still says `?tab=` goes now, or null when it says no `?tab=`. */
14export function movedTab(url: URL, pathFor: (tab: string) => string): string | null {
15 const tab = url.searchParams.get("tab");
16 if (tab == null) return null;
17 return `${pathFor(tab.toLowerCase())}${rest(url, "tab")}`;
18}
19
20/**
21 * The query an address keeps once `moved` is in its path, with its `?`.
22 * A click asks for a page's data at `<path>.data?_routes=…`; the address
23 * sent on is the page's own, which the browser then asks for as usual.
24 */
25function rest(url: URL, moved: string): string {
26 const kept = new URLSearchParams(url.searchParams);
27 kept.delete(moved);
28 kept.delete("_routes");
29 const query = kept.toString();
30 return query ? `?${query}` : "";
31}
32
33/** The segment `value` names among `tabs`, the first (the page's own address) for nothing, null for anything else. */
34function segmentTab<T extends string>(tabs: readonly T[], value: string | undefined): T | null {
35 if (value == null || value === "") return tabs[0];
36 return tabs.find((tab) => tab !== tabs[0] && tab === value.toLowerCase()) ?? null;
37}
38
39// --- A person's profile -----------------------------------------------------------
40
41export const PROFILE_TABS = ["overview", "pulls", "issues", "stars"] as const;
42export type ProfileTab = (typeof PROFILE_TABS)[number];
43
44/** `/u/<username>`, or one of its tabs: `/u/<username>/pulls`. */
45export function profilePath(username: string, tab: ProfileTab | string = "overview"): string {
46 const known = (PROFILE_TABS as readonly string[]).includes(tab) ? tab : "overview";
47 return known === "overview" ? `/u/${username}` : `/u/${username}/${known}`;
48}
49
50/** The profile tab a path segment names; null for one it does not (a 404). */
51export function profileTab(segment: string | undefined): ProfileTab | null {
52 return segmentTab(PROFILE_TABS, segment);
53}
54
55// --- A pull request ---------------------------------------------------------------
56
57export const PULL_TABS = ["conversation", "session", "changes"] as const;
58export type PullTab = (typeof PULL_TABS)[number];
59
60/** Each tab's segment: Files changed is `/files`. */
61const PULL_SEGMENTS: Record<PullTab, string> = { conversation: "", session: "/session", changes: "/files" };
62
63/** `<repo>/pull/<n>`, or one of its tabs: `<repo>/pull/<n>/files`. `repo` is `/<owner>/<repo>`. */
64export function pullPath(repo: string, number: number | string, tab: PullTab | string = "conversation"): string {
65 const known: PullTab = tab === "files" ? "changes" : ((PULL_TABS as readonly string[]).includes(tab) ? (tab as PullTab) : "conversation");
66 return `${repo}/pull/${number}${PULL_SEGMENTS[known]}`;
67}
68
69/** The pull request tab a path segment names; null for one it does not (a 404). */
70export function pullTab(segment: string | undefined): PullTab | null {
71 if (segment == null || segment === "") return "conversation";
72 const value = segment.toLowerCase();
73 if (value === "files") return "changes";
74 if (value === "session") return "session";
75 return null;
76}
77
78// --- A repository's branches ------------------------------------------------------
79
80/** The Branches page's tabs, as lib/branches.ts's BRANCH_TABS. */
81const BRANCH_SEGMENTS = ["overview", "yours", "active", "stale", "all"] as const;
82
83/** `<repo>/branches`, or one of its tabs: `<repo>/branches/stale`. */
84export function branchesPath(repo: string, tab: string = "overview"): string {
85 const known = (BRANCH_SEGMENTS as readonly string[]).includes(tab) ? tab : "overview";
86 return known === "overview" ? `${repo}/branches` : `${repo}/branches/${known}`;
87}
88
89/** The Branches tab a path segment names; null for one it does not (a 404). */
90export function branchSegment(segment: string | undefined): (typeof BRANCH_SEGMENTS)[number] | null {
91 return segmentTab(BRANCH_SEGMENTS, segment);
92}
93
94// --- Rules: a repository's or a workspace's ---------------------------------------
95
96export type RulesTab = "rulesets" | "insights";
97
98/** The rules page (`<repo>/settings/rules`, `/<workspace>/-/rules`), or its Insights: `…/rules/insights`. */
99export function rulesPath(rules: string, tab: RulesTab | string = "rulesets"): string {
100 return tab === "insights" ? `${rules}/insights` : rules;
101}
102
103/** The rules tab a page's path is on: `…/rules/insights` is Insights (its data at `…/insights.data`). */
104export function rulesAt(pathname: string): RulesTab {
105 return /\/rules\/insights(\.data)?\/?$/.test(pathname) ? "insights" : "rulesets";
106}
107
108// --- Notifications ----------------------------------------------------------------
109
110export const NOTIFICATION_VIEWS = ["inbox", "saved", "done"] as const;
111export type NotificationView = (typeof NOTIFICATION_VIEWS)[number];
112
113/** `/notifications` (Everything), `/notifications/saved` or `/notifications/done`. */
114export function notificationsPath(view: NotificationView | string = "inbox"): string {
115 return view === "saved" || view === "done" ? `/notifications/${view}` : "/notifications";
116}
117
118/** The notifications view a path segment names; null for one it does not (a 404). */
119export function notificationView(segment: string | undefined): NotificationView | null {
120 return segmentTab(NOTIFICATION_VIEWS, segment);
121}
122
123/**
124 * A notifications address: the view as its path, and what narrows it
125 * (`tab`, the severity tabs; `reason`; `before`, a page's cursor) as its
126 * query. Empty values, and the `all` tab, are left out.
127 */
128export function notificationsAddress(state: { view?: string | null; tab?: string | null; reason?: string | null; before?: string | null }): string {
129 const query = new URLSearchParams();
130 if (state.tab && state.tab !== "all") query.set("tab", state.tab);
131 if (state.reason) query.set("reason", state.reason);
132 if (state.before) query.set("before", state.before);
133 const text = query.toString();
134 return `${notificationsPath(state.view ?? "inbox")}${text ? `?${text}` : ""}`;
135}
136
137/** The view a notifications page's path is on: `/notifications/saved` is Saved. */
138export function notificationViewAt(pathname: string): NotificationView {
139 const [first, second] = pathname.split("/").filter(Boolean);
140 return first === "notifications" ? (notificationView(second) ?? "inbox") : "inbox";
141}
142
143/** Where a notifications address that still says `?view=` goes now, or null. */
144export function movedView(url: URL): string | null {
145 const view = url.searchParams.get("view");
146 if (view == null) return null;
147 return `${notificationsPath(view.toLowerCase())}${rest(url, "view")}`;
148}