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