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