Skip to content
316 linesCodeBlameRaw

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";
Merge branch 'worktree-agent-a985c01f43538af01'2import { Form, Link, redirect } from "react-router";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3
4import type { Route } from "./+types/new-github";
5import { GithubMark } from "../components/github";
Merge branch 'worktree-agent-a985c01f43538af01'6import { ButtonLink, ErrorText, Pill, SubmitButton } from "../components/ui";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7import { 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);
Merge branch 'worktree-agent-a985c01f43538af01'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: [] };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66 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)}`}
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.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-fg-hover"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look128 >
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}`}
You choose how g1t looks: Auto follows your system, Light and Dark stay put, set from the account menu, the phone's You and help sheet, Settings → Account or the command palette; the page is drawn in your choice from the first byte, every colour has a light value that reads at 4.5:1 or better, code and diffs are highlighted for both, diagrams and the document editor follow along, the product tour and the g1t mark's avatar stay dark, and Dark is what you see until you choose; the workspaces guide says how.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-fg-hover"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look161 >
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} />
Merge branch 'worktree-agent-a985c01f43538af01'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 >
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look199 Remove
Merge branch 'worktree-agent-a985c01f43538af01'200 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201 </Form>
202 )}
203 </span>
204 </li>
205 ))}
206 </ul>
207 )}
Merge branch 'worktree-agent-a985c01f43538af01'208 <ErrorText>{actionData && "removeError" in actionData ? actionData.removeError : null}</ErrorText>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look209 </section>
210
211 {installation && (
212 <Form method="post" className="mt-10 space-y-8">
Merge branch 'worktree-agent-a985c01f43538af01'213 <input type="hidden" name="intent" value="import" />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look214 <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"
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily282 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."
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look283 />
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">
Merge branch 'worktree-agent-a985c01f43538af01'303 <SubmitButton variant="accent" pending="Bringing them across…" match={{ intent: "import" }}>
304 Bring to g1t
305 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look306 <ButtonLink to={`/new?workspace=${workspace}`} variant="quiet">
307 Cancel
308 </ButtonLink>
309 </div>
310 </Form>
311 )}
312 </>
313 )}
314 </main>
315 );
316}

This file's history is long; its oldest lines are credited to the oldest commit read.