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.

192 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 Sessions may carry: `/acme/-/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 if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`;
58 // Labels and milestones stay on the site.
59 return null;
60 }
61 if (third === "sessions") {
62 if (!fourth) return `/work/sessions?${workspace}${filtersOf(query, SESSION_FILTERS)}`;
63 if (!fifth) return `/work/sessions/${encodeURIComponent(fourth)}?${workspace}`;
64 return null;
65 }
66 if (third === "overview" && !fourth) return `/code?${workspace}`;
67 if (third && RETIRED.has(third)) return `/home?${workspace}`;
68 return null;
69 }
70
71 // A repository's pages: /<owner>/<repo>[/pull/<n>].
72 if (!second) return null;
73 const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`;
74 if (!third) return repo;
75 if (third === "pull" && isNumber(fourth) && !fifth) return `${repo}/pull/${fourth}`;
76 // Issues became the workspace's work items. An old issue's number is
77 // read in its repository, where it may name a pull request (they shared
78 // numbers then): the ref screen asks g1t which.
79 if (third === "issues") {
80 const name = `repo=${encodeURIComponent(second)}`;
81 if (!fourth) return `/work?${workspace}&${name}`;
82 if (fourth === "new" && !fifth) return `/work/new?${workspace}&${name}`;
83 if (isNumber(fourth) && !fifth) return refRoute({ repo: `${owner}/${second}` }, Number(fourth));
84 return null;
85 }
86 return null;
87}
88
89/** Where `#N` is read: in a repository (`owner/name`), or in a workspace alone. */
90export type RefContext = { repo: string } | { workspace: string };
91
92/** The screen that asks g1t what `#N` names in a context, and opens it (app/ref.tsx). */
93export function refRoute(context: RefContext, number: number): string {
94 const where = "repo" in context ? `repo=${encodeURIComponent(context.repo)}` : `workspace=${encodeURIComponent(context.workspace)}`;
95 return `/ref?${where}&number=${number}`;
96}
97
98/**
99 * The context a reference in text is read in (crates/contracts/src/work/refs.rs):
100 * `owner/name#N` in that repository, `workspace#N` in that workspace, and
101 * `#N` where it was written.
102 */
103export function refContext(written: string | null, here: RefContext): RefContext {
104 if (!written) return here;
105 return written.includes("/") ? { repo: written } : { workspace: written };
106}
107
108/** What `#N` names, as the API answers (`RefTarget`, in snake_case). */
109export type RefTarget =
110 | { kind: "work_item"; workspace: string; number: number; item_id?: string }
111 | { kind: "pull"; repo: string; number: number }
112 | { kind: "none" };
113
114/** The app's screen for what a reference names, or null for nothing. */
115export function targetRoute(target: RefTarget): string | null {
116 if (target.kind === "work_item") return `/work/${target.number}?workspace=${encodeURIComponent(target.workspace)}`;
117 if (target.kind === "pull") {
118 const [owner, name] = target.repo.split("/", 2);
119 if (!owner || !name) return null;
120 return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${target.number}`;
121 }
122 return null;
123}
124
125/**
126 * The site's page for a reference, for when the app cannot ask: the site
127 * reads it the same way and redirects.
128 */
129export function refSitePath(context: RefContext, number: number): string {
130 return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`;
131}
132
133/** A session in a list: what kind it is, its repository (`owner/name`) and pull request. */
134export type SessionTarget = { kind: "harness" | "hosted" | "agent" | "recorded"; id: string; repo: string; pull: number | null };
135
136/**
137 * Where tapping a session goes. Someone's harness and hosted ones have a
138 * page of their own; g1t's agent and a recorded one are their pull
139 * request's: its screen in Code, or its session on the site for someone
140 * without Code. Null when there is nowhere to go.
141 */
142export function sessionRoute(session: SessionTarget, workspace: string, code: boolean): string | null {
143 if (session.kind === "harness" || session.kind === "hosted") {
144 return `/work/sessions/${encodeURIComponent(session.id)}?workspace=${encodeURIComponent(workspace)}`;
145 }
146 const [owner, name] = session.repo.split("/", 2);
147 if (!session.pull || !owner || !name) return null;
148 if (code) return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${session.pull}`;
149 return `/open?path=${encodeURIComponent(`/${owner}/${name}/pull/${session.pull}/session`)}`;
150}
151
152/**
153 * The site path a `g1t://` link names, or null for any other link.
154 * `g1t://acme/-/chat?x` is `/acme/-/chat?x`.
155 */
156export function sitePathOf(link: string): string | null {
157 const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link);
158 if (!match) return null;
159 const path = `/${match[1].replace(/^\/+/, "")}`;
160 return path + (match[2] ?? "");
161}
162
163/**
164 * The site path any link that opens the app names: a `g1t://` one, or an
165 * `https` one to the site (Android App Links, iOS Universal Links), as
166 * `https://g1t.sh/acme/-/work/12` is `/acme/-/work/12`. Null for others.
167 */
168export function linkPathOf(link: string): string | null {
169 const own = sitePathOf(link);
170 if (own) return own;
171 const web = /^https?:\/\/[^/?#]+(\/[^?#]*)?(\?[^#]*)?/i.exec(link);
172 return web ? (web[1] || "/") + (web[2] ?? "") : null;
173}
174
175function filtersOf(query: string, names: readonly string[]): string {
176 const params = new URLSearchParams(query);
177 return names.filter((name) => params.get(name))
178 .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`)
179 .join("");
180}
181
182function isNumber(part: string | undefined): part is string {
183 return Boolean(part && /^\d+$/.test(part));
184}
185
186function decodeURIComponentSafe(part: string): string {
187 try {
188 return decodeURIComponent(part);
189 } catch {
190 return part;
191 }
192}