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.

194 linesCodeBlameRaw
1/**
2 * Links into the app. A `g1t://` link names a site path, as on the desktop
3 * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is
4 * the page `/acme/-/chat/dm/chn_1`, as `https://g1t.sh/acme/-/chat/dm/chn_1`
5 * is. The app has screens for some of the site's pages; `routeFor` says
6 * which screen shows a site path, and the rest open on the site.
7 *
8 * Kept free of React Native imports so `npm test` runs it under Node.
9 */
10
11/** Where the app's OAuth sign-in comes back to. */
12export const OAUTH_CALLBACK_PATH = "/oauth/callback";
13
14/**
15 * Pages a workspace no longer has: Agents (with the skill library),
16 * Customize and the Marketplace. An old link to one opens Home.
17 */
18const RETIRED = new Set(["agents", "customize", "marketplace"]);
19
20/** Path segments a workspace or account can never be named (the site's own pages). */
21const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]);
22
23/** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */
24export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const;
25
26/** The filters a link to Work's sessions may carry: `/acme/-/work/sessions?state=live&repo=web`. */
27export const SESSION_FILTERS = ["state", "person", "repo", "item"] as const;
28
29/**
30 * The app's screen for a site path, or null when the app has none and the
31 * page should open on the site.
32 */
33export function routeFor(sitePath: string): string | null {
34 const [pathname, query = ""] = sitePath.split("?", 2);
35 const parts = pathname.split("/").filter(Boolean).map(decodeURIComponentSafe);
36 const keep = query ? `?${query}` : "";
37 if (parts.length === 0) return "/";
38 if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep;
39 const [owner, second, third, fourth, fifth] = parts;
40 // `g1t://connect?server=<site>`: point the app at a g1t, as a self-hosted one's download page offers.
41 if (owner === "connect" && !second && /(^|&)server=/.test(query)) return `/connect?${query}`;
42 if (NOT_OWNERS.has(owner.toLowerCase())) return null;
43 const workspace = `workspace=${encodeURIComponent(owner)}`;
44
45 // A workspace's own pages: /<workspace>/-/<page>.
46 if (second === "-") {
47 if (third === "chat") {
48 if (!fourth) return `/chat?${workspace}`;
49 if (fourth === "dm" && fifth) return `/chat/${encodeURIComponent(fifth)}?${workspace}`;
50 if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`;
51 return null;
52 }
53 if (third === "work") {
54 if (!fourth) return `/work?${workspace}${filtersOf(query, WORK_FILTERS)}`;
55 if (fourth === "new" && !fifth) return `/work/new?${workspace}`;
56 if (fourth === "board" && !fifth) return `/work?${workspace}&view=board`;
57 // Sessions are Work's: its Sessions view, and one session's screen.
58 if (fourth === "sessions") {
59 if (!fifth) return `/work?${workspace}&view=sessions${filtersOf(query, SESSION_FILTERS)}`;
60 if (parts.length === 5) return `/work/sessions/${encodeURIComponent(fifth)}?${workspace}`;
61 return null;
62 }
63 if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`;
64 // Labels and milestones stay on the site.
65 return null;
66 }
67 if (third === "overview" && !fourth) return `/code?${workspace}`;
68 if (third && RETIRED.has(third)) return `/home?${workspace}`;
69 return null;
70 }
71
72 // A repository's pages: /<owner>/<repo>[/pull/<n>].
73 if (!second) return null;
74 const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`;
75 if (!third) return repo;
76 if (third === "pull" && isNumber(fourth) && !fifth) return `${repo}/pull/${fourth}`;
77 // Issues became the workspace's work items. An old issue's number is
78 // read in its repository, where it may name a pull request (they shared
79 // numbers then): the ref screen asks g1t which.
80 if (third === "issues") {
81 const name = `repo=${encodeURIComponent(second)}`;
82 if (!fourth) return `/work?${workspace}&${name}`;
83 if (fourth === "new" && !fifth) return `/work/new?${workspace}&${name}`;
84 if (isNumber(fourth) && !fifth) return refRoute({ repo: `${owner}/${second}` }, Number(fourth));
85 return null;
86 }
87 return null;
88}
89
90/** Where `#N` is read: in a repository (`owner/name`), or in a workspace alone. */
91export type RefContext = { repo: string } | { workspace: string };
92
93/** The screen that asks g1t what `#N` names in a context, and opens it (app/ref.tsx). */
94export function refRoute(context: RefContext, number: number): string {
95 const where = "repo" in context ? `repo=${encodeURIComponent(context.repo)}` : `workspace=${encodeURIComponent(context.workspace)}`;
96 return `/ref?${where}&number=${number}`;
97}
98
99/**
100 * The context a reference in text is read in (crates/contracts/src/work/refs.rs):
101 * `owner/name#N` in that repository, `workspace#N` in that workspace, and
102 * `#N` where it was written.
103 */
104export function refContext(written: string | null, here: RefContext): RefContext {
105 if (!written) return here;
106 return written.includes("/") ? { repo: written } : { workspace: written };
107}
108
109/** What `#N` names, as the API answers (`RefTarget`, in snake_case). */
110export type RefTarget =
111 | { kind: "work_item"; workspace: string; number: number; item_id?: string }
112 | { kind: "pull"; repo: string; number: number }
113 | { kind: "none" };
114
115/** The app's screen for what a reference names, or null for nothing. */
116export function targetRoute(target: RefTarget): string | null {
117 if (target.kind === "work_item") return `/work/${target.number}?workspace=${encodeURIComponent(target.workspace)}`;
118 if (target.kind === "pull") {
119 const [owner, name] = target.repo.split("/", 2);
120 if (!owner || !name) return null;
121 return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${target.number}`;
122 }
123 return null;
124}
125
126/**
127 * The site's page for a reference, for when the app cannot ask: the site
128 * reads it the same way and redirects.
129 */
130export function refSitePath(context: RefContext, number: number): string {
131 return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`;
132}
133
134/** A session in a list: what kind it is, its repository (`owner/name`) and pull request. */
135export type SessionTarget = { kind: "harness" | "hosted" | "agent" | "recorded"; id: string; repo: string; pull: number | null };
136
137/**
138 * Where tapping a session goes. Someone's harness and hosted ones have a
139 * page of their own; g1t's agent and a recorded one are their pull
140 * request's: its screen in Code, or its session on the site for someone
141 * without Code. Null when there is nowhere to go.
142 */
143export function sessionRoute(session: SessionTarget, workspace: string, code: boolean): string | null {
144 if (session.kind === "harness" || session.kind === "hosted") {
145 return `/work/sessions/${encodeURIComponent(session.id)}?workspace=${encodeURIComponent(workspace)}`;
146 }
147 const [owner, name] = session.repo.split("/", 2);
148 if (!session.pull || !owner || !name) return null;
149 if (code) return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${session.pull}`;
150 return `/open?path=${encodeURIComponent(`/${owner}/${name}/pull/${session.pull}/session`)}`;
151}
152
153/**
154 * The site path a `g1t://` link names, or null for any other link.
155 * `g1t://acme/-/chat?x` is `/acme/-/chat?x`.
156 */
157export function sitePathOf(link: string): string | null {
158 const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link);
159 if (!match) return null;
160 const path = `/${match[1].replace(/^\/+/, "")}`;
161 return path + (match[2] ?? "");
162}
163
164/**
165 * The site path any link that opens the app names: a `g1t://` one, or an
166 * `https` one to the site (Android App Links, iOS Universal Links), as
167 * `https://g1t.sh/acme/-/work/12` is `/acme/-/work/12`. Null for others.
168 */
169export function linkPathOf(link: string): string | null {
170 const own = sitePathOf(link);
171 if (own) return own;
172 const web = /^https?:\/\/[^/?#]+(\/[^?#]*)?(\?[^#]*)?/i.exec(link);
173 return web ? (web[1] || "/") + (web[2] ?? "") : null;
174}
175
176function filtersOf(query: string, names: readonly string[]): string {
177 const params = new URLSearchParams(query);
178 return names
179 .filter((name) => params.get(name))
180 .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`)
181 .join("");
182}
183
184function isNumber(part: string | undefined): part is string {
185 return Boolean(part && /^\d+$/.test(part));
186}
187
188function decodeURIComponentSafe(part: string): string {
189 try {
190 return decodeURIComponent(part);
191 } catch {
192 return part;
193 }
194}