g1t/apps/web/app/routes/new-github.tsx

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