Skip to content

Commit

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.

syntaqxcommitted Parent5c2b792Browse files
10 files+591−510/10 viewed
+26−1
7575 3. If no GitHub account is connected to the workspace, a workspace owner
7676 chooses **Install on GitHub**, picks the GitHub account or organization,
7777 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).
7981 4. Tick the repositories to bring across. You see only repositories that
8082 both you and the app can reach.
8183 5. Choose how they come across, and whether to copy their issues.
9698 run its GitHub workflows on g1t while GitHub's don't run, or move it to g1t
9799 for good. See [mirroring](/guides/mirroring/).
98100
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+
99124 ### What comes across
100125
101126 | | |
+3−8
99 import { CONNECTORS } from "@g1t/contracts/connectors";
1010
1111 import type { ConnectedState } from "./connectors";
12+import { githubConnected } from "./github";
1213 import { githubApp } from "./github.server";
1314 import { integrationsSection } from "./integration-sections";
1415 import { integrations, webhooks } from "./services.server";
3536 };
3637 }
3738 }
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;
4641 const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : [];
4742 if (ours.length > 0) {
4843 const failing = ours.filter((hook) => hook.lastStatus === "failed");
+75−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
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";
522
623 test("the state must come back exactly as it was given", () => {
724 const state = newState();
3855 assert.equal(installWorkspace(null, state), null);
3956 assert.equal(installWorkspace(`${state}.`, state), null);
4057 });
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+});
+59−0
7272 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
7373 }
7474
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+
75134 /** What each way of bringing a repository across does, for the picker. */
76135 export const MODES = [
77136 {
+1−1
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121121 "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"
123123 ).split(" "),
124124 );
125125
+124−17
1−import { Link, data, redirect } from "react-router";
1+import { Form, Link, data, redirect } from "react-router";
22
33 import type { Route } from "./+types/github-setup";
44 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";
68 import { githubApp } from "../lib/github.server";
79 import { page } from "../lib/meta";
8−import { requireUser } from "../lib/session.server";
10+import { assertSameOrigin, requireUser, roleIn } from "../lib/session.server";
911
1012 export function meta(args: Route.MetaArgs) {
1113 return page(args, { title: "Connecting GitHub · g1t" });
1214 }
1315
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+
1423 /**
1524 * The app's Setup URL: GitHub returns here after an installation with
1625 * `installation_id`, `setup_action` and the state. The installation is
1726 * recorded against the workspace it was started for, after integrations
1827 * 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.
1932 */
2033 export async function loader({ request, context }: Route.LoaderArgs) {
2134 const user = requireUser(context, request);
2437 const workspace = installWorkspace(readCookie(request.headers.get("cookie"), INSTALL_COOKIE), url.searchParams.get("state"));
2538 const action = url.searchParams.get("setup_action");
2639 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 });
2742 // Repositories chosen again on GitHub: nothing to record, the picker
2843 // lists what the installation can see now.
2944 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 });
3549 }
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 });
3950 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.");
4152 }
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." };
4294 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}`);
4597 }
4698
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+ }
48147 return (
49148 <AuthCard
50149 title="Connecting GitHub"
51150 subtitle="That did not work"
52151 footer={
53152 <Link
54− to={loaderData.workspace ? `/new/github?workspace=${loaderData.workspace}` : "/new"}
153+ to={loaderData.workspace ? `/new/github?workspace=${loaderData.workspace}` : "/new/github"}
55154 className="text-fg underline underline-offset-4"
56155 >
57− Back to New project
156+ Back to Import from GitHub
58157 </Link>
59158 }
60159 >
61160 <p className="text-sm text-danger" role="alert">
62161 {loaderData.error}
63162 </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+ )}
64171 </AuthCard>
65172 );
66173 }
+64−5
77 import { CheckboxOption } from "../components/ui/checkbox";
88 import { FieldLegend, FieldSet } from "../components/ui/field";
99 import { RadioCard, RadioGroup } from "../components/ui/radio-group";
10−import { MODES } from "../lib/github";
10+import { MODES, installationSummary, notYetAdded } from "../lib/github";
1111 import { githubApp } from "../lib/github.server";
1212 import { page } from "../lib/meta";
1313 import { assertSameOrigin, requireUser, roleIn } from "../lib/session.server";
2727 if (workspaces.length === 0) throw redirect("/workspaces/new");
2828 const asked = url.searchParams.get("workspace")?.toLowerCase();
2929 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+ ]);
3136 if (!status.ok || !status.value.configured) throw redirect(`/new?workspace=${workspace}`);
3237 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+ : [];
3345 const wanted = Number(url.searchParams.get("installation"));
3446 const installation = installations.find((item) => item.id === wanted) ?? installations[0] ?? null;
3547 const pageNumber = Math.max(1, Number(url.searchParams.get("page")) || 1);
4658 owner: roleIn(user, workspace) === "owner",
4759 linked,
4860 installations,
61+ claimable,
4962 installation,
5063 repositories,
5164 error,
5972 const form = await request.formData();
6073 const workspace = String(form.get("workspace") ?? "");
6174 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+ }
6280 if (form.get("intent") === "remove-installation") {
6381 const removed = await githubApp.removeInstallation(user, workspace, installationId);
6482 // Shown by the list it was removed from, which is there with or without an account chosen.
84102 }
85103
86104 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";
88108 const here = `/new/github?workspace=${workspace}`;
89109 const names = new Map((repositories?.repositories ?? []).map((repo) => [repo.id, repo.fullName]));
90110 return (
119139 <section className="mt-8 rounded-lg border border-line p-6">
120140 <h2 className="font-medium">Connect your GitHub account</h2>
121141 <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.
123144 </p>
124145 <div className="mt-4">
125146 <a
149170 Your request went to the organization's owners on GitHub. Once one approves it, add the account here again.
150171 </p>
151172 )}
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+ )}
152207 {installations.length === 0 ? (
153208 <div className="mt-3 rounded-lg border border-dashed border-line p-6 text-sm text-muted">
154209 {owner ? (
155210 <>
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>
157216 <div className="mt-4">
158217 <a
159218 href={`/integrations/github/install?workspace=${workspace}`}
+51−0
3030 //! `Outcome<GithubInstallation>`.
3131 //! - `github_remove_installation` takes `GithubInstallationArgs`, returns
3232 //! `Outcome<bool>`.
33+//! - `github_visible_installations` takes `GithubVisibleArgs`, returns
34+//! `Outcome<Vec<GithubVisibleInstallation>>`.
3335 //! - `github_repositories` takes `GithubRepositoriesArgs`, returns
3436 //! `Outcome<GithubRepositories>`.
3537 //! - `github_import` takes `GithubImportArgs`, returns `Outcome<GithubRepoLink>`.
279281 pub installation_id: u64,
280282 }
281283
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.
282287 #[derive(Debug, Serialize, Deserialize)]
283288 #[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")]
284312 pub struct GithubRepositoriesArgs {
285313 pub actor: User,
286314 pub workspace: String,
369397 pub headers: HashMap<String, String>,
370398 pub body: String,
371399 }
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+}
+17−0
6565 createdAt: string;
6666 };
6767
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+
6884 export type GithubAppStatus = {
6985 configured: boolean;
7086 installUrl: string | null;
145161 status: (viewer: User, workspace: string) => call<Result<GithubAppStatus>>("github_status", { viewer, workspace }),
146162 addInstallation: (actor: User, workspace: string, installationId: number) =>
147163 call<Result<GithubInstallation>>("github_add_installation", { actor, workspace, installationId }),
164+ visibleInstallations: (actor: User) => call<Result<GithubVisibleInstallation[]>>("github_visible_installations", { actor }),
148165 removeInstallation: (actor: User, workspace: string, installationId: number) =>
149166 call<Result<boolean>>("github_remove_installation", { actor, workspace, installationId }),
150167 repositories: (actor: User, workspace: string, installationId: number, page?: number) =>
+171−18
55 //! back to `g1t.sh/integrations/github/setup`. The site asks this service
66 //! to record the installation against a workspace, which it does only after
77 //! 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
913 //! same user token, so a person only ever sees what both they and the app
1014 //! can reach.
1115 //!
269273 }
270274 }
271275
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+
272307 fn null_or(value: Option<&str>) -> JsValue {
273308 value.map_or(JsValue::NULL, Into::into)
274309 }
444479 Outcome::Ok(token) => token,
445480 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
446481 };
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 {
463487 return Ok(fail(
464488 FailureCode::Forbidden,
465489 "Your GitHub account cannot see that installation. Install the app from your own GitHub account or an organization you manage.",
472496 account: item["account"]["login"].as_str().unwrap_or_default().to_owned(),
473497 account_type: item["account"]["type"].as_str().or(item["target_type"].as_str()).unwrap_or("User").to_owned(),
474498 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(),
476500 suspended_at: item["suspended_at"].as_str().map(str::to_owned),
477501 created_at: now,
478502 };
501525 Ok(Outcome::Ok(row.into()))
502526 }
503527
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+
504592 /// Forgets an installation in a workspace; its mirrors stop. The app
505593 /// stays installed on GitHub until it is uninstalled there.
506594 pub async fn remove_installation(&self, a: GithubInstallationArgs, mirrors: Option<&crate::remotes::Mirrors>) -> Result<Outcome<bool>> {
9401028 let action = payload["action"].as_str().unwrap_or_default();
9411029 let installation = payload["installation"]["id"].as_u64();
9421030 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+ }
9431053 ("installation", "deleted") | ("installation", "suspend") | ("installation", "unsuspend") => {
9441054 let Some(id) = installation else { return Ok(()) };
9451055 match action {
10981208 match method {
10991209 "github_status" => reply(&app.status(args(body)?).await?),
11001210 "github_add_installation" => reply(&app.add_installation(args(body)?).await?),
1211+ "github_visible_installations" => reply(&app.visible_installations(args(body)?).await?),
11011212 "github_remove_installation" => {
11021213 let mirrors = crate::remotes::Mirrors::new(env).ok();
11031214 reply(&app.remove_installation(args(body)?, mirrors.as_ref()).await?)
12401351 config.private_key = None;
12411352 assert!(!config.configured());
12421353 }
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+ }
12431396 }