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

310 lines14,945 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { ArrowLeft, Lock, Plus } from "lucide-react";
2import { Form, Link, redirect, useNavigation } from "react-router";
3
4import type { Route } from "./+types/new-github";
5import { GithubMark } from "../components/github";
6import { Button, ButtonLink, ErrorText, Pill } 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 if (!removed.ok) return { error: removed.error.message, results: [] };
65 throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}`);
66 }
67 const mode = (["import", "mirror", "push"] as const).find((option) => option === form.get("mode")) ?? "import";
68 const chosen = form.getAll("repo").map(Number).filter((id) => Number.isSafeInteger(id) && id > 0).slice(0, 20);
69 if (chosen.length === 0) return { error: "Choose at least one repository.", results: [] };
70 const issues = form.get("issues") === "on";
71 // One at a time: each is a copy of a whole repository.
72 const results: { id: number; ok: boolean; repo?: string; error?: string }[] = [];
73 for (const githubRepoId of chosen) {
74 const imported = await githubApp.import(user, workspace, { installationId, githubRepoId, mode, issues });
75 results.push(
76 imported.ok
77 ? { id: githubRepoId, ok: true, repo: imported.value.repo }
78 : { id: githubRepoId, ok: false, error: imported.error.message },
79 );
80 }
81 if (results.length === 1 && results[0].ok) throw redirect(`/${results[0].repo}`);
82 return { error: null, results };
83}
84
85export default function NewFromGithub({ loaderData, actionData }: Route.ComponentProps) {
86 const { workspace, workspaces, installations, installation, repositories, linked, owner } = loaderData;
87 const busy = useNavigation().state === "submitting";
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 <button type="submit" className="text-xs text-faint hover:text-danger">
195 Remove
196 </button>
197 </Form>
198 )}
199 </span>
200 </li>
201 ))}
202 </ul>
203 )}
204 </section>
205
206 {installation && (
207 <Form method="post" className="mt-10 space-y-8">
208 <input type="hidden" name="workspace" value={workspace} />
209 <input type="hidden" name="installation" value={installation.id} />
210 <FieldSet>
211 <FieldLegend>Repositories on {installation.account}</FieldLegend>
212 <ErrorText>{loaderData.error}</ErrorText>
213 {repositories && repositories.repositories.length === 0 && (
214 <p className="text-sm text-muted">
215 The app cannot see any repositories here yet.{" "}
216 <a href={installation.settingsUrl} className="text-fg underline underline-offset-4">
217 Choose some on GitHub
218 </a>
219 .
220 </p>
221 )}
222 <div className="divide-y divide-line rounded-md border border-line empty:hidden">
223 {(repositories?.repositories ?? []).map((repo) => (
224 <div key={repo.id} className="px-4 py-3">
225 <CheckboxOption
226 name="repo"
227 value={String(repo.id)}
228 disabled={repo.linkedTo != null}
229 label={
230 <span className="flex flex-wrap items-center gap-2">
231 <span className="font-mono">{repo.fullName}</span>
232 {repo.private && (
233 <span className="inline-flex items-center gap-1 text-xs text-faint">
234 <Lock size={11} /> Private
235 </span>
236 )}
237 {repo.linkedTo && <Pill>On g1t as {repo.linkedTo}</Pill>}
238 </span>
239 }
240 description={repo.description ?? undefined}
241 />
242 </div>
243 ))}
244 </div>
245 {repositories && repositories.total > repositories.perPage && (
246 <div className="flex justify-between text-sm">
247 {repositories.page > 1 ? (
248 <Link to={`${here}&installation=${installation.id}&page=${repositories.page - 1}`} className="text-muted hover:text-fg">
249 Previous
250 </Link>
251 ) : (
252 <span />
253 )}
254 {repositories.page * repositories.perPage < repositories.total && (
255 <Link to={`${here}&installation=${installation.id}&page=${repositories.page + 1}`} className="text-muted hover:text-fg">
256 Next
257 </Link>
258 )}
259 </div>
260 )}
261 </FieldSet>
262
263 <FieldSet>
264 <FieldLegend>How it comes across</FieldLegend>
265 <RadioGroup name="mode" defaultValue="import" aria-label="How it comes across" className="gap-3 sm:grid-cols-3">
266 {MODES.map((mode) => (
267 <RadioCard key={mode.id} value={mode.id} title={mode.title} description={mode.text} />
268 ))}
269 </RadioGroup>
270 </FieldSet>
271
272 <CheckboxOption
273 name="issues"
274 defaultChecked
275 label="Copy issues too"
276 description="Up to 200 per repository, with their labels, milestone and whether they are open. Pull requests stay on GitHub; their branches come across."
277 />
278
279 {actionData?.results && actionData.results.length > 0 && (
280 <ul className="space-y-1 rounded-md border border-line p-4 text-sm">
281 {actionData.results.map((result) => (
282 <li key={result.id}>
283 <span className="font-mono">{names.get(result.id) ?? result.id}</span>:{" "}
284 {result.ok ? (
285 <Link to={`/${result.repo}`} className="text-accent underline underline-offset-4">
286 {result.repo}
287 </Link>
288 ) : (
289 <span className="text-danger">{result.error}</span>
290 )}
291 </li>
292 ))}
293 </ul>
294 )}
295 <ErrorText>{actionData?.error}</ErrorText>
296 <div className="flex items-center gap-3">
297 <Button type="submit" variant="accent" disabled={busy}>
298 {busy ? "Bringing them across…" : "Bring to g1t"}
299 </Button>
300 <ButtonLink to={`/new?workspace=${workspace}`} variant="quiet">
301 Cancel
302 </ButtonLink>
303 </div>
304 </Form>
305 )}
306 </>
307 )}
308 </main>
309 );
310}