Skip to content
375 linesCodeBlameRaw
1import { ArrowLeft, Lock, Plus } from "lucide-react";
2import { Form, Link, redirect } from "react-router";
3
4import type { Route } from "./+types/new-github";
5import { GithubMark } from "../components/github";
6import { ButtonLink, ErrorText, Pill, SubmitButton } from "../components/ui";
7import { CheckboxOption } from "../components/ui/checkbox";
8import { FieldLegend, FieldSet } from "../components/ui/field";
9import { RadioCard, RadioGroup } from "../components/ui/radio-group";
10import { MODES, installationSummary, notYetAdded } from "../lib/github";
11import { githubApp } from "../lib/github.server";
12import { page } from "../lib/meta";
13import { assertSameOrigin, requireUser, roleIn } from "../lib/session.server";
14
15export function meta(args: Route.MetaArgs) {
16 return page(args, { title: "Import from GitHub · g1t" });
17}
18
19export async function loader({ request, context }: Route.LoaderArgs) {
20 const user = requireUser(context, request);
21 const url = new URL(request.url);
22 const workspaces = (user.workspaces ?? []).map((membership) => ({
23 slug: membership.slug,
24 name: membership.name ?? membership.slug,
25 owner: membership.role === "owner",
26 }));
27 if (workspaces.length === 0) throw redirect("/workspaces/new");
28 const asked = url.searchParams.get("workspace")?.toLowerCase();
29 const workspace = workspaces.find((option) => option.slug === asked)?.slug ?? workspaces[0].slug;
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 ]);
36 if (!status.ok || !status.value.configured) throw redirect(`/new?workspace=${workspace}`);
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 : [];
45 const wanted = Number(url.searchParams.get("installation"));
46 const installation = installations.find((item) => item.id === wanted) ?? installations[0] ?? null;
47 const pageNumber = Math.max(1, Number(url.searchParams.get("page")) || 1);
48 let repositories = null;
49 let error: string | null = null;
50 if (linked && installation) {
51 const listed = await githubApp.repositories(user, workspace, installation.id, pageNumber);
52 if (listed.ok) repositories = listed.value;
53 else error = listed.error.message;
54 }
55 return {
56 workspaces,
57 workspace,
58 owner: roleIn(user, workspace) === "owner",
59 linked,
60 installations,
61 claimable,
62 installation,
63 repositories,
64 error,
65 requested: url.searchParams.get("requested") === "1",
66 };
67}
68
69export async function action({ request, context }: Route.ActionArgs) {
70 assertSameOrigin(request);
71 const user = requireUser(context, request);
72 const form = await request.formData();
73 const workspace = String(form.get("workspace") ?? "");
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 }
80 if (form.get("intent") === "remove-installation") {
81 const removed = await githubApp.removeInstallation(user, workspace, installationId);
82 // Shown by the list it was removed from, which is there with or without an account chosen.
83 if (!removed.ok) return { error: null, removeError: removed.error.message, results: [] };
84 throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}`);
85 }
86 const mode = (["import", "mirror", "push"] as const).find((option) => option === form.get("mode")) ?? "import";
87 const chosen = form.getAll("repo").map(Number).filter((id) => Number.isSafeInteger(id) && id > 0).slice(0, 20);
88 if (chosen.length === 0) return { error: "Choose at least one repository.", results: [] };
89 const issues = form.get("issues") === "on";
90 // One at a time: each is a copy of a whole repository.
91 const results: { id: number; ok: boolean; repo?: string; error?: string }[] = [];
92 for (const githubRepoId of chosen) {
93 const imported = await githubApp.import(user, workspace, { installationId, githubRepoId, mode, issues });
94 results.push(
95 imported.ok
96 ? { id: githubRepoId, ok: true, repo: imported.value.repo }
97 : { id: githubRepoId, ok: false, error: imported.error.message },
98 );
99 }
100 if (results.length === 1 && results[0].ok) throw redirect(`/${results[0].repo}`);
101 return { error: null, results };
102}
103
104export default function NewFromGithub({ loaderData, actionData }: Route.ComponentProps) {
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";
108 const here = `/new/github?workspace=${workspace}`;
109 const names = new Map((repositories?.repositories ?? []).map((repo) => [repo.id, repo.fullName]));
110 return (
111 <main className="mx-auto max-w-2xl px-4 py-12">
112 <Link to={`/new?workspace=${workspace}`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
113 <ArrowLeft size={14} /> New project
114 </Link>
115 <span className="mt-6 flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
116 <GithubMark className="size-[18px]" />
117 </span>
118 <h1 className="mt-4 text-2xl font-semibold tracking-tight">Import from GitHub</h1>
119 <p className="mt-1.5 text-sm text-muted">
120 Bring repositories into <span className="font-mono text-fg">{workspace}</span> through g1t's GitHub App. You choose
121 which repositories it can see on GitHub; nothing else is read.
122 </p>
123
124 {workspaces.length > 1 && (
125 <nav className="mt-6 flex flex-wrap gap-2" aria-label="Workspace">
126 {workspaces.map((option) => (
127 <Link
128 key={option.slug}
129 to={`/new/github?workspace=${option.slug}`}
130 className={`rounded-full border px-3 py-1 text-sm ${option.slug === workspace ? "border-accent/60 text-fg" : "border-line text-muted hover:text-fg"}`}
131 >
132 {option.name}
133 </Link>
134 ))}
135 </nav>
136 )}
137
138 {!linked ? (
139 <section className="mt-8 rounded-lg border border-line p-6">
140 <h2 className="font-medium">Connect your GitHub account</h2>
141 <p className="mt-1.5 text-sm text-muted">
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.
144 </p>
145 <div className="mt-4">
146 <a
147 href={`/auth/github?link=1&next=${encodeURIComponent(here)}`}
148 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"
149 >
150 <GithubMark /> Connect GitHub
151 </a>
152 </div>
153 </section>
154 ) : (
155 <>
156 <section className="mt-8">
157 <div className="flex items-center justify-between gap-4">
158 <h2 className="font-medium">GitHub accounts</h2>
159 {owner && (
160 <a
161 href={`/integrations/github/install?workspace=${workspace}`}
162 className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"
163 >
164 <Plus size={14} /> Add a GitHub account
165 </a>
166 )}
167 </div>
168 {loaderData.requested && (
169 <p className="mt-3 text-sm text-muted">
170 Your request went to the organization's owners on GitHub. Once one approves it, add the account here again.
171 </p>
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 )}
207 {installations.length === 0 ? (
208 <div className="mt-3 rounded-lg border border-dashed border-line p-6 text-sm text-muted">
209 {owner ? (
210 <>
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>
216 <div className="mt-4">
217 <a
218 href={`/integrations/github/install?workspace=${workspace}`}
219 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"
220 >
221 <GithubMark /> Install on GitHub
222 </a>
223 </div>
224 </>
225 ) : (
226 <p>No GitHub account is connected to this workspace yet. Ask one of its owners to add one.</p>
227 )}
228 </div>
229 ) : (
230 <ul className="mt-3 divide-y divide-line rounded-md border border-line">
231 {installations.map((item) => (
232 <li key={item.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2.5 text-sm">
233 <Link
234 to={`${here}&installation=${item.id}`}
235 className={`whitespace-nowrap ${item.id === installation?.id ? "font-medium text-fg" : "text-muted hover:text-fg"}`}
236 >
237 {item.account}
238 </Link>
239 <span className="whitespace-nowrap text-xs text-faint">
240 {item.accountType === "Organization" ? "Organization" : "Personal"} ·{" "}
241 {item.repositorySelection === "all" ? "all repositories" : "selected repositories"}
242 </span>
243 {item.suspended && <Pill>Suspended</Pill>}
244 <span className="ml-auto flex items-center gap-3">
245 <a href={item.settingsUrl} className="whitespace-nowrap text-xs text-muted hover:text-fg" target="_blank" rel="noreferrer">
246 Choose repositories on GitHub
247 </a>
248 {owner && (
249 <Form method="post">
250 <input type="hidden" name="intent" value="remove-installation" />
251 <input type="hidden" name="workspace" value={workspace} />
252 <input type="hidden" name="installation" value={item.id} />
253 <SubmitButton
254 pending="Removing…"
255 match={{ intent: "remove-installation", installation: String(item.id) }}
256 className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50"
257 >
258 Remove
259 </SubmitButton>
260 </Form>
261 )}
262 </span>
263 </li>
264 ))}
265 </ul>
266 )}
267 <ErrorText>{actionData && "removeError" in actionData ? actionData.removeError : null}</ErrorText>
268 </section>
269
270 {installation && (
271 <Form method="post" className="mt-10 space-y-8">
272 <input type="hidden" name="intent" value="import" />
273 <input type="hidden" name="workspace" value={workspace} />
274 <input type="hidden" name="installation" value={installation.id} />
275 <FieldSet>
276 <FieldLegend>Repositories on {installation.account}</FieldLegend>
277 <ErrorText>{loaderData.error}</ErrorText>
278 {repositories && repositories.repositories.length === 0 && (
279 <p className="text-sm text-muted">
280 The app cannot see any repositories here yet.{" "}
281 <a href={installation.settingsUrl} className="text-fg underline underline-offset-4">
282 Choose some on GitHub
283 </a>
284 .
285 </p>
286 )}
287 <div className="divide-y divide-line rounded-md border border-line empty:hidden">
288 {(repositories?.repositories ?? []).map((repo) => (
289 <div key={repo.id} className="px-4 py-3">
290 <CheckboxOption
291 name="repo"
292 value={String(repo.id)}
293 disabled={repo.linkedTo != null}
294 label={
295 <span className="flex flex-wrap items-center gap-2">
296 <span className="font-mono">{repo.fullName}</span>
297 {repo.private && (
298 <span className="inline-flex items-center gap-1 text-xs text-faint">
299 <Lock size={11} /> Private
300 </span>
301 )}
302 {repo.linkedTo && <Pill>On g1t as {repo.linkedTo}</Pill>}
303 </span>
304 }
305 description={repo.description ?? undefined}
306 />
307 </div>
308 ))}
309 </div>
310 {repositories && repositories.total > repositories.perPage && (
311 <div className="flex justify-between text-sm">
312 {repositories.page > 1 ? (
313 <Link to={`${here}&installation=${installation.id}&page=${repositories.page - 1}`} className="text-muted hover:text-fg">
314 Previous
315 </Link>
316 ) : (
317 <span />
318 )}
319 {repositories.page * repositories.perPage < repositories.total && (
320 <Link to={`${here}&installation=${installation.id}&page=${repositories.page + 1}`} className="text-muted hover:text-fg">
321 Next
322 </Link>
323 )}
324 </div>
325 )}
326 </FieldSet>
327
328 <FieldSet>
329 <FieldLegend>How it comes across</FieldLegend>
330 <RadioGroup name="mode" defaultValue="import" aria-label="How it comes across" className="gap-3 sm:grid-cols-3">
331 {MODES.map((mode) => (
332 <RadioCard key={mode.id} value={mode.id} title={mode.title} description={mode.text} />
333 ))}
334 </RadioGroup>
335 </FieldSet>
336
337 <CheckboxOption
338 name="issues"
339 defaultChecked
340 label="Copy issues too"
341 description="Up to 200 per repository, with their labels and whether they are open; a milestone is noted in the description. Pull requests stay on GitHub; their branches come across."
342 />
343
344 {actionData?.results && actionData.results.length > 0 && (
345 <ul className="space-y-1 rounded-md border border-line p-4 text-sm">
346 {actionData.results.map((result) => (
347 <li key={result.id}>
348 <span className="font-mono">{names.get(result.id) ?? result.id}</span>:{" "}
349 {result.ok ? (
350 <Link to={`/${result.repo}`} className="text-accent underline underline-offset-4">
351 {result.repo}
352 </Link>
353 ) : (
354 <span className="text-danger">{result.error}</span>
355 )}
356 </li>
357 ))}
358 </ul>
359 )}
360 <ErrorText>{actionData?.error}</ErrorText>
361 <div className="flex items-center gap-3">
362 <SubmitButton variant="accent" pending="Bringing them across…" match={{ intent: "import" }}>
363 Bring to g1t
364 </SubmitButton>
365 <ButtonLink to={`/new?workspace=${workspace}`} variant="quiet">
366 Cancel
367 </ButtonLink>
368 </div>
369 </Form>
370 )}
371 </>
372 )}
373 </main>
374 );
375}