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.

152 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", "items", "stars"] as const;
42export type ProfileTab = (typeof PROFILE_TABS)[number];
43
44/** Profile tabs by an old name: Issues is Work items now. */
45const PROFILE_MOVED: Record<string, ProfileTab> = { issues: "items" };
46
47/** `/u/<username>`, or one of its tabs: `/u/<username>/pulls`. */
48export function profilePath(username: string, tab: ProfileTab | string = "overview"): string {
49 const named = PROFILE_MOVED[tab] ?? tab;
50 const known = (PROFILE_TABS as readonly string[]).includes(named) ? named : "overview";
51 return known === "overview" ? `/u/${username}` : `/u/${username}/${known}`;
52}
53
54/** The profile tab a path segment names; null for one it does not (a 404). */
55export function profileTab(segment: string | undefined): ProfileTab | null {
56 return PROFILE_MOVED[segment?.toLowerCase() ?? ""] ?? segmentTab(PROFILE_TABS, segment);
57}
58
59// --- A pull request ---------------------------------------------------------------
60
61export const PULL_TABS = ["conversation", "session", "changes"] as const;
62export type PullTab = (typeof PULL_TABS)[number];
63
64/** Each tab's segment: Files changed is `/files`. */
65const PULL_SEGMENTS: Record<PullTab, string> = { conversation: "", session: "/session", changes: "/files" };
66
67/** `<repo>/pull/<n>`, or one of its tabs: `<repo>/pull/<n>/files`. `repo` is `/<owner>/<repo>`. */
68export function pullPath(repo: string, number: number | string, tab: PullTab | string = "conversation"): string {
69 const known: PullTab = tab === "files" ? "changes" : (PULL_TABS as readonly string[]).includes(tab) ? (tab as PullTab) : "conversation";
70 return `${repo}/pull/${number}${PULL_SEGMENTS[known]}`;
71}
72
73/** The pull request tab a path segment names; null for one it does not (a 404). */
74export function pullTab(segment: string | undefined): PullTab | null {
75 if (segment == null || segment === "") return "conversation";
76 const value = segment.toLowerCase();
77 if (value === "files") return "changes";
78 if (value === "session") return "session";
79 return null;
80}
81
82// --- A repository's branches ------------------------------------------------------
83
84/** The Branches page's tabs, as lib/branches.ts's BRANCH_TABS. */
85const BRANCH_SEGMENTS = ["overview", "yours", "active", "stale", "all"] as const;
86
87/** `<repo>/branches`, or one of its tabs: `<repo>/branches/stale`. */
88export function branchesPath(repo: string, tab: string = "overview"): string {
89 const known = (BRANCH_SEGMENTS as readonly string[]).includes(tab) ? tab : "overview";
90 return known === "overview" ? `${repo}/branches` : `${repo}/branches/${known}`;
91}
92
93/** The Branches tab a path segment names; null for one it does not (a 404). */
94export function branchSegment(segment: string | undefined): (typeof BRANCH_SEGMENTS)[number] | null {
95 return segmentTab(BRANCH_SEGMENTS, segment);
96}
97
98// --- Rules: a repository's or a workspace's ---------------------------------------
99
100export type RulesTab = "rulesets" | "insights";
101
102/** The rules page (`<repo>/settings/rules`, `/<workspace>/-/rules`), or its Insights: `…/rules/insights`. */
103export function rulesPath(rules: string, tab: RulesTab | string = "rulesets"): string {
104 return tab === "insights" ? `${rules}/insights` : rules;
105}
106
107/** The rules tab a page's path is on: `…/rules/insights` is Insights (its data at `…/insights.data`). */
108export function rulesAt(pathname: string): RulesTab {
109 return /\/rules\/insights(\.data)?\/?$/.test(pathname) ? "insights" : "rulesets";
110}
111
112// --- Notifications ----------------------------------------------------------------
113
114export const NOTIFICATION_VIEWS = ["inbox", "saved", "done"] as const;
115export type NotificationView = (typeof NOTIFICATION_VIEWS)[number];
116
117/** `/notifications` (Everything), `/notifications/saved` or `/notifications/done`. */
118export function notificationsPath(view: NotificationView | string = "inbox"): string {
119 return view === "saved" || view === "done" ? `/notifications/${view}` : "/notifications";
120}
121
122/** The notifications view a path segment names; null for one it does not (a 404). */
123export function notificationView(segment: string | undefined): NotificationView | null {
124 return segmentTab(NOTIFICATION_VIEWS, segment);
125}
126
127/**
128 * A notifications address: the view as its path, and what narrows it
129 * (`tab`, the severity tabs; `reason`; `before`, a page's cursor) as its
130 * query. Empty values, and the `all` tab, are left out.
131 */
132export function notificationsAddress(state: { view?: string | null; tab?: string | null; reason?: string | null; before?: string | null }): string {
133 const query = new URLSearchParams();
134 if (state.tab && state.tab !== "all") query.set("tab", state.tab);
135 if (state.reason) query.set("reason", state.reason);
136 if (state.before) query.set("before", state.before);
137 const text = query.toString();
138 return `${notificationsPath(state.view ?? "inbox")}${text ? `?${text}` : ""}`;
139}
140
141/** The view a notifications page's path is on: `/notifications/saved` is Saved. */
142export function notificationViewAt(pathname: string): NotificationView {
143 const [first, second] = pathname.split("/").filter(Boolean);
144 return first === "notifications" ? (notificationView(second) ?? "inbox") : "inbox";
145}
146
147/** Where a notifications address that still says `?view=` goes now, or null. */
148export function movedView(url: URL): string | null {
149 const view = url.searchParams.get("view");
150 if (view == null) return null;
151 return `${notificationsPath(view.toLowerCase())}${rest(url, "view")}`;
152}