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.
7 files+146−420/7 viewed
| 72 | 72 | ||
| 73 | 73 | 1. Choose **New project**, then **Import from GitHub**. You can also go to | |
| 74 | 74 | `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. | |
| 82 | 84 | 4. Tick the repositories to bring across. You see only repositories that | |
| 83 | 85 | both you and the app can reach. | |
| 84 | 86 | 5. Choose whether to copy their issues. | |
| ⋯ | |||
| 95 | 97 | after you install the app. If you installed it on GitHub directly, in | |
| 96 | 98 | another browser, or the return to g1t was lost, add it from g1t instead: | |
| 97 | 99 | ||
| 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**. | |
| 103 | 104 | ||
| 104 | 105 | Only a workspace owner can add one, and only an installation your own | |
| 105 | 106 | GitHub account can see: on your account, or on an organization you belong | |
| 106 | 107 | to. | |
| 107 | 108 | ||
| 108 | 109 | 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 | |
| 110 | 112 | check: the installation must be one your GitHub account can see. | |
| 111 | 113 | ||
| 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 | + | ||
| 112 | 118 | ### What comes across | |
| 113 | 119 | ||
| 114 | 120 | | | | | |
| 5 | 5 | import { | |
| 6 | 6 | INSTALL_COOKIE, | |
| 7 | 7 | STATE_COOKIE, | |
| 8 | + | connectStep, | |
| 8 | 9 | cookie, | |
| 9 | 10 | githubConnected, | |
| 10 | 11 | installCookieValue, | |
| ⋯ | |||
| 106 | 107 | "The installation on b is suspended on GitHub.", | |
| 107 | 108 | ); | |
| 108 | 109 | }); | |
| 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 | + | }); | |
| 91 | 91 | return seen.filter((item) => !item.recordedIn.includes(workspace)); | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 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 | + | ||
| 94 | 122 | /** How an installation is described in a list: `Organization · all repositories`. */ | |
| 95 | 123 | export function installationSummary(item: { accountType: string; repositorySelection: string }): string { | |
| 96 | 124 | const kind = item.accountType === "Organization" ? "Organization" : "Personal"; |
| 1 | 1 | import { redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | 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"; | |
| 5 | 5 | import { githubApp } from "../lib/github.server"; | |
| 6 | 6 | import { requireUser, roleIn } from "../lib/session.server"; | |
| 7 | 7 | ||
| 8 | 8 | /** | |
| 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. | |
| 12 | 15 | */ | |
| 13 | 16 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 14 | 17 | 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); | |
| 17 | 22 | const status = await githubApp.status(user, workspace); | |
| 18 | 23 | 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 | + | } | |
| 25 | 51 | } |
| 27 | 27 | * checks with the person's own GitHub token that they can see it. | |
| 28 | 28 | * | |
| 29 | 29 | * 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. | |
| 32 | 33 | */ | |
| 33 | 34 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 34 | 35 | const user = requireUser(context, request); | |
| ⋯ | |||
| 39 | 40 | const installation = Number(url.searchParams.get("installation_id")); | |
| 40 | 41 | const problem = (error: string, at: string | null = workspace, link: string | null = null) => | |
| 41 | 42 | 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 | + | }; | |
| 42 | 53 | // Repositories chosen again on GitHub: nothing to record, the picker | |
| 43 | 54 | // lists what the installation can see now. | |
| 44 | 55 | if (!workspace && action === "update") throw redirect("/new/github", { headers: clear }); | |
| ⋯ | |||
| 52 | 63 | } | |
| 53 | 64 | if (workspace) { | |
| 54 | 65 | 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)); | |
| 56 | 67 | throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}&installation=${installation}`, { headers: clear }); | |
| 57 | 68 | } | |
| 58 | 69 | const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner"); | |
| ⋯ | |||
| 60 | 71 | 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); | |
| 61 | 72 | } | |
| 62 | 73 | 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)); | |
| 69 | 75 | const found = visible.value.find((item) => item.id === installation); | |
| 70 | 76 | if (!found) { | |
| 71 | 77 | return problem( | |
| ⋯ | |||
| 73 | 79 | null, | |
| 74 | 80 | ); | |
| 75 | 81 | } | |
| 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 | + | } | |
| 76 | 91 | return data<Problem | Choice>( | |
| 77 | 92 | { | |
| 78 | 93 | kind: "choose", | |
| 121 | 121 | {!linked ? ( | |
| 122 | 122 | <Card asChild tone="plain" radius="lg" className="mt-8 p-6"> | |
| 123 | 123 | <section> | |
| 124 | − | <h2 className="font-medium">Connect your GitHub account</h2> | |
| 124 | + | <h2 className="font-medium">Connect GitHub</h2> | |
| 125 | 125 | <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."} | |
| 128 | 129 | </p> | |
| 129 | 130 | <div className="mt-4"> | |
| 130 | 131 | <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)}`} | |
| 132 | 133 | 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" | |
| 133 | 134 | > | |
| 134 | 135 | <GithubMark /> Connect GitHub | |
| ⋯ | |||
| 143 | 144 | <h2 className="font-medium">GitHub accounts</h2> | |
| 144 | 145 | {owner && ( | |
| 145 | 146 | <a | |
| 146 | − | href={`/integrations/github/install?workspace=${workspace}`} | |
| 147 | + | href={`/integrations/github/install?workspace=${workspace}&another=1`} | |
| 147 | 148 | className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg" | |
| 148 | 149 | > | |
| 149 | 150 | <Plus size={14} /> Add a GitHub account | |
| ⋯ | |||
| 200 | 201 | </p> | |
| 201 | 202 | <div className="mt-4"> | |
| 202 | 203 | <a | |
| 203 | − | href={`/integrations/github/install?workspace=${workspace}`} | |
| 204 | + | href={`/integrations/github/install?workspace=${workspace}${claimable.length > 0 ? "&another=1" : ""}`} | |
| 204 | 205 | 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" | |
| 205 | 206 | > | |
| 206 | 207 | <GithubMark /> Install on GitHub | |
| ⋯ | |||
| 227 | 228 | </span> | |
| 228 | 229 | {item.suspended && <Badge size="md">Suspended</Badge>} | |
| 229 | 230 | <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"> | |
| 231 | 232 | Choose repositories on GitHub | |
| 232 | 233 | </a> | |
| 233 | 234 | {owner && ( | |
| 1 | − | import { Form } from "react-router"; | |
| 1 | + | import { Form, Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/github"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 71 | 71 | <GithubMark /> Link GitHub | |
| 72 | 72 | </a> | |
| 73 | 73 | )} | |
| 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 | + | )} | |
| 74 | 83 | {github.account && !github.hasPassword && ( | |
| 75 | 84 | <p className="mt-2 text-xs text-faint"> | |
| 76 | 85 | 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.