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

Commit

Connecting GitHub to a workspace is one click and one trip to GitHub. A workspace owner's Connect GitHub on Import from GitHub links their GitHub account on the way if it is not linked yet and comes straight back to carry on: an installation they already have on GitHub and the workspace lacks is added rather than installed again (with several, they choose), and only with none are they sent to install the app; GitHub then returns them to the import page with the account connected. Before, linking and installing were separate chores (link, come back, install), Settings -> GitHub stopped at "Linked" with nowhere to go, and an app already on the account was "installed" again through GitHub's settings page. When GitHub returns after an installation that did not start from g1t, an unlinked account is linked on the way instead of showing an error with a button, and someone who owns one workspace has it added there without being asked which. Choose repositories on GitHub opens in the same tab, since GitHub brings you back after saving; Add a GitHub account still always installs on another account; Settings -> GitHub points to Import from GitHub once linked. A link that does not take shows the page instead of going round again. The GitHub guide describes the one-step flow. (The app's Setup URL was empty on GitHub, which left people there after installing; it is now set to /integrations/github/setup.) Checked: the site's typecheck (tsc -b --force), its 911 tests (new: what Connect GitHub does next), a production build and the deploy tool's 92 tests.

syntaqxcommitted Parentbe0e43bBrowse files
7 files+146−420/7 viewed
+19−13
7272
7373 1. Choose **New project**, then **Import from GitHub**. You can also go to
7474 `g1t.sh/new/github`.
75−2. If your GitHub account is not linked yet, choose **Connect GitHub**.
76−3. If no GitHub account is connected to the workspace, a workspace owner
77− chooses **Install on GitHub**, picks the GitHub account or organization,
78− and chooses which repositories the app may see. GitHub returns you to
79− g1t, which records the installation for the workspace. If the app is
80− installed on GitHub already, add it instead: see
81− [already installed the app on GitHub](#already-installed-the-app-on-github).
75+2. A workspace owner chooses **Connect GitHub**. In one trip to GitHub,
76+ g1t links your GitHub account (if it is not linked yet) and installs
77+ its GitHub App on the account or organization you pick, with the
78+ repositories you let it see. GitHub then returns you to the import page
79+ with that account connected to the workspace. If the app is installed
80+ on your GitHub account already, g1t adds that installation instead of
81+ asking you to install again; with several, you choose which to add.
82+3. Members who are not owners choose **Connect GitHub** to link their own
83+ GitHub account, and import from the accounts an owner connected.
8284 4. Tick the repositories to bring across. You see only repositories that
8385 both you and the app can reach.
8486 5. Choose whether to copy their issues.
9597 after you install the app. If you installed it on GitHub directly, in
9698 another browser, or the return to g1t was lost, add it from g1t instead:
9799
98−1. Make sure your GitHub account is linked: **Connect GitHub** on the import
99− page, or **Link GitHub** in [Settings → GitHub](https://g1t.sh/settings/github).
100−2. Open `g1t.sh/new/github?workspace=<workspace>`.
101−3. Under **Already installed on GitHub**, choose **Add to** the workspace
102− beside the GitHub account or organization.
100+1. Open `g1t.sh/new/github?workspace=<workspace>` and choose **Connect
101+ GitHub**. With one installation, g1t adds it straight away.
102+2. With several, choose **Add to** the workspace beside the GitHub account
103+ or organization under **Already installed on GitHub**.
103104
104105 Only a workspace owner can add one, and only an installation your own
105106 GitHub account can see: on your account, or on an organization you belong
106107 to.
107108
108109 If GitHub sends you to g1t after an installation that did not start from
109−g1t, g1t asks which of the workspaces you own to add it to. It is the same
110+g1t, g1t adds it to the workspace you own, or asks which one if you own
111+several, linking your GitHub account on the way if needed. It is the same
110112 check: the installation must be one your GitHub account can see.
111113
114+**Choose repositories on GitHub**, beside a connected account, opens the
115+installation's settings on GitHub; saving there brings you back to the
116+import page.
117+
112118 ### What comes across
113119
114120 | | |
+19−0
55 import {
66 INSTALL_COOKIE,
77 STATE_COOKIE,
8+ connectStep,
89 cookie,
910 githubConnected,
1011 installCookieValue,
106107 "The installation on b is suspended on GitHub.",
107108 );
108109 });
110+
111+test("Connect GitHub links first, adds what is there, and installs only when nothing is", () => {
112+ const none: { id: number; suspended: boolean }[] = [];
113+ assert.deepEqual(connectStep({ linked: false, justLinked: false, another: false, unadded: none }), { kind: "link" });
114+ // A link that did not take goes back to the page, not round again.
115+ assert.deepEqual(connectStep({ linked: false, justLinked: true, another: false, unadded: none }), { kind: "choose" });
116+ assert.deepEqual(connectStep({ linked: true, justLinked: true, another: false, unadded: none }), { kind: "install" });
117+ assert.deepEqual(connectStep({ linked: true, justLinked: false, another: false, unadded: [{ id: 7, suspended: false }] }), {
118+ kind: "add",
119+ installation: 7,
120+ });
121+ assert.deepEqual(connectStep({ linked: true, justLinked: false, another: false, unadded: [{ id: 7, suspended: true }] }), { kind: "choose" });
122+ assert.deepEqual(
123+ connectStep({ linked: true, justLinked: false, another: false, unadded: [{ id: 7, suspended: false }, { id: 8, suspended: false }] }),
124+ { kind: "choose" },
125+ );
126+ assert.deepEqual(connectStep({ linked: true, justLinked: false, another: true, unadded: [{ id: 7, suspended: false }] }), { kind: "install" });
127+});
+28−0
9191 return seen.filter((item) => !item.recordedIn.includes(workspace));
9292 }
9393
94+/** Where "Connect GitHub" goes next for a workspace owner. */
95+export type ConnectStep =
96+ | { kind: "link" }
97+ | { kind: "add"; installation: number }
98+ | { kind: "choose" }
99+ | { kind: "install" };
100+
101+/**
102+ * One click connects GitHub to a workspace: the person's GitHub account is
103+ * linked first (g1t lists repositories as them), an installation they
104+ * already have is added rather than installed again, several are left to
105+ * choose from, and only with none is the app installed on GitHub.
106+ * `another` is an owner asking to install on one more account; `justLinked`
107+ * stops a link that did not take from sending them round again.
108+ */
109+export function connectStep(input: {
110+ linked: boolean;
111+ justLinked: boolean;
112+ another: boolean;
113+ unadded: { id: number; suspended: boolean }[];
114+}): ConnectStep {
115+ if (!input.linked) return input.justLinked ? { kind: "choose" } : { kind: "link" };
116+ if (input.another) return { kind: "install" };
117+ const usable = input.unadded.filter((item) => !item.suspended);
118+ if (usable.length === 1 && input.unadded.length === 1) return { kind: "add", installation: usable[0].id };
119+ return input.unadded.length > 0 ? { kind: "choose" } : { kind: "install" };
120+}
121+
94122 /** How an installation is described in a list: `Organization · all repositories`. */
95123 export function installationSummary(item: { accountType: string; repositorySelection: string }): string {
96124 const kind = item.accountType === "Organization" ? "Organization" : "Personal";
+38−12
11 import { redirect } from "react-router";
22
33 import type { Route } from "./+types/github-install";
4−import { INSTALL_COOKIE, cookie, installCookieValue, newState } from "../lib/github";
4+import { INSTALL_COOKIE, connectStep, cookie, installCookieValue, newState, notYetAdded } from "../lib/github";
55 import { githubApp } from "../lib/github.server";
66 import { requireUser, roleIn } from "../lib/session.server";
77
88 /**
9− * Sends a workspace owner to GitHub to install g1t's GitHub App on an
10− * account or organization. GitHub returns to /integrations/github/setup
11− * with the state, which must match the cookie set here.
9+ * "Connect GitHub" for a workspace owner, in one click: links their GitHub
10+ * account on the way if it is not linked yet, adds an installation they
11+ * already have, and otherwise sends them to GitHub to install g1t's GitHub
12+ * App. GitHub returns to /integrations/github/setup with the state, which
13+ * must match the cookie set here. `?another=1` always installs, for adding
14+ * one more account.
1215 */
1316 export async function loader({ request, context }: Route.LoaderArgs) {
1417 const user = requireUser(context, request);
15− const workspace = (new URL(request.url).searchParams.get("workspace") ?? "").toLowerCase();
16− if (roleIn(user, workspace) !== "owner") throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}`);
18+ const url = new URL(request.url);
19+ const workspace = (url.searchParams.get("workspace") ?? "").toLowerCase();
20+ const back = `/new/github?workspace=${encodeURIComponent(workspace)}`;
21+ if (roleIn(user, workspace) !== "owner") throw redirect(back);
1722 const status = await githubApp.status(user, workspace);
1823 if (!status.ok || !status.value.installUrl) throw redirect("/new");
19− const state = newState();
20− const target = new URL(status.value.installUrl);
21− target.searchParams.set("state", state);
22− throw redirect(target.toString(), {
23− headers: { "set-cookie": cookie(INSTALL_COOKIE, installCookieValue(state, workspace), 1800) },
24− });
24+ const another = url.searchParams.get("another") === "1";
25+ const linked = status.value.linked;
26+ const visible = linked ? await githubApp.visibleInstallations(user).catch(() => null) : null;
27+ const recorded = new Set(status.value.installations.map((item) => item.id));
28+ const unadded = visible?.ok ? notYetAdded(visible.value, workspace).filter((item) => !recorded.has(item.id)) : [];
29+ const step = connectStep({ linked, justLinked: url.searchParams.get("linked") === "1", another, unadded });
30+ switch (step.kind) {
31+ case "link": {
32+ // Back here once GitHub says who they are, to carry on installing.
33+ const here = `${url.pathname}?workspace=${encodeURIComponent(workspace)}${another ? "&another=1" : ""}&linked=1`;
34+ throw redirect(`/auth/github?link=1&next=${encodeURIComponent(here)}`);
35+ }
36+ case "add": {
37+ const added = await githubApp.addInstallation(user, workspace, step.installation);
38+ throw redirect(added.ok ? `${back}&installation=${step.installation}` : back);
39+ }
40+ case "choose":
41+ throw redirect(back);
42+ case "install": {
43+ const state = newState();
44+ const target = new URL(status.value.installUrl);
45+ target.searchParams.set("state", state);
46+ throw redirect(target.toString(), {
47+ headers: { "set-cookie": cookie(INSTALL_COOKIE, installCookieValue(state, workspace), 1800) },
48+ });
49+ }
50+ }
2551 }
+24−9
2727 * checks with the person's own GitHub token that they can see it.
2828 *
2929 * Without g1t's state (the app was installed on GitHub directly, from
30− * another browser, or the cookie was lost), the person chooses which of
31− * the workspaces they own to add it to, checked the same way.
30+ * another browser, or the cookie was lost), it is added to the workspace
31+ * the person owns, or with several they choose which, checked the same
32+ * way; a GitHub account not linked yet is linked on the way.
3233 */
3334 export async function loader({ request, context }: Route.LoaderArgs) {
3435 const user = requireUser(context, request);
3940 const installation = Number(url.searchParams.get("installation_id"));
4041 const problem = (error: string, at: string | null = workspace, link: string | null = null) =>
4142 data<Problem | Choice>({ kind: "error", error, workspace: at, link }, { headers: clear });
43+ // GitHub not linked, or its access ended: link it and come back here,
44+ // once; a link that did not take shows the problem instead.
45+ const relink = (code: string) => {
46+ if (code !== "not_found" && code !== "unauthenticated") return null;
47+ const back = new URL(url);
48+ back.searchParams.set("linked", "1");
49+ const link = `/auth/github?link=1&next=${encodeURIComponent(`${back.pathname}${back.search}`)}`;
50+ if (url.searchParams.get("linked") !== "1") throw redirect(link);
51+ return link;
52+ };
4253 // Repositories chosen again on GitHub: nothing to record, the picker
4354 // lists what the installation can see now.
4455 if (!workspace && action === "update") throw redirect("/new/github", { headers: clear });
5263 }
5364 if (workspace) {
5465 const added = await githubApp.addInstallation(user, workspace, installation);
55− if (!added.ok) return problem(added.error.message);
66+ if (!added.ok) return problem(added.error.message, workspace, relink(added.error.code));
5667 throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}&installation=${installation}`, { headers: clear });
5768 }
5869 const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner");
6071 return problem("Only a workspace owner can add a GitHub account. Ask an owner of your workspace to add it from Import from GitHub.", null);
6172 }
6273 const visible = await githubApp.visibleInstallations(user);
63− if (!visible.ok) {
64− // GitHub not linked, or its access ended: link it and come back here.
65− const unlinked = visible.error.code === "not_found" || visible.error.code === "unauthenticated";
66− const here = `${url.pathname}${url.search}`;
67− return problem(visible.error.message, null, unlinked ? `/auth/github?link=1&next=${encodeURIComponent(here)}` : null);
68− }
74+ if (!visible.ok) return problem(visible.error.message, null, relink(visible.error.code));
6975 const found = visible.value.find((item) => item.id === installation);
7076 if (!found) {
7177 return problem(
7379 null,
7480 );
7581 }
82+ // One workspace of theirs: there is nothing to choose.
83+ if (owned.length === 1) {
84+ const only = owned[0].slug;
85+ if (!found.recordedIn.includes(only)) {
86+ const added = await githubApp.addInstallation(user, only, installation);
87+ if (!added.ok) return problem(added.error.message, only);
88+ }
89+ throw redirect(`/new/github?workspace=${encodeURIComponent(only)}&installation=${installation}`, { headers: clear });
90+ }
7691 return data<Problem | Choice>(
7792 {
7893 kind: "choose",
+8−7
121121 {!linked ? (
122122 <Card asChild tone="plain" radius="lg" className="mt-8 p-6">
123123 <section>
124− <h2 className="font-medium">Connect your GitHub account</h2>
124+ <h2 className="font-medium">Connect GitHub</h2>
125125 <p className="mt-1.5 text-sm text-muted">
126− g1t lists the repositories you can reach on GitHub as you, so it needs your GitHub account linked first. If
127− the app is installed on GitHub already, it is listed here to add once your account is linked.
126+ {owner
127+ ? `GitHub asks once who you are and which repositories g1t may see, then brings you back here to choose what to import into ${workspaceName}.`
128+ : "g1t lists the repositories you can reach on GitHub as you, so it needs your GitHub account linked first."}
128129 </p>
129130 <div className="mt-4">
130131 <a
131− href={`/auth/github?link=1&next=${encodeURIComponent(here)}`}
132+ href={owner ? `/integrations/github/install?workspace=${workspace}` : `/auth/github?link=1&next=${encodeURIComponent(here)}`}
132133 className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover"
133134 >
134135 <GithubMark /> Connect GitHub
143144 <h2 className="font-medium">GitHub accounts</h2>
144145 {owner && (
145146 <a
146− href={`/integrations/github/install?workspace=${workspace}`}
147+ href={`/integrations/github/install?workspace=${workspace}&another=1`}
147148 className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"
148149 >
149150 <Plus size={14} /> Add a GitHub account
200201 </p>
201202 <div className="mt-4">
202203 <a
203− href={`/integrations/github/install?workspace=${workspace}`}
204+ href={`/integrations/github/install?workspace=${workspace}${claimable.length > 0 ? "&another=1" : ""}`}
204205 className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover"
205206 >
206207 <GithubMark /> Install on GitHub
227228 </span>
228229 {item.suspended && <Badge size="md">Suspended</Badge>}
229230 <span className="ml-auto flex items-center gap-3">
230− <a href={item.settingsUrl} className="whitespace-nowrap text-xs text-muted hover:text-fg" target="_blank" rel="noreferrer">
231+ <a href={item.settingsUrl} className="whitespace-nowrap text-xs text-muted hover:text-fg">
231232 Choose repositories on GitHub
232233 </a>
233234 {owner && (
+10−1
1−import { Form } from "react-router";
1+import { Form, Link } from "react-router";
22
33 import type { Route } from "./+types/github";
44 import { page } from "../../lib/meta";
7171 <GithubMark /> Link GitHub
7272 </a>
7373 )}
74+ {github.account?.authorized && (
75+ <p className="mt-3 text-sm text-muted">
76+ To bring repositories into a workspace,{" "}
77+ <Link to="/new/github" className="text-fg underline underline-offset-4">
78+ import from GitHub
79+ </Link>
80+ .
81+ </p>
82+ )}
7483 {github.account && !github.hasPassword && (
7584 <p className="mt-2 text-xs text-faint">
7685 GitHub is the only way you sign in, so it cannot be unlinked yet. Sign out and use Forgot your password to set one.

Comments

Sign in to comment.