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