A GitHub App installed straight on GitHub can be added to a workspace: Import from GitHub lists the installations your GitHub account can see that this workspace hasn't added, each with Add for owners, and coming back from GitHub without g1t's own start asks which of your workspaces to add it to instead of stopping; the GitHub guide says how.
10 files+591−510/10 viewed
| 75 | 75 | 3. If no GitHub account is connected to the workspace, a workspace owner | |
| 76 | 76 | chooses **Install on GitHub**, picks the GitHub account or organization, | |
| 77 | 77 | and chooses which repositories the app may see. GitHub returns you to | |
| 78 | − | g1t, which records the installation for the workspace. | |
| 78 | + | g1t, which records the installation for the workspace. If the app is | |
| 79 | + | installed on GitHub already, add it instead: see | |
| 80 | + | [already installed the app on GitHub](#already-installed-the-app-on-github). | |
| 79 | 81 | 4. Tick the repositories to bring across. You see only repositories that | |
| 80 | 82 | both you and the app can reach. | |
| 81 | 83 | 5. Choose how they come across, and whether to copy their issues. | |
| ⋯ | |||
| 96 | 98 | run its GitHub workflows on g1t while GitHub's don't run, or move it to g1t | |
| 97 | 99 | for good. See [mirroring](/guides/mirroring/). | |
| 98 | 100 | ||
| 101 | + | ### Already installed the app on GitHub? | |
| 102 | + | ||
| 103 | + | g1t adds an installation to a workspace when GitHub sends you back to g1t | |
| 104 | + | after you install the app. If you installed it on GitHub directly, in | |
| 105 | + | another browser, or the return to g1t was lost, add it from g1t instead: | |
| 106 | + | ||
| 107 | + | 1. Make sure your GitHub account is linked: **Connect GitHub** on the import | |
| 108 | + | page, or **Link GitHub** in [Settings → GitHub](https://g1t.sh/settings/github). | |
| 109 | + | 2. Open `g1t.sh/new/github?workspace=<workspace>`. You can also get there | |
| 110 | + | from **Connect** beside GitHub in the workspace's Integrations or | |
| 111 | + | Marketplace. | |
| 112 | + | 3. Under **Already installed on GitHub**, choose **Add to** the workspace | |
| 113 | + | beside the GitHub account or organization. | |
| 114 | + | ||
| 115 | + | Only a workspace owner can add one, and only an installation your own | |
| 116 | + | GitHub account can see: on your account, or on an organization you belong | |
| 117 | + | to. Once added, GitHub shows as connected in the workspace's Integrations | |
| 118 | + | and Marketplace. | |
| 119 | + | ||
| 120 | + | If GitHub sends you to g1t after an installation that did not start from | |
| 121 | + | g1t, g1t asks which of the workspaces you own to add it to. It is the same | |
| 122 | + | check: the installation must be one your GitHub account can see. | |
| 123 | + | ||
| 99 | 124 | ### What comes across | |
| 100 | 125 | ||
| 101 | 126 | | | | | |
| 9 | 9 | import { CONNECTORS } from "@g1t/contracts/connectors"; | |
| 10 | 10 | ||
| 11 | 11 | import type { ConnectedState } from "./connectors"; | |
| 12 | + | import { githubConnected } from "./github"; | |
| 12 | 13 | import { githubApp } from "./github.server"; | |
| 13 | 14 | import { integrationsSection } from "./integration-sections"; | |
| 14 | 15 | import { integrations, webhooks } from "./services.server"; | |
| ⋯ | |||
| 35 | 36 | }; | |
| 36 | 37 | } | |
| 37 | 38 | } | |
| 38 | − | if (github?.ok && github.value.installations.length > 0) { | |
| 39 | − | const suspended = github.value.installations.find((installation) => installation.suspended); | |
| 40 | − | connected.github = { | |
| 41 | − | detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`, | |
| 42 | − | problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null, | |
| 43 | − | manage: null, | |
| 44 | − | }; | |
| 45 | − | } | |
| 39 | + | const fromGithub = github?.ok ? githubConnected(github.value.installations) : null; | |
| 40 | + | if (fromGithub) connected.github = fromGithub; | |
| 46 | 41 | const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : []; | |
| 47 | 42 | if (ours.length > 0) { | |
| 48 | 43 | const failing = ours.filter((hook) => hook.lastStatus === "failed"); | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { INSTALL_COOKIE, STATE_COOKIE, cookie, installCookieValue, installWorkspace, newState, readCookie, sameString, stateMatches } from "./github.ts"; | |
| 4 | + | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | INSTALL_COOKIE, | |
| 8 | + | STATE_COOKIE, | |
| 9 | + | cookie, | |
| 10 | + | githubConnected, | |
| 11 | + | installCookieValue, | |
| 12 | + | installWorkspace, | |
| 13 | + | installationSummary, | |
| 14 | + | newState, | |
| 15 | + | notYetAdded, | |
| 16 | + | readCookie, | |
| 17 | + | sameString, | |
| 18 | + | setupChoices, | |
| 19 | + | stateMatches, | |
| 20 | + | } from "./github.ts"; | |
| 21 | + | import { integrationListings } from "./marketplace.ts"; | |
| 5 | 22 | ||
| 6 | 23 | test("the state must come back exactly as it was given", () => { | |
| 7 | 24 | const state = newState(); | |
| ⋯ | |||
| 38 | 55 | assert.equal(installWorkspace(null, state), null); | |
| 39 | 56 | assert.equal(installWorkspace(`${state}.`, state), null); | |
| 40 | 57 | }); | |
| 58 | + | ||
| 59 | + | const seen = (id: number, account: string, recordedIn: string[], accountType = "Organization") => ({ | |
| 60 | + | id, | |
| 61 | + | account, | |
| 62 | + | accountType, | |
| 63 | + | repositorySelection: "all", | |
| 64 | + | suspended: false, | |
| 65 | + | settingsUrl: `https://github.com/organizations/${account}/settings/installations/${id}`, | |
| 66 | + | recordedIn, | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test("an installation made on GitHub directly is offered to the workspaces that lack it", () => { | |
| 70 | + | const listed = [seen(1, "flagon-io", []), seen(2, "syntaqx", ["syntaqx"], "User"), seen(3, "acme", ["flagon-io"])]; | |
| 71 | + | assert.deepEqual( | |
| 72 | + | notYetAdded(listed, "flagon-io").map((item) => item.account), | |
| 73 | + | ["flagon-io", "syntaqx"], | |
| 74 | + | ); | |
| 75 | + | assert.deepEqual( | |
| 76 | + | notYetAdded(listed, "syntaqx").map((item) => item.id), | |
| 77 | + | [1, 3], | |
| 78 | + | ); | |
| 79 | + | assert.equal(installationSummary(listed[0]!), "Organization · all repositories"); | |
| 80 | + | assert.equal(installationSummary({ accountType: "User", repositorySelection: "selected" }), "Personal · selected repositories"); | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | test("the setup page offers only workspaces the person owns, saying which have it", () => { | |
| 84 | + | const workspaces = [ | |
| 85 | + | { slug: "flagon-io", name: "Flagon", role: "owner" }, | |
| 86 | + | { slug: "acme", role: "member" }, | |
| 87 | + | { slug: "syntaqx", name: null, role: "owner" }, | |
| 88 | + | ]; | |
| 89 | + | assert.deepEqual(setupChoices(workspaces, seen(1, "flagon-io", ["syntaqx"])), [ | |
| 90 | + | { slug: "flagon-io", name: "Flagon", added: false }, | |
| 91 | + | { slug: "syntaqx", name: "syntaqx", added: true }, | |
| 92 | + | ]); | |
| 93 | + | assert.deepEqual( | |
| 94 | + | setupChoices(workspaces, null).map((choice) => choice.added), | |
| 95 | + | [false, false], | |
| 96 | + | ); | |
| 97 | + | }); | |
| 98 | + | ||
| 99 | + | test("once an installation is recorded, the Marketplace and Integrations say GitHub is connected", () => { | |
| 100 | + | assert.equal(githubConnected([]), null); | |
| 101 | + | const state = githubConnected([{ account: "flagon-io", suspended: false }]); | |
| 102 | + | assert.deepEqual(state, { detail: "On flagon-io", problem: null, manage: null }); | |
| 103 | + | const listings = integrationListings(connectorsFor("workspace"), { github: state! }, [], "ana", "flagon-io"); | |
| 104 | + | const github = listings.find((listing) => listing.view.id === "github"); | |
| 105 | + | assert.equal(github?.connected?.detail, "On flagon-io"); | |
| 106 | + | assert.equal(listings[0]!.view.id, "github"); | |
| 107 | + | assert.equal( | |
| 108 | + | githubConnected([ | |
| 109 | + | { account: "a", suspended: false }, | |
| 110 | + | { account: "b", suspended: true }, | |
| 111 | + | ])?.problem, | |
| 112 | + | "The installation on b is suspended on GitHub.", | |
| 113 | + | ); | |
| 114 | + | }); | |
| 72 | 72 | return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | + | /** An installation as GitHub lists it to the person, with the workspaces of theirs that have it. */ | |
| 76 | + | export type SeenInstallation = { | |
| 77 | + | id: number; | |
| 78 | + | account: string; | |
| 79 | + | accountType: string; | |
| 80 | + | repositorySelection: string; | |
| 81 | + | suspended: boolean; | |
| 82 | + | recordedIn: string[]; | |
| 83 | + | }; | |
| 84 | + | ||
| 85 | + | /** | |
| 86 | + | * The installations the person can see on GitHub that `workspace` has not | |
| 87 | + | * added yet: the app installed on GitHub directly, or from a return that | |
| 88 | + | * lost g1t's state. | |
| 89 | + | */ | |
| 90 | + | export function notYetAdded<T extends SeenInstallation>(seen: T[], workspace: string): T[] { | |
| 91 | + | return seen.filter((item) => !item.recordedIn.includes(workspace)); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** How an installation is described in a list: `Organization · all repositories`. */ | |
| 95 | + | export function installationSummary(item: { accountType: string; repositorySelection: string }): string { | |
| 96 | + | const kind = item.accountType === "Organization" ? "Organization" : "Personal"; | |
| 97 | + | return `${kind} · ${item.repositorySelection === "all" ? "all repositories" : "selected repositories"}`; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /** | |
| 101 | + | * The workspaces an installation can be added to from the setup page: the | |
| 102 | + | * ones the person owns, each saying whether it has the installation already. | |
| 103 | + | */ | |
| 104 | + | export function setupChoices( | |
| 105 | + | workspaces: { slug: string; name?: string | null; role: string }[], | |
| 106 | + | installation: SeenInstallation | null, | |
| 107 | + | ): { slug: string; name: string; added: boolean }[] { | |
| 108 | + | return workspaces | |
| 109 | + | .filter((membership) => membership.role === "owner") | |
| 110 | + | .map((membership) => ({ | |
| 111 | + | slug: membership.slug, | |
| 112 | + | name: membership.name ?? membership.slug, | |
| 113 | + | added: installation?.recordedIn.includes(membership.slug) ?? false, | |
| 114 | + | })); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /** | |
| 118 | + | * What the Integrations directory and the Marketplace say about GitHub | |
| 119 | + | * from the installations a workspace has recorded: nothing until there is | |
| 120 | + | * one, then the accounts it is on and any that GitHub has suspended. | |
| 121 | + | */ | |
| 122 | + | export function githubConnected( | |
| 123 | + | installations: { account: string; suspended: boolean }[], | |
| 124 | + | ): { detail: string; problem: string | null; manage: null } | null { | |
| 125 | + | if (installations.length === 0) return null; | |
| 126 | + | const suspended = installations.find((installation) => installation.suspended); | |
| 127 | + | return { | |
| 128 | + | detail: `On ${installations.map((installation) => installation.account).join(", ")}`, | |
| 129 | + | problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null, | |
| 130 | + | manage: null, | |
| 131 | + | }; | |
| 132 | + | } | |
| 133 | + | ||
| 75 | 134 | /** What each way of bringing a repository across does, for the picker. */ | |
| 76 | 135 | export const MODES = [ | |
| 77 | 136 | { |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | 121 | "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs " + | |
| 122 | − | "people_directory team_agents team_context team_members" | |
| 122 | + | "people_directory team_agents team_context team_members github_visible_installations" | |
| 123 | 123 | ).split(" "), | |
| 124 | 124 | ); | |
| 125 | 125 |
| 1 | − | import { Link, data, redirect } from "react-router"; | |
| 1 | + | import { Form, Link, data, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/github-setup"; | |
| 4 | 4 | import { AuthCard } from "../components/auth-card"; | |
| 5 | − | import { INSTALL_COOKIE, cookie, installWorkspace, readCookie } from "../lib/github"; | |
| 5 | + | import { GithubMark } from "../components/github"; | |
| 6 | + | import { ErrorText, SubmitButton } from "../components/ui"; | |
| 7 | + | import { INSTALL_COOKIE, cookie, installWorkspace, installationSummary, readCookie, setupChoices } from "../lib/github"; | |
| 6 | 8 | import { githubApp } from "../lib/github.server"; | |
| 7 | 9 | import { page } from "../lib/meta"; | |
| 8 | − | import { requireUser } from "../lib/session.server"; | |
| 10 | + | import { assertSameOrigin, requireUser, roleIn } from "../lib/session.server"; | |
| 9 | 11 | ||
| 10 | 12 | export function meta(args: Route.MetaArgs) { | |
| 11 | 13 | return page(args, { title: "Connecting GitHub · g1t" }); | |
| 12 | 14 | } | |
| 13 | 15 | ||
| 16 | + | type Problem = { kind: "error"; error: string; workspace: string | null; link: string | null }; | |
| 17 | + | type Choice = { | |
| 18 | + | kind: "choose"; | |
| 19 | + | installation: { id: number; account: string; summary: string }; | |
| 20 | + | workspaces: { slug: string; name: string; added: boolean }[]; | |
| 21 | + | }; | |
| 22 | + | ||
| 14 | 23 | /** | |
| 15 | 24 | * The app's Setup URL: GitHub returns here after an installation with | |
| 16 | 25 | * `installation_id`, `setup_action` and the state. The installation is | |
| 17 | 26 | * recorded against the workspace it was started for, after integrations | |
| 18 | 27 | * checks with the person's own GitHub token that they can see it. | |
| 28 | + | * | |
| 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. | |
| 19 | 32 | */ | |
| 20 | 33 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 21 | 34 | const user = requireUser(context, request); | |
| ⋯ | |||
| 24 | 37 | const workspace = installWorkspace(readCookie(request.headers.get("cookie"), INSTALL_COOKIE), url.searchParams.get("state")); | |
| 25 | 38 | const action = url.searchParams.get("setup_action"); | |
| 26 | 39 | const installation = Number(url.searchParams.get("installation_id")); | |
| 40 | + | const problem = (error: string, at: string | null = workspace, link: string | null = null) => | |
| 41 | + | data<Problem | Choice>({ kind: "error", error, workspace: at, link }, { headers: clear }); | |
| 27 | 42 | // Repositories chosen again on GitHub: nothing to record, the picker | |
| 28 | 43 | // lists what the installation can see now. | |
| 29 | 44 | if (!workspace && action === "update") throw redirect("/new/github", { headers: clear }); | |
| 30 | − | if (!workspace) { | |
| 31 | − | return data( | |
| 32 | − | { error: "This installation did not start from g1t in this browser. Start again from New project.", workspace: null }, | |
| 33 | − | { headers: clear }, | |
| 34 | − | ); | |
| 45 | + | // An organization member asked an owner to approve: nothing to record yet. | |
| 46 | + | if (action === "request") { | |
| 47 | + | const back = workspace ? `/new/github?workspace=${encodeURIComponent(workspace)}&` : "/new/github?"; | |
| 48 | + | throw redirect(`${back}requested=1`, { headers: clear }); | |
| 35 | 49 | } | |
| 36 | − | const back = `/new/github?workspace=${encodeURIComponent(workspace)}`; | |
| 37 | − | // An organization member asked an owner to approve: nothing to record yet. | |
| 38 | − | if (action === "request") throw redirect(`${back}&requested=1`, { headers: clear }); | |
| 39 | 50 | if (!Number.isSafeInteger(installation) || installation <= 0) { | |
| 40 | − | return data({ error: "GitHub did not say which installation was made.", workspace }, { headers: clear }); | |
| 51 | + | return problem("GitHub did not say which installation was made."); | |
| 41 | 52 | } | |
| 53 | + | if (workspace) { | |
| 54 | + | const added = await githubApp.addInstallation(user, workspace, installation); | |
| 55 | + | if (!added.ok) return problem(added.error.message); | |
| 56 | + | throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}&installation=${installation}`, { headers: clear }); | |
| 57 | + | } | |
| 58 | + | const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner"); | |
| 59 | + | if (owned.length === 0) { | |
| 60 | + | 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 | + | } | |
| 62 | + | 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 | + | } | |
| 69 | + | const found = visible.value.find((item) => item.id === installation); | |
| 70 | + | if (!found) { | |
| 71 | + | return problem( | |
| 72 | + | "Your GitHub account cannot see that installation. Install the app from your own GitHub account or an organization you manage.", | |
| 73 | + | null, | |
| 74 | + | ); | |
| 75 | + | } | |
| 76 | + | return data<Problem | Choice>( | |
| 77 | + | { | |
| 78 | + | kind: "choose", | |
| 79 | + | installation: { id: found.id, account: found.account, summary: installationSummary(found) }, | |
| 80 | + | workspaces: setupChoices(owned, found), | |
| 81 | + | }, | |
| 82 | + | { headers: clear }, | |
| 83 | + | ); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 87 | + | assertSameOrigin(request); | |
| 88 | + | const user = requireUser(context, request); | |
| 89 | + | const form = await request.formData(); | |
| 90 | + | const workspace = String(form.get("workspace") ?? "").toLowerCase(); | |
| 91 | + | const installation = Number(form.get("installation")); | |
| 92 | + | if (roleIn(user, workspace) !== "owner") return { error: "Only an owner of the workspace can add GitHub accounts to it." }; | |
| 93 | + | if (!Number.isSafeInteger(installation) || installation <= 0) return { error: "Choose an installation to add." }; | |
| 42 | 94 | const added = await githubApp.addInstallation(user, workspace, installation); | |
| 43 | − | if (!added.ok) return data({ error: added.error.message, workspace }, { headers: clear }); | |
| 44 | − | throw redirect(`${back}&installation=${installation}`, { headers: clear }); | |
| 95 | + | if (!added.ok) return { error: added.error.message }; | |
| 96 | + | throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}&installation=${installation}`); | |
| 45 | 97 | } | |
| 46 | 98 | ||
| 47 | − | export default function GithubSetup({ loaderData }: Route.ComponentProps) { | |
| 99 | + | export default function GithubSetup({ loaderData, actionData }: Route.ComponentProps) { | |
| 100 | + | if (loaderData.kind === "choose") { | |
| 101 | + | const { installation, workspaces } = loaderData; | |
| 102 | + | return ( | |
| 103 | + | <AuthCard | |
| 104 | + | title="Add a GitHub account" | |
| 105 | + | subtitle={`Add the installation on ${installation.account} to which workspace?`} | |
| 106 | + | footer={ | |
| 107 | + | <Link to="/new/github" className="text-fg underline underline-offset-4"> | |
| 108 | + | Not now | |
| 109 | + | </Link> | |
| 110 | + | } | |
| 111 | + | > | |
| 112 | + | <div className="flex items-center gap-3 rounded-md border border-line px-4 py-3"> | |
| 113 | + | <GithubMark className="size-5 shrink-0" /> | |
| 114 | + | <div className="min-w-0"> | |
| 115 | + | <p className="truncate font-mono text-sm">{installation.account}</p> | |
| 116 | + | <p className="text-xs text-faint">{installation.summary}</p> | |
| 117 | + | </div> | |
| 118 | + | </div> | |
| 119 | + | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 120 | + | {workspaces.map((option) => ( | |
| 121 | + | <li key={option.slug} className="flex items-center gap-3 px-4 py-2.5"> | |
| 122 | + | <div className="min-w-0"> | |
| 123 | + | <p className="truncate text-sm">{option.name}</p> | |
| 124 | + | {option.name !== option.slug && <p className="truncate font-mono text-xs text-faint">{option.slug}</p>} | |
| 125 | + | </div> | |
| 126 | + | <span className="ml-auto shrink-0"> | |
| 127 | + | {option.added ? ( | |
| 128 | + | <Link to={`/new/github?workspace=${option.slug}&installation=${installation.id}`} className="text-sm text-muted hover:text-fg"> | |
| 129 | + | Added · Open | |
| 130 | + | </Link> | |
| 131 | + | ) : ( | |
| 132 | + | <Form method="post"> | |
| 133 | + | <input type="hidden" name="installation" value={installation.id} /> | |
| 134 | + | <SubmitButton name="workspace" value={option.slug} pending="Adding…"> | |
| 135 | + | Add | |
| 136 | + | </SubmitButton> | |
| 137 | + | </Form> | |
| 138 | + | )} | |
| 139 | + | </span> | |
| 140 | + | </li> | |
| 141 | + | ))} | |
| 142 | + | </ul> | |
| 143 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 144 | + | </AuthCard> | |
| 145 | + | ); | |
| 146 | + | } | |
| 48 | 147 | return ( | |
| 49 | 148 | <AuthCard | |
| 50 | 149 | title="Connecting GitHub" | |
| 51 | 150 | subtitle="That did not work" | |
| 52 | 151 | footer={ | |
| 53 | 152 | <Link | |
| 54 | − | to={loaderData.workspace ? `/new/github?workspace=${loaderData.workspace}` : "/new"} | |
| 153 | + | to={loaderData.workspace ? `/new/github?workspace=${loaderData.workspace}` : "/new/github"} | |
| 55 | 154 | className="text-fg underline underline-offset-4" | |
| 56 | 155 | > | |
| 57 | − | Back to New project | |
| 156 | + | Back to Import from GitHub | |
| 58 | 157 | </Link> | |
| 59 | 158 | } | |
| 60 | 159 | > | |
| 61 | 160 | <p className="text-sm text-danger" role="alert"> | |
| 62 | 161 | {loaderData.error} | |
| 63 | 162 | </p> | |
| 163 | + | {loaderData.link && ( | |
| 164 | + | <a | |
| 165 | + | href={loaderData.link} | |
| 166 | + | className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-fg-hover" | |
| 167 | + | > | |
| 168 | + | <GithubMark /> Link your GitHub account | |
| 169 | + | </a> | |
| 170 | + | )} | |
| 64 | 171 | </AuthCard> | |
| 65 | 172 | ); | |
| 66 | 173 | } | |
| 7 | 7 | import { CheckboxOption } from "../components/ui/checkbox"; | |
| 8 | 8 | import { FieldLegend, FieldSet } from "../components/ui/field"; | |
| 9 | 9 | import { RadioCard, RadioGroup } from "../components/ui/radio-group"; | |
| 10 | − | import { MODES } from "../lib/github"; | |
| 10 | + | import { MODES, installationSummary, notYetAdded } from "../lib/github"; | |
| 11 | 11 | import { githubApp } from "../lib/github.server"; | |
| 12 | 12 | import { page } from "../lib/meta"; | |
| 13 | 13 | import { assertSameOrigin, requireUser, roleIn } from "../lib/session.server"; | |
| ⋯ | |||
| 27 | 27 | if (workspaces.length === 0) throw redirect("/workspaces/new"); | |
| 28 | 28 | const asked = url.searchParams.get("workspace")?.toLowerCase(); | |
| 29 | 29 | const workspace = workspaces.find((option) => option.slug === asked)?.slug ?? workspaces[0].slug; | |
| 30 | − | const status = await githubApp.status(user, workspace); | |
| 30 | + | // What the person can see on GitHub is asked for alongside the status: | |
| 31 | + | // an installation made there directly is offered here to be added. | |
| 32 | + | const [status, visible] = await Promise.all([ | |
| 33 | + | githubApp.status(user, workspace), | |
| 34 | + | githubApp.visibleInstallations(user).catch(() => null), | |
| 35 | + | ]); | |
| 31 | 36 | if (!status.ok || !status.value.configured) throw redirect(`/new?workspace=${workspace}`); | |
| 32 | 37 | const { installations, linked } = status.value; | |
| 38 | + | const recorded = new Set(installations.map((item) => item.id)); | |
| 39 | + | const claimable = | |
| 40 | + | linked && visible?.ok | |
| 41 | + | ? notYetAdded(visible.value, workspace) | |
| 42 | + | .filter((item) => !recorded.has(item.id)) | |
| 43 | + | .map((item) => ({ ...item, summary: installationSummary(item) })) | |
| 44 | + | : []; | |
| 33 | 45 | const wanted = Number(url.searchParams.get("installation")); | |
| 34 | 46 | const installation = installations.find((item) => item.id === wanted) ?? installations[0] ?? null; | |
| 35 | 47 | const pageNumber = Math.max(1, Number(url.searchParams.get("page")) || 1); | |
| ⋯ | |||
| 46 | 58 | owner: roleIn(user, workspace) === "owner", | |
| 47 | 59 | linked, | |
| 48 | 60 | installations, | |
| 61 | + | claimable, | |
| 49 | 62 | installation, | |
| 50 | 63 | repositories, | |
| 51 | 64 | error, | |
| ⋯ | |||
| 59 | 72 | const form = await request.formData(); | |
| 60 | 73 | const workspace = String(form.get("workspace") ?? ""); | |
| 61 | 74 | const installationId = Number(form.get("installation")); | |
| 75 | + | if (form.get("intent") === "add-installation") { | |
| 76 | + | const added = await githubApp.addInstallation(user, workspace, installationId); | |
| 77 | + | if (!added.ok) return { error: null, addError: added.error.message, results: [] }; | |
| 78 | + | throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}&installation=${installationId}`); | |
| 79 | + | } | |
| 62 | 80 | if (form.get("intent") === "remove-installation") { | |
| 63 | 81 | const removed = await githubApp.removeInstallation(user, workspace, installationId); | |
| 64 | 82 | // Shown by the list it was removed from, which is there with or without an account chosen. | |
| ⋯ | |||
| 84 | 102 | } | |
| 85 | 103 | ||
| 86 | 104 | export default function NewFromGithub({ loaderData, actionData }: Route.ComponentProps) { | |
| 87 | − | const { workspace, workspaces, installations, installation, repositories, linked, owner } = loaderData; | |
| 105 | + | const { workspace, workspaces, installations, claimable, installation, repositories, linked, owner } = loaderData; | |
| 106 | + | const workspaceName = workspaces.find((option) => option.slug === workspace)?.name ?? workspace; | |
| 107 | + | const these = claimable.length === 1 ? "this account" : "these accounts"; | |
| 88 | 108 | const here = `/new/github?workspace=${workspace}`; | |
| 89 | 109 | const names = new Map((repositories?.repositories ?? []).map((repo) => [repo.id, repo.fullName])); | |
| 90 | 110 | return ( | |
| ⋯ | |||
| 119 | 139 | <section className="mt-8 rounded-lg border border-line p-6"> | |
| 120 | 140 | <h2 className="font-medium">Connect your GitHub account</h2> | |
| 121 | 141 | <p className="mt-1.5 text-sm text-muted"> | |
| 122 | − | g1t lists the repositories you can reach on GitHub as you, so it needs your GitHub account linked first. | |
| 142 | + | g1t lists the repositories you can reach on GitHub as you, so it needs your GitHub account linked first. If | |
| 143 | + | the app is installed on GitHub already, it is listed here to add once your account is linked. | |
| 123 | 144 | </p> | |
| 124 | 145 | <div className="mt-4"> | |
| 125 | 146 | <a | |
| ⋯ | |||
| 149 | 170 | Your request went to the organization's owners on GitHub. Once one approves it, add the account here again. | |
| 150 | 171 | </p> | |
| 151 | 172 | )} | |
| 173 | + | {claimable.length > 0 && ( | |
| 174 | + | <div className="mt-3 rounded-lg border border-accent/30 bg-accent/5 p-4"> | |
| 175 | + | <h3 className="text-sm font-medium">Already installed on GitHub</h3> | |
| 176 | + | <p className="mt-1 text-sm text-muted"> | |
| 177 | + | {owner | |
| 178 | + | ? `The app is installed on ${these}, but not added to ${workspaceName} yet.` | |
| 179 | + | : `The app is installed on ${these}. An owner of ${workspaceName} can add ${claimable.length === 1 ? "it" : "them"} here.`} | |
| 180 | + | </p> | |
| 181 | + | <ul className="mt-3 divide-y divide-line rounded-md border border-line bg-bg"> | |
| 182 | + | {claimable.map((item) => ( | |
| 183 | + | <li key={item.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 text-sm"> | |
| 184 | + | <GithubMark className="size-4 shrink-0 text-muted" /> | |
| 185 | + | <span className="font-mono">{item.account}</span> | |
| 186 | + | <span className="whitespace-nowrap text-xs text-faint">{item.summary}</span> | |
| 187 | + | {item.suspended && <Pill>Suspended</Pill>} | |
| 188 | + | {owner && ( | |
| 189 | + | <Form method="post" className="ml-auto"> | |
| 190 | + | <input type="hidden" name="intent" value="add-installation" /> | |
| 191 | + | <input type="hidden" name="workspace" value={workspace} /> | |
| 192 | + | <input type="hidden" name="installation" value={item.id} /> | |
| 193 | + | <SubmitButton | |
| 194 | + | pending="Adding…" | |
| 195 | + | match={{ intent: "add-installation", installation: String(item.id) }} | |
| 196 | + | > | |
| 197 | + | Add to {workspaceName} | |
| 198 | + | </SubmitButton> | |
| 199 | + | </Form> | |
| 200 | + | )} | |
| 201 | + | </li> | |
| 202 | + | ))} | |
| 203 | + | </ul> | |
| 204 | + | <ErrorText>{actionData && "addError" in actionData ? actionData.addError : null}</ErrorText> | |
| 205 | + | </div> | |
| 206 | + | )} | |
| 152 | 207 | {installations.length === 0 ? ( | |
| 153 | 208 | <div className="mt-3 rounded-lg border border-dashed border-line p-6 text-sm text-muted"> | |
| 154 | 209 | {owner ? ( | |
| 155 | 210 | <> | |
| 156 | − | <p>Install g1t's GitHub App on your GitHub account or an organization, and choose the repositories it may see.</p> | |
| 211 | + | <p> | |
| 212 | + | {claimable.length > 0 | |
| 213 | + | ? "Or install g1t's GitHub App on another GitHub account or organization, and choose the repositories it may see." | |
| 214 | + | : "Install g1t's GitHub App on your GitHub account or an organization, and choose the repositories it may see."} | |
| 215 | + | </p> | |
| 157 | 216 | <div className="mt-4"> | |
| 158 | 217 | <a | |
| 159 | 218 | href={`/integrations/github/install?workspace=${workspace}`} | |
| 30 | 30 | //! `Outcome<GithubInstallation>`. | |
| 31 | 31 | //! - `github_remove_installation` takes `GithubInstallationArgs`, returns | |
| 32 | 32 | //! `Outcome<bool>`. | |
| 33 | + | //! - `github_visible_installations` takes `GithubVisibleArgs`, returns | |
| 34 | + | //! `Outcome<Vec<GithubVisibleInstallation>>`. | |
| 33 | 35 | //! - `github_repositories` takes `GithubRepositoriesArgs`, returns | |
| 34 | 36 | //! `Outcome<GithubRepositories>`. | |
| 35 | 37 | //! - `github_import` takes `GithubImportArgs`, returns `Outcome<GithubRepoLink>`. | |
| ⋯ | |||
| 279 | 281 | pub installation_id: u64, | |
| 280 | 282 | } | |
| 281 | 283 | ||
| 284 | + | /// `github_visible_installations`: the app's installations the person's | |
| 285 | + | /// own GitHub account can see, for claiming one that was installed on | |
| 286 | + | /// GitHub directly rather than from g1t. | |
| 282 | 287 | #[derive(Debug, Serialize, Deserialize)] | |
| 283 | 288 | #[serde(rename_all = "camelCase")] | |
| 289 | + | pub struct GithubVisibleArgs { | |
| 290 | + | pub actor: User, | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// An installation of the app, as the person's GitHub account sees it. | |
| 294 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 295 | + | #[serde(rename_all = "camelCase")] | |
| 296 | + | pub struct GithubVisibleInstallation { | |
| 297 | + | pub id: u64, | |
| 298 | + | /// The GitHub user or organization it is installed on. | |
| 299 | + | pub account: String, | |
| 300 | + | /// `User` or `Organization`. | |
| 301 | + | pub account_type: String, | |
| 302 | + | /// `all` or `selected` repositories. | |
| 303 | + | pub repository_selection: String, | |
| 304 | + | pub suspended: bool, | |
| 305 | + | pub settings_url: String, | |
| 306 | + | /// The person's workspaces it is recorded in already. | |
| 307 | + | pub recorded_in: Vec<String>, | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 311 | + | #[serde(rename_all = "camelCase")] | |
| 284 | 312 | pub struct GithubRepositoriesArgs { | |
| 285 | 313 | pub actor: User, | |
| 286 | 314 | pub workspace: String, | |
| ⋯ | |||
| 369 | 397 | pub headers: HashMap<String, String>, | |
| 370 | 398 | pub body: String, | |
| 371 | 399 | } | |
| 400 | + | ||
| 401 | + | #[cfg(test)] | |
| 402 | + | mod tests { | |
| 403 | + | use super::*; | |
| 404 | + | ||
| 405 | + | #[test] | |
| 406 | + | fn a_visible_installation_reads_as_the_site_expects() { | |
| 407 | + | let item = GithubVisibleInstallation { | |
| 408 | + | id: 7, | |
| 409 | + | account: "flagon-io".into(), | |
| 410 | + | account_type: "Organization".into(), | |
| 411 | + | repository_selection: "all".into(), | |
| 412 | + | suspended: false, | |
| 413 | + | settings_url: "https://github.com/organizations/flagon-io/settings/installations/7".into(), | |
| 414 | + | recorded_in: vec!["flagon-io".into()], | |
| 415 | + | }; | |
| 416 | + | let json = serde_json::to_value(&item).unwrap(); | |
| 417 | + | assert_eq!(json["accountType"], "Organization"); | |
| 418 | + | assert_eq!(json["repositorySelection"], "all"); | |
| 419 | + | assert_eq!(json["recordedIn"][0], "flagon-io"); | |
| 420 | + | assert_eq!(serde_json::from_value::<GithubVisibleInstallation>(json).unwrap(), item); | |
| 421 | + | } | |
| 422 | + | } | |
| 65 | 65 | createdAt: string; | |
| 66 | 66 | }; | |
| 67 | 67 | ||
| 68 | + | /** | |
| 69 | + | * An installation of the app the person's own GitHub account can see, | |
| 70 | + | * from `github_visible_installations`: how one installed on GitHub | |
| 71 | + | * directly is found and added to a workspace. | |
| 72 | + | */ | |
| 73 | + | export type GithubVisibleInstallation = { | |
| 74 | + | id: number; | |
| 75 | + | account: string; | |
| 76 | + | accountType: string; | |
| 77 | + | repositorySelection: "all" | "selected" | string; | |
| 78 | + | suspended: boolean; | |
| 79 | + | settingsUrl: string; | |
| 80 | + | /** The person's workspaces it is recorded in already. */ | |
| 81 | + | recordedIn: string[]; | |
| 82 | + | }; | |
| 83 | + | ||
| 68 | 84 | export type GithubAppStatus = { | |
| 69 | 85 | configured: boolean; | |
| 70 | 86 | installUrl: string | null; | |
| ⋯ | |||
| 145 | 161 | status: (viewer: User, workspace: string) => call<Result<GithubAppStatus>>("github_status", { viewer, workspace }), | |
| 146 | 162 | addInstallation: (actor: User, workspace: string, installationId: number) => | |
| 147 | 163 | call<Result<GithubInstallation>>("github_add_installation", { actor, workspace, installationId }), | |
| 164 | + | visibleInstallations: (actor: User) => call<Result<GithubVisibleInstallation[]>>("github_visible_installations", { actor }), | |
| 148 | 165 | removeInstallation: (actor: User, workspace: string, installationId: number) => | |
| 149 | 166 | call<Result<boolean>>("github_remove_installation", { actor, workspace, installationId }), | |
| 150 | 167 | repositories: (actor: User, workspace: string, installationId: number, page?: number) => | |
| 5 | 5 | //! back to `g1t.sh/integrations/github/setup`. The site asks this service | |
| 6 | 6 | //! to record the installation against a workspace, which it does only after | |
| 7 | 7 | //! checking with the person's own GitHub user token (from identity) that | |
| 8 | − | //! the installation is one they can see. Repositories are listed with that | |
| 8 | + | //! the installation is one they can see. An installation made on GitHub | |
| 9 | + | //! directly, or whose return lost g1t's state, is added the same way from | |
| 10 | + | //! the list `github_visible_installations` gives: what the person's token | |
| 11 | + | //! can see, and which of their workspaces have it already. The webhook | |
| 12 | + | //! never adds one, since it names no workspace. Repositories are listed with that | |
| 9 | 13 | //! same user token, so a person only ever sees what both they and the app | |
| 10 | 14 | //! can reach. | |
| 11 | 15 | //! | |
| ⋯ | |||
| 269 | 273 | } | |
| 270 | 274 | } | |
| 271 | 275 | ||
| 276 | + | const SETTINGS_URL: &str = "https://github.com/settings/installations"; | |
| 277 | + | ||
| 278 | + | /// One entry of GitHub's `GET /user/installations` (or an `installation` | |
| 279 | + | /// webhook's), as g1t shows it, with the workspaces it is recorded in. | |
| 280 | + | /// `None` without an id or an account. | |
| 281 | + | pub fn visible(item: &Value, recorded: &HashMap<u64, Vec<String>>) -> Option<GithubVisibleInstallation> { | |
| 282 | + | let id = item["id"].as_u64()?; | |
| 283 | + | let account = item["account"]["login"].as_str().filter(|login| !login.is_empty())?.to_owned(); | |
| 284 | + | Some(GithubVisibleInstallation { | |
| 285 | + | id, | |
| 286 | + | account, | |
| 287 | + | account_type: item["account"]["type"].as_str().or(item["target_type"].as_str()).unwrap_or("User").to_owned(), | |
| 288 | + | repository_selection: item["repository_selection"].as_str().unwrap_or("selected").to_owned(), | |
| 289 | + | suspended: item["suspended_at"].as_str().is_some(), | |
| 290 | + | settings_url: item["html_url"].as_str().unwrap_or(SETTINGS_URL).to_owned(), | |
| 291 | + | recorded_in: recorded.get(&id).cloned().unwrap_or_default(), | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | /// GitHub's listing as g1t shows it: by account, each once. | |
| 296 | + | pub fn visible_installations(listed: &[Value], recorded: &HashMap<u64, Vec<String>>) -> Vec<GithubVisibleInstallation> { | |
| 297 | + | let mut seen: Vec<GithubVisibleInstallation> = Vec::new(); | |
| 298 | + | for item in listed.iter().filter_map(|item| visible(item, recorded)) { | |
| 299 | + | if !seen.iter().any(|known| known.id == item.id) { | |
| 300 | + | seen.push(item); | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | seen.sort_by_key(|item| item.account.to_lowercase()); | |
| 304 | + | seen | |
| 305 | + | } | |
| 306 | + | ||
| 272 | 307 | fn null_or(value: Option<&str>) -> JsValue { | |
| 273 | 308 | value.map_or(JsValue::NULL, Into::into) | |
| 274 | 309 | } | |
| ⋯ | |||
| 444 | 479 | Outcome::Ok(token) => token, | |
| 445 | 480 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 446 | 481 | }; | |
| 447 | − | let mut found = None; | |
| 448 | − | for page in 1..=5 { | |
| 449 | − | let answer = self | |
| 450 | − | .github(Method::Get, &format!("/user/installations?per_page=100&page={page}"), &token, None) | |
| 451 | − | .await?; | |
| 452 | − | if !answer.ok() { | |
| 453 | − | return Ok(fail(FailureCode::Conflict, answer.problem("GitHub"))); | |
| 454 | − | } | |
| 455 | − | let body = answer.json(); | |
| 456 | − | let listed = body["installations"].as_array().cloned().unwrap_or_default(); | |
| 457 | − | found = listed.iter().find(|item| item["id"].as_u64() == Some(a.installation_id)).cloned(); | |
| 458 | − | if found.is_some() || listed.len() < 100 { | |
| 459 | − | break; | |
| 460 | − | } | |
| 461 | − | } | |
| 462 | − | let Some(item) = found else { | |
| 482 | + | let listed = match self.user_installations(&token).await? { | |
| 483 | + | Ok(listed) => listed, | |
| 484 | + | Err(problem) => return Ok(fail(FailureCode::Conflict, problem)), | |
| 485 | + | }; | |
| 486 | + | let Some(item) = listed.into_iter().find(|item| item["id"].as_u64() == Some(a.installation_id)) else { | |
| 463 | 487 | return Ok(fail( | |
| 464 | 488 | FailureCode::Forbidden, | |
| 465 | 489 | "Your GitHub account cannot see that installation. Install the app from your own GitHub account or an organization you manage.", | |
| ⋯ | |||
| 472 | 496 | account: item["account"]["login"].as_str().unwrap_or_default().to_owned(), | |
| 473 | 497 | account_type: item["account"]["type"].as_str().or(item["target_type"].as_str()).unwrap_or("User").to_owned(), | |
| 474 | 498 | repository_selection: item["repository_selection"].as_str().unwrap_or("selected").to_owned(), | |
| 475 | − | settings_url: item["html_url"].as_str().unwrap_or("https://github.com/settings/installations").to_owned(), | |
| 499 | + | settings_url: item["html_url"].as_str().unwrap_or(SETTINGS_URL).to_owned(), | |
| 476 | 500 | suspended_at: item["suspended_at"].as_str().map(str::to_owned), | |
| 477 | 501 | created_at: now, | |
| 478 | 502 | }; | |
| ⋯ | |||
| 501 | 525 | Ok(Outcome::Ok(row.into())) | |
| 502 | 526 | } | |
| 503 | 527 | ||
| 528 | + | /// Every installation of the app the person's GitHub user token can | |
| 529 | + | /// see: on their own account, and on organizations they belong to. | |
| 530 | + | async fn user_installations(&self, token: &str) -> Result<std::result::Result<Vec<Value>, String>> { | |
| 531 | + | let mut all = Vec::new(); | |
| 532 | + | for page in 1..=5 { | |
| 533 | + | let answer = self | |
| 534 | + | .github(Method::Get, &format!("/user/installations?per_page=100&page={page}"), token, None) | |
| 535 | + | .await?; | |
| 536 | + | if !answer.ok() { | |
| 537 | + | return Ok(Err(answer.problem("GitHub"))); | |
| 538 | + | } | |
| 539 | + | let listed = answer.json()["installations"].as_array().cloned().unwrap_or_default(); | |
| 540 | + | let more = listed.len() == 100; | |
| 541 | + | all.extend(listed); | |
| 542 | + | if !more { | |
| 543 | + | break; | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | Ok(Ok(all)) | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | /// The app's installations the person can see on GitHub, each with the | |
| 550 | + | /// workspaces of theirs it is recorded in, so one installed on GitHub | |
| 551 | + | /// directly can be added to a workspace. | |
| 552 | + | pub async fn visible_installations(&self, a: GithubVisibleArgs) -> Result<Outcome<Vec<GithubVisibleInstallation>>> { | |
| 553 | + | if a.actor.kind != PrincipalKind::User { | |
| 554 | + | return Ok(fail(FailureCode::Forbidden, "Only a person can see their GitHub installations.")); | |
| 555 | + | } | |
| 556 | + | if !self.config.configured() { | |
| 557 | + | return Ok(fail(FailureCode::NotFound, NOT_SET_UP)); | |
| 558 | + | } | |
| 559 | + | let token = match self.user_token(&a.actor).await? { | |
| 560 | + | Outcome::Ok(token) => token, | |
| 561 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 562 | + | }; | |
| 563 | + | let listed = match self.user_installations(&token).await? { | |
| 564 | + | Ok(listed) => listed, | |
| 565 | + | Err(problem) => return Ok(fail(FailureCode::Conflict, problem)), | |
| 566 | + | }; | |
| 567 | + | let ids: Vec<u64> = listed.iter().filter_map(|item| item["id"].as_u64()).collect(); | |
| 568 | + | let mut recorded: HashMap<u64, Vec<String>> = HashMap::new(); | |
| 569 | + | if !ids.is_empty() { | |
| 570 | + | #[derive(Deserialize)] | |
| 571 | + | struct Recorded { | |
| 572 | + | id: u64, | |
| 573 | + | workspace: String, | |
| 574 | + | } | |
| 575 | + | let marks = vec!["?"; ids.len()].join(", "); | |
| 576 | + | let bind: Vec<JsValue> = ids.iter().map(|id| number(*id)).collect(); | |
| 577 | + | let rows = self | |
| 578 | + | .db | |
| 579 | + | .prepare(format!("SELECT id, workspace FROM github_installations WHERE id IN ({marks}) ORDER BY workspace")) | |
| 580 | + | .bind(&bind)? | |
| 581 | + | .all() | |
| 582 | + | .await? | |
| 583 | + | .results::<Recorded>()?; | |
| 584 | + | // Only the person's own workspaces are named back to them. | |
| 585 | + | for row in rows.into_iter().filter(|row| a.actor.is_member(&row.workspace)) { | |
| 586 | + | recorded.entry(row.id).or_default().push(row.workspace); | |
| 587 | + | } | |
| 588 | + | } | |
| 589 | + | Ok(Outcome::Ok(visible_installations(&listed, &recorded))) | |
| 590 | + | } | |
| 591 | + | ||
| 504 | 592 | /// Forgets an installation in a workspace; its mirrors stop. The app | |
| 505 | 593 | /// stays installed on GitHub until it is uninstalled there. | |
| 506 | 594 | pub async fn remove_installation(&self, a: GithubInstallationArgs, mirrors: Option<&crate::remotes::Mirrors>) -> Result<Outcome<bool>> { | |
| ⋯ | |||
| 940 | 1028 | let action = payload["action"].as_str().unwrap_or_default(); | |
| 941 | 1029 | let installation = payload["installation"]["id"].as_u64(); | |
| 942 | 1030 | match (event, action) { | |
| 1031 | + | // An installation recorded already (GitHub sent its creation | |
| 1032 | + | // again, or new permissions were accepted there) is kept in | |
| 1033 | + | // step. One nobody added from g1t names no workspace: it waits | |
| 1034 | + | // for an owner to add it from New project. | |
| 1035 | + | ("installation", "created") | ("installation", "new_permissions_accepted") => { | |
| 1036 | + | let Some(id) = installation else { return Ok(()) }; | |
| 1037 | + | if let Some(seen) = visible(&payload["installation"], &HashMap::new()) { | |
| 1038 | + | self.db | |
| 1039 | + | .prepare( | |
| 1040 | + | "UPDATE github_installations SET account = ?2, repository_selection = ?3, settings_url = ?4 | |
| 1041 | + | WHERE id = ?1", | |
| 1042 | + | ) | |
| 1043 | + | .bind(&[ | |
| 1044 | + | number(id), | |
| 1045 | + | seen.account.as_str().into(), | |
| 1046 | + | seen.repository_selection.as_str().into(), | |
| 1047 | + | seen.settings_url.as_str().into(), | |
| 1048 | + | ])? | |
| 1049 | + | .run() | |
| 1050 | + | .await?; | |
| 1051 | + | } | |
| 1052 | + | } | |
| 943 | 1053 | ("installation", "deleted") | ("installation", "suspend") | ("installation", "unsuspend") => { | |
| 944 | 1054 | let Some(id) = installation else { return Ok(()) }; | |
| 945 | 1055 | match action { | |
| ⋯ | |||
| 1098 | 1208 | match method { | |
| 1099 | 1209 | "github_status" => reply(&app.status(args(body)?).await?), | |
| 1100 | 1210 | "github_add_installation" => reply(&app.add_installation(args(body)?).await?), | |
| 1211 | + | "github_visible_installations" => reply(&app.visible_installations(args(body)?).await?), | |
| 1101 | 1212 | "github_remove_installation" => { | |
| 1102 | 1213 | let mirrors = crate::remotes::Mirrors::new(env).ok(); | |
| 1103 | 1214 | reply(&app.remove_installation(args(body)?, mirrors.as_ref()).await?) | |
| ⋯ | |||
| 1240 | 1351 | config.private_key = None; | |
| 1241 | 1352 | assert!(!config.configured()); | |
| 1242 | 1353 | } | |
| 1354 | + | ||
| 1355 | + | #[test] | |
| 1356 | + | fn installations_seen_on_github_say_where_they_are_recorded() { | |
| 1357 | + | let listed = vec![ | |
| 1358 | + | json!({ | |
| 1359 | + | "id": 2, | |
| 1360 | + | "account": { "login": "syntaqx", "type": "User" }, | |
| 1361 | + | "repository_selection": "selected", | |
| 1362 | + | "html_url": "https://github.com/settings/installations/2", | |
| 1363 | + | "suspended_at": null, | |
| 1364 | + | }), | |
| 1365 | + | json!({ | |
| 1366 | + | "id": 1, | |
| 1367 | + | "account": { "login": "flagon-io", "type": "Organization" }, | |
| 1368 | + | "repository_selection": "all", | |
| 1369 | + | "html_url": "https://github.com/organizations/flagon-io/settings/installations/1", | |
| 1370 | + | "suspended_at": "2026-10-01T00:00:00Z", | |
| 1371 | + | }), | |
| 1372 | + | // Listed twice across pages, and one with no account: shown once, and not at all. | |
| 1373 | + | json!({ "id": 1, "account": { "login": "flagon-io", "type": "Organization" } }), | |
| 1374 | + | json!({ "id": 3, "account": null }), | |
| 1375 | + | ]; | |
| 1376 | + | let recorded = HashMap::from([(2, vec!["syntaqx".to_owned()])]); | |
| 1377 | + | let seen = visible_installations(&listed, &recorded); | |
| 1378 | + | assert_eq!(seen.len(), 2); | |
| 1379 | + | assert_eq!(seen[0].account, "flagon-io"); | |
| 1380 | + | assert_eq!(seen[0].account_type, "Organization"); | |
| 1381 | + | assert_eq!(seen[0].repository_selection, "all"); | |
| 1382 | + | assert!(seen[0].suspended); | |
| 1383 | + | assert!(seen[0].recorded_in.is_empty()); | |
| 1384 | + | assert_eq!(seen[1].account, "syntaqx"); | |
| 1385 | + | assert_eq!(seen[1].recorded_in, vec!["syntaqx".to_owned()]); | |
| 1386 | + | assert!(!seen[1].suspended); | |
| 1387 | + | } | |
| 1388 | + | ||
| 1389 | + | #[test] | |
| 1390 | + | fn an_installation_without_its_settings_link_points_at_githubs_list() { | |
| 1391 | + | let seen = visible(&json!({ "id": 9, "account": { "login": "ada" }, "target_type": "User" }), &HashMap::new()).unwrap(); | |
| 1392 | + | assert_eq!(seen.settings_url, SETTINGS_URL); | |
| 1393 | + | assert_eq!(seen.repository_selection, "selected"); | |
| 1394 | + | assert_eq!(seen.account_type, "User"); | |
| 1395 | + | } | |
| 1243 | 1396 | } | |