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.

164 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/**
27 * The app's screen for a site path, or null when the app has none and the
28 * page should open on the site.
29 */
30export function routeFor(sitePath: string): string | null {
31 const [pathname, query = ""] = sitePath.split("?", 2);
32 const parts = pathname.split("/").filter(Boolean).map(decodeURIComponentSafe);
33 const keep = query ? `?${query}` : "";
34 if (parts.length === 0) return "/";
35 if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep;
36 const [owner, second, third, fourth, fifth] = parts;
37 // `g1t://connect?server=<site>`: point the app at a g1t, as a self-hosted one's download page offers.
38 if (owner === "connect" && !second && /(^|&)server=/.test(query)) return `/connect?${query}`;
39 if (NOT_OWNERS.has(owner.toLowerCase())) return null;
40 const workspace = `workspace=${encodeURIComponent(owner)}`;
41
42 // A workspace's own pages: /<workspace>/-/<page>.
43 if (second === "-") {
44 if (third === "chat") {
45 if (!fourth) return `/chat?${workspace}`;
46 if (fourth === "dm" && fifth) return `/chat/${encodeURIComponent(fifth)}?${workspace}`;
47 if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`;
48 return null;
49 }
50 if (third === "work") {
51 if (!fourth) return `/work?${workspace}${filtersOf(query)}`;
52 if (fourth === "new" && !fifth) return `/work/new?${workspace}`;
53 if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`;
54 // Labels and milestones stay on the site.
55 return null;
56 }
57 if (third === "overview" && !fourth) return `/code?${workspace}`;
58 if (third && RETIRED.has(third)) return `/home?${workspace}`;
59 return null;
60 }
61
62 // A repository's pages: /<owner>/<repo>[/pull/<n>].
63 if (!second) return null;
64 const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`;
65 if (!third) return repo;
66 if (third === "pull" && isNumber(fourth) && !fifth) return `${repo}/pull/${fourth}`;
67 // Issues became the workspace's work items. An old issue's number is
68 // read in its repository, where it may name a pull request (they shared
69 // numbers then): the ref screen asks g1t which.
70 if (third === "issues") {
71 const name = `repo=${encodeURIComponent(second)}`;
72 if (!fourth) return `/work?${workspace}&${name}`;
73 if (fourth === "new" && !fifth) return `/work/new?${workspace}&${name}`;
74 if (isNumber(fourth) && !fifth) return refRoute({ repo: `${owner}/${second}` }, Number(fourth));
75 return null;
76 }
77 return null;
78}
79
80/** Where `#N` is read: in a repository (`owner/name`), or in a workspace alone. */
81export type RefContext = { repo: string } | { workspace: string };
82
83/** The screen that asks g1t what `#N` names in a context, and opens it (app/ref.tsx). */
84export function refRoute(context: RefContext, number: number): string {
85 const where = "repo" in context ? `repo=${encodeURIComponent(context.repo)}` : `workspace=${encodeURIComponent(context.workspace)}`;
86 return `/ref?${where}&number=${number}`;
87}
88
89/**
90 * The context a reference in text is read in (crates/contracts/src/work/refs.rs):
91 * `owner/name#N` in that repository, `workspace#N` in that workspace, and
92 * `#N` where it was written.
93 */
94export function refContext(written: string | null, here: RefContext): RefContext {
95 if (!written) return here;
96 return written.includes("/") ? { repo: written } : { workspace: written };
97}
98
99/** What `#N` names, as the API answers (`RefTarget`, in snake_case). */
100export type RefTarget =
101 | { kind: "work_item"; workspace: string; number: number; item_id?: string }
102 | { kind: "pull"; repo: string; number: number }
103 | { kind: "none" };
104
105/** The app's screen for what a reference names, or null for nothing. */
106export function targetRoute(target: RefTarget): string | null {
107 if (target.kind === "work_item") return `/work/${target.number}?workspace=${encodeURIComponent(target.workspace)}`;
108 if (target.kind === "pull") {
109 const [owner, name] = target.repo.split("/", 2);
110 if (!owner || !name) return null;
111 return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${target.number}`;
112 }
113 return null;
114}
115
116/**
117 * The site's page for a reference, for when the app cannot ask: the site
118 * reads it the same way and redirects.
119 */
120export function refSitePath(context: RefContext, number: number): string {
121 return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`;
122}
123
124/**
125 * The site path a `g1t://` link names, or null for any other link.
126 * `g1t://acme/-/chat?x` is `/acme/-/chat?x`.
127 */
128export function sitePathOf(link: string): string | null {
129 const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link);
130 if (!match) return null;
131 const path = `/${match[1].replace(/^\/+/, "")}`;
132 return path + (match[2] ?? "");
133}
134
135/**
136 * The site path any link that opens the app names: a `g1t://` one, or an
137 * `https` one to the site (Android App Links, iOS Universal Links), as
138 * `https://g1t.sh/acme/-/work/12` is `/acme/-/work/12`. Null for others.
139 */
140export function linkPathOf(link: string): string | null {
141 const own = sitePathOf(link);
142 if (own) return own;
143 const web = /^https?:\/\/[^/?#]+(\/[^?#]*)?(\?[^#]*)?/i.exec(link);
144 return web ? (web[1] || "/") + (web[2] ?? "") : null;
145}
146
147function filtersOf(query: string): string {
148 const params = new URLSearchParams(query);
149 return WORK_FILTERS.filter((name) => params.get(name))
150 .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`)
151 .join("");
152}
153
154function isNumber(part: string | undefined): part is string {
155 return Boolean(part && /^\d+$/.test(part));
156}
157
158function decodeURIComponentSafe(part: string): string {
159 try {
160 return decodeURIComponent(part);
161 } catch {
162 return part;
163 }
164}