Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

import-bulk.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

import-bulk.tsxBlame1012 lines · 1 contributor
14c3cc8Claude1/**
2 * Bulk GitHub import — "paste my org + token → import everything".
3 *
4 * Owner flow for migrating many products at once. Reuses the single-repo
5 * import logic from `src/lib/import-helper.ts` so the clone + DB insert
6 * code path is identical to `/import`.
7 *
8 * Token never leaves this process: it's read from the form body, passed
9 * to GitHub's API via `Authorization` header, and embedded in the git
10 * clone URL only at the moment of spawning `git`. Results never contain
11 * the token — `scrubSecrets()` in the helper redacts it before display.
12 */
13
14import { Hono } from "hono";
15import { Layout } from "../views/layout";
16import { softAuth, requireAuth } from "../middleware/auth";
17import type { AuthEnv } from "../middleware/auth";
18import {
19 sanitizeRepoName,
20 importOneRepo,
21 type ImportOneRepoResult,
22} from "../lib/import-helper";
23
24const importBulkRoutes = new Hono<AuthEnv>();
25
26importBulkRoutes.use("*", softAuth);
27
28// Hard limits to keep a single request bounded.
29const MAX_REPOS = 200;
30const MAX_REPO_SIZE_KB = 500 * 1024; // 500 MB in KB (GitHub reports size in KB)
31const GITHUB_PER_PAGE = 100;
32
33interface GitHubRepo {
34 name: string;
35 full_name: string;
36 description: string | null;
37 private: boolean;
38 clone_url: string;
39 default_branch: string;
40 fork: boolean;
41 size: number; // KB
42}
43
44type Visibility = "public" | "private" | "both";
45
7a99d47Claude46// ─── PAGE-SCOPED CSS ─────────────────────────────────────────
47// All classes prefixed with .import-bulk- so the block cannot bleed
48// into neighbouring routes. Mirrors the /import polish.
49const importBulkStyles = `
eed4684Claude50 .import-bulk-container { max-width: 1200px; margin: 0 auto; }
7a99d47Claude51
52 /* ─── Hero ─── */
53 .import-bulk-hero {
54 position: relative;
55 margin-bottom: var(--space-6);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .import-bulk-hero::before {
63 content: '';
64 position: absolute;
65 top: 0; left: 0; right: 0;
66 height: 2px;
67 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
68 opacity: 0.75;
69 pointer-events: none;
70 }
71 .import-bulk-hero-bg {
72 position: absolute;
73 inset: -20% -10% auto auto;
74 width: 360px; height: 360px;
75 pointer-events: none;
76 z-index: 0;
77 }
78 .import-bulk-hero-orb {
79 position: absolute;
80 inset: 0;
81 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
82 filter: blur(80px);
83 opacity: 0.7;
84 animation: importBulkHeroOrb 14s ease-in-out infinite;
85 }
86 @keyframes importBulkHeroOrb {
87 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
88 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
89 }
90 @media (prefers-reduced-motion: reduce) {
91 .import-bulk-hero-orb { animation: none; }
92 }
93 .import-bulk-hero-inner {
94 position: relative;
95 z-index: 1;
96 max-width: 640px;
97 }
98 .import-bulk-hero-eyebrow {
99 font-size: 13px;
100 color: var(--text-muted);
101 margin-bottom: var(--space-2);
102 letter-spacing: -0.005em;
103 }
104 .import-bulk-hero-eyebrow-dot {
105 display: inline-block;
106 width: 6px; height: 6px;
107 border-radius: 50%;
108 background: var(--accent);
109 box-shadow: 0 0 8px rgba(140,109,255,0.6);
110 margin-right: 8px;
111 vertical-align: 1px;
112 }
113 .import-bulk-hero-title {
114 font-size: clamp(28px, 4vw, 40px);
115 font-family: var(--font-display);
116 font-weight: 800;
117 letter-spacing: -0.028em;
118 line-height: 1.05;
119 margin: 0 0 var(--space-2);
120 color: var(--text-strong);
121 }
122 .import-bulk-hero-title .gradient-text {
123 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
124 -webkit-background-clip: text;
125 background-clip: text;
126 -webkit-text-fill-color: transparent;
127 color: transparent;
128 }
129 .import-bulk-hero-sub {
130 font-size: 15px;
131 color: var(--text-muted);
132 margin: 0;
133 line-height: 1.55;
134 }
135
136 /* ─── Banners ─── */
137 .import-bulk-banner {
138 position: relative;
139 padding: 14px 16px 14px 44px;
140 margin-bottom: var(--space-5);
141 border-radius: 12px;
142 border: 1px solid var(--border);
143 background: var(--bg-elevated);
144 font-size: 14px;
145 line-height: 1.5;
146 }
147 .import-bulk-banner::before {
148 content: '';
149 position: absolute;
150 left: 14px; top: 18px;
151 width: 14px; height: 14px;
152 border-radius: 50%;
153 }
154 .import-bulk-banner-error {
155 border-color: rgba(248, 81, 73, 0.32);
156 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
157 }
158 .import-bulk-banner-error::before {
159 background: radial-gradient(circle, #f85149 30%, transparent 70%);
160 box-shadow: 0 0 10px rgba(248,81,73,0.5);
161 }
162 .import-bulk-banner-title { font-weight: 600; color: var(--text-strong); }
163 .import-bulk-banner-detail { color: var(--text-muted); margin-top: 4px; font-size: 13.5px; }
164
165 /* ─── Section cards ─── */
166 .import-bulk-section {
167 background: var(--bg-elevated);
168 border: 1px solid var(--border);
169 border-radius: 14px;
170 margin-bottom: var(--space-5);
171 overflow: hidden;
172 }
173 .import-bulk-section-head {
174 padding: var(--space-4) var(--space-5) var(--space-3);
175 border-bottom: 1px solid var(--border);
176 }
177 .import-bulk-section-eyebrow {
178 font-size: 11px;
179 font-weight: 600;
180 letter-spacing: 0.08em;
181 text-transform: uppercase;
182 color: var(--accent);
183 margin-bottom: 6px;
184 }
185 .import-bulk-section-title {
186 font-family: var(--font-display);
187 font-size: 18px;
188 font-weight: 700;
189 letter-spacing: -0.018em;
190 margin: 0 0 4px;
191 color: var(--text-strong);
192 }
193 .import-bulk-section-desc {
194 font-size: 13.5px;
195 color: var(--text-muted);
196 margin: 0;
197 line-height: 1.5;
198 }
199 .import-bulk-section-body { padding: var(--space-4) var(--space-5); }
200
201 /* ─── Form ─── */
202 .import-bulk-field { margin-bottom: var(--space-4); }
203 .import-bulk-field:last-child { margin-bottom: 0; }
204 .import-bulk-field-label {
205 display: block;
206 font-size: 13px;
207 font-weight: 600;
208 color: var(--text-strong);
209 margin-bottom: 6px;
210 letter-spacing: -0.005em;
211 }
212 .import-bulk-field-label code {
213 font-size: 12px;
214 background: var(--bg-tertiary, var(--bg-secondary));
215 padding: 1px 5px;
216 border-radius: 4px;
217 font-weight: 500;
218 color: var(--text-muted);
219 }
220 .import-bulk-input,
221 .import-bulk-select {
222 width: 100%;
223 padding: 9px 12px;
224 font-size: 14px;
225 color: var(--text);
226 background: var(--bg);
227 border: 1px solid var(--border-strong);
228 border-radius: 8px;
229 outline: none;
230 transition: border-color 120ms ease, box-shadow 120ms ease;
231 font-family: var(--font-sans);
232 }
233 .import-bulk-input.is-mono { font-family: var(--font-mono); font-size: 13px; }
234 .import-bulk-input:focus,
235 .import-bulk-select:focus {
236 border-color: var(--accent);
237 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
238 }
239
240 /* ─── Toggle row (dry-run checkbox) ─── */
241 .import-bulk-toggle {
242 display: flex;
243 align-items: flex-start;
244 gap: 12px;
245 padding: 12px 14px;
246 border-radius: 10px;
247 border: 1px solid var(--border-subtle);
248 background: var(--bg-secondary);
249 transition: border-color 120ms ease;
250 }
251 .import-bulk-toggle:hover { border-color: var(--border); }
252 .import-bulk-toggle input[type="checkbox"] {
253 margin-top: 2px;
254 width: 16px; height: 16px;
255 accent-color: var(--accent);
256 cursor: pointer;
257 }
258 .import-bulk-toggle-text {
259 font-size: 13.5px;
260 color: var(--text);
261 line-height: 1.45;
262 }
263 .import-bulk-toggle-hint {
264 display: block;
265 margin-top: 3px;
266 font-size: 12.5px;
267 color: var(--text-muted);
268 }
269
270 .import-bulk-actions {
271 display: flex;
272 gap: 8px;
273 flex-wrap: wrap;
274 align-items: center;
275 margin-top: var(--space-4);
276 }
277
278 /* ─── Info / "what this does" panel ─── */
279 .import-bulk-info {
280 position: relative;
281 padding: 14px 16px 14px 18px;
282 margin-bottom: var(--space-5);
283 background: var(--bg-elevated);
284 border: 1px solid var(--border);
285 border-radius: 12px;
286 overflow: hidden;
287 }
288 .import-bulk-info::before {
289 content: '';
290 position: absolute;
291 left: 0; top: 0; bottom: 0;
292 width: 3px;
293 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
294 }
295 .import-bulk-info strong {
296 display: block;
297 font-family: var(--font-display);
298 font-weight: 700;
299 font-size: 14px;
300 color: var(--text-strong);
301 letter-spacing: -0.012em;
302 margin-bottom: 6px;
303 }
304 .import-bulk-info ul {
305 margin: 0;
306 padding-left: 18px;
307 font-size: 13px;
308 color: var(--text-muted);
309 line-height: 1.6;
310 }
311 .import-bulk-info code {
312 font-size: 12px;
313 background: var(--bg-secondary);
314 padding: 1px 5px;
315 border-radius: 4px;
316 color: var(--text);
317 }
318
319 /* ─── Summary strip (counts) ─── */
320 .import-bulk-summary {
321 display: flex;
322 align-items: center;
323 gap: var(--space-3);
324 flex-wrap: wrap;
325 padding: 12px 16px;
326 margin-bottom: var(--space-4);
327 background: var(--bg-elevated);
328 border: 1px solid var(--border);
329 border-radius: 12px;
330 font-size: 13.5px;
331 color: var(--text-muted);
332 }
333 .import-bulk-summary code {
334 font-size: 12.5px;
335 background: var(--bg-secondary);
336 padding: 1px 6px;
337 border-radius: 4px;
338 color: var(--text);
339 }
340 .import-bulk-summary-stat {
341 display: inline-flex;
342 align-items: center;
343 gap: 6px;
344 padding: 3px 10px;
345 border-radius: 9999px;
346 background: var(--bg-secondary);
347 border: 1px solid var(--border-subtle);
348 font-weight: 500;
349 color: var(--text-strong);
350 font-size: 12.5px;
351 }
352 .import-bulk-summary-stat .num { color: var(--accent); font-weight: 700; }
353
354 /* ─── Results table ─── */
355 .import-bulk-table-wrap {
356 background: var(--bg-elevated);
357 border: 1px solid var(--border);
358 border-radius: 14px;
359 overflow: hidden;
360 }
361 .import-bulk-table {
362 width: 100%;
363 border-collapse: collapse;
364 font-size: 13.5px;
365 }
366 .import-bulk-table thead tr {
367 background: var(--bg-secondary);
368 text-align: left;
369 }
370 .import-bulk-table th {
371 padding: 10px 14px;
372 border-bottom: 1px solid var(--border);
373 font-size: 11.5px;
374 font-weight: 600;
375 letter-spacing: 0.06em;
376 text-transform: uppercase;
377 color: var(--text-muted);
378 }
379 .import-bulk-table tbody tr {
380 transition: background 120ms ease;
381 }
382 .import-bulk-table tbody tr:hover {
383 background: rgba(255,255,255,0.018);
384 }
385 .import-bulk-table td {
386 padding: 9px 14px;
387 border-bottom: 1px solid var(--border-subtle);
388 vertical-align: middle;
389 }
390 .import-bulk-table tbody tr:last-child td { border-bottom: 0; }
391 .import-bulk-table-name {
392 font-family: var(--font-mono);
393 color: var(--text-strong);
394 font-size: 13px;
395 }
396 .import-bulk-table-notes {
397 color: var(--text-muted);
398 font-size: 12.5px;
399 }
400
401 /* ─── Status badge ─── */
402 .import-bulk-badge {
403 display: inline-flex;
404 align-items: center;
405 gap: 6px;
406 padding: 2px 9px;
407 border-radius: 9999px;
408 font-size: 12px;
409 font-weight: 600;
410 letter-spacing: -0.005em;
411 }
412 .import-bulk-badge-dot {
413 width: 6px; height: 6px;
414 border-radius: 50%;
415 flex-shrink: 0;
416 }
417 .import-bulk-badge-success {
418 color: #3fb950;
419 background: rgba(63,185,80,0.13);
420 border: 1px solid rgba(63,185,80,0.28);
421 }
422 .import-bulk-badge-success .import-bulk-badge-dot {
423 background: #3fb950;
424 box-shadow: 0 0 6px rgba(63,185,80,0.7);
425 }
426 .import-bulk-badge-warn {
427 color: #f0b429;
428 background: rgba(240,180,41,0.12);
429 border: 1px solid rgba(240,180,41,0.28);
430 }
431 .import-bulk-badge-warn .import-bulk-badge-dot { background: #f0b429; }
432 .import-bulk-badge-info {
433 color: #58a6ff;
434 background: rgba(88,166,255,0.12);
435 border: 1px solid rgba(88,166,255,0.28);
436 }
437 .import-bulk-badge-info .import-bulk-badge-dot { background: #58a6ff; }
438 .import-bulk-badge-error {
439 color: #f85149;
440 background: rgba(248,81,73,0.12);
441 border: 1px solid rgba(248,81,73,0.28);
442 }
443 .import-bulk-badge-error .import-bulk-badge-dot { background: #f85149; }
444
445 .import-bulk-empty {
446 padding: 18px;
447 text-align: center;
448 color: var(--text-muted);
449 font-size: 13.5px;
450 }
451
452 .import-bulk-callout {
453 margin-top: var(--space-4);
454 padding: 12px 14px;
455 background: var(--bg-elevated);
456 border: 1px solid var(--border);
457 border-left: 3px solid var(--accent);
458 border-radius: 10px;
459 font-size: 13px;
460 color: var(--text-muted);
461 }
462 .import-bulk-callout em { color: var(--text-strong); font-style: normal; font-weight: 600; }
463
464 .import-bulk-subhead {
465 font-family: var(--font-display);
466 font-size: 16px;
467 font-weight: 700;
468 letter-spacing: -0.014em;
469 color: var(--text-strong);
470 margin: var(--space-5) 0 var(--space-3);
471 }
472`;
473
14c3cc8Claude474/**
475 * Paginate the GitHub "list org repos" endpoint. Caps at MAX_REPOS so a
476 * single request can't fan out indefinitely. Throws on non-2xx so the
477 * caller can surface a friendly error.
478 */
479async function fetchOrgRepos(
480 org: string,
481 token: string
482): Promise<GitHubRepo[]> {
483 const headers: Record<string, string> = {
484 Accept: "application/vnd.github.v3+json",
485 "User-Agent": "gluecron/1.0",
486 Authorization: `Bearer ${token}`,
487 };
488
489 const repos: GitHubRepo[] = [];
490 let page = 1;
491 while (repos.length < MAX_REPOS) {
492 const url = `https://api.github.com/orgs/${encodeURIComponent(
493 org
494 )}/repos?per_page=${GITHUB_PER_PAGE}&page=${page}&type=all`;
495 const res = await fetch(url, { headers });
496 if (!res.ok) {
609a27aClaude497 // Distinguish the three real failure modes the operator can act on
498 // instead of dumping a raw GitHub body that hides the actual cause.
499 let detail = "";
500 try {
501 const body = await res.json();
502 detail = body?.message ? ` — ${String(body.message)}` : "";
503 } catch {
504 /* non-JSON body */
505 }
506 if (res.status === 404) {
507 throw new Error(
508 `Organization "${org}" not found on GitHub (404)${detail}. Check the spelling.`
509 );
510 }
511 if (res.status === 401) {
512 throw new Error(
513 `GitHub rejected the token (401)${detail}. The PAT is invalid or expired — mint a new one at github.com/settings/tokens.`
514 );
515 }
516 if (res.status === 403) {
517 throw new Error(
518 `GitHub forbade the request (403)${detail}. Your token likely lacks the 'read:org' / 'repo' scope, or you've hit the rate limit. Wait an hour or mint a token with the right scopes.`
519 );
520 }
521 throw new Error(`GitHub API error (${res.status})${detail}`);
522 }
523 let batch: GitHubRepo[];
524 try {
525 batch = (await res.json()) as GitHubRepo[];
526 } catch (err) {
527 // A malformed batch shouldn't kill the whole bulk import. Log it
528 // (without token leak) and stop pagination — the operator will see
529 // whatever we managed to collect so far.
530 console.error(
531 `[import-bulk] non-JSON response on page ${page} for org ${org}:`,
532 err instanceof Error ? err.message : err
533 );
534 break;
14c3cc8Claude535 }
536 if (!Array.isArray(batch) || batch.length === 0) break;
537 repos.push(...batch);
538 if (batch.length < GITHUB_PER_PAGE) break;
539 page++;
540 if (page > 10) break; // hard page ceiling: 1000 entries, we cap earlier anyway
541 }
542 return repos.slice(0, MAX_REPOS);
543}
544
545function matchesVisibility(repo: GitHubRepo, v: Visibility): boolean {
546 if (v === "both") return true;
547 if (v === "public") return repo.private === false;
548 if (v === "private") return repo.private === true;
549 return true;
550}
551
552// ─── FORM PAGE ───────────────────────────────────────────────
553
554importBulkRoutes.get("/import/bulk", requireAuth, async (c) => {
555 const user = c.get("user")!;
556 const error = c.req.query("error");
557
558 return c.html(
559 <Layout title="Bulk import from GitHub" user={user}>
7a99d47Claude560 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
561 <div class="import-bulk-container">
562 {/* ─── Hero ─── */}
563 <div class="import-bulk-hero">
564 <div class="import-bulk-hero-bg" aria-hidden="true">
565 <div class="import-bulk-hero-orb" />
566 </div>
567 <div class="import-bulk-hero-inner">
568 <div class="import-bulk-hero-eyebrow">
569 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
adf5e18Claude570 GitHub migration
7a99d47Claude571 </div>
572 <h1 class="import-bulk-hero-title">
adf5e18Claude573 Migrate your GitHub org{" "}
574 <span class="gradient-text">in 60 seconds</span>.
7a99d47Claude575 </h1>
576 <p class="import-bulk-hero-sub">
adf5e18Claude577 All repos, issues, and history — paste a GitHub org + personal access
578 token and Gluecron imports everything into your namespace
579 sequentially, with per-repo status so one failure can't abort the
580 batch.
7a99d47Claude581 </p>
582 </div>
583 </div>
584
585 {error && (
586 <div class="import-bulk-banner import-bulk-banner-error" role="alert">
587 <div class="import-bulk-banner-title">Bulk import didn't run</div>
588 <div class="import-bulk-banner-detail">
589 {decodeURIComponent(error)}
590 </div>
591 </div>
592 )}
593
594 {/* ─── What this does (info panel) ─── */}
595 <div class="import-bulk-info">
596 <strong>What this does</strong>
597 <ul>
14c3cc8Claude598 <li>
599 Lists every repo in the org via the GitHub API
600 (<code>/orgs/{"{org}"}/repos</code>, paginated).
601 </li>
602 <li>
603 Clones each one as a bare mirror into your gluecron account
604 (<code>{user.username}/{"{repo}"}</code>).
605 </li>
606 <li>
607 Reports per-repo success / failure / skipped-if-exists at
608 the end. One failure does not abort the batch.
609 </li>
610 <li>
7a99d47Claude611 Hard caps: <code>{MAX_REPOS}</code> repos per run, 500MB per repo.
14c3cc8Claude612 </li>
613 </ul>
614 </div>
615
7a99d47Claude616 {/* ─── Form section ─── */}
617 <div class="import-bulk-section">
618 <div class="import-bulk-section-head">
619 <div class="import-bulk-section-eyebrow">Configure</div>
620 <h2 class="import-bulk-section-title">Org + token</h2>
621 <p class="import-bulk-section-desc">
622 Token is used only in this request — never stored.
623 </p>
14c3cc8Claude624 </div>
7a99d47Claude625 <div class="import-bulk-section-body">
626 <form method="post" action="/import/bulk">
627 <div class="import-bulk-field">
628 <label class="import-bulk-field-label">GitHub org</label>
629 <input
630 type="text"
631 name="githubOrg"
632 required
633 placeholder="my-company"
634 aria-label="GitHub org"
635 class="import-bulk-input"
636 />
637 </div>
638
639 <div class="import-bulk-field">
640 <label class="import-bulk-field-label">
641 Personal access token <code>repo:read</code> scope
642 </label>
643 <input
644 type="password"
645 name="githubToken"
646 required
647 placeholder="ghp_xxxxxxxxxxxx"
648 autocomplete="off"
649 aria-label="GitHub personal access token"
650 class="import-bulk-input is-mono"
651 />
652 </div>
653
654 <div class="import-bulk-field">
655 <label class="import-bulk-field-label">Visibility filter</label>
656 <select
657 name="visibility"
658 aria-label="Visibility filter"
659 class="import-bulk-select"
660 >
661 <option value="both" selected>Both (public + private)</option>
662 <option value="public">Public only</option>
663 <option value="private">Private only</option>
664 </select>
665 </div>
666
667 <div class="import-bulk-field">
668 <label class="import-bulk-toggle">
669 <input type="checkbox" name="dryRun" value="1" checked />
670 <span class="import-bulk-toggle-text">
671 Dry run — preview the list without cloning
672 <span class="import-bulk-toggle-hint">
673 Recommended for the first pass. Uncheck once the preview
674 looks right.
675 </span>
676 </span>
677 </label>
678 </div>
679
680 <div class="import-bulk-actions">
681 <button type="submit" class="btn btn-primary">
682 Run bulk import
683 </button>
684 <a href="/import" class="btn">
685 Back to /import
686 </a>
687 </div>
688 </form>
14c3cc8Claude689 </div>
7a99d47Claude690 </div>
14c3cc8Claude691 </div>
692 </Layout>
693 );
694});
695
696// ─── POST HANDLER ────────────────────────────────────────────
697
698importBulkRoutes.post("/import/bulk", requireAuth, async (c) => {
699 const user = c.get("user")!;
700 const body = await c.req.parseBody();
701
702 const githubOrg = String(body.githubOrg || "").trim();
703 const githubToken = String(body.githubToken || "").trim();
704 const visibilityRaw = String(body.visibility || "both").trim();
705 const visibility: Visibility =
706 visibilityRaw === "public" || visibilityRaw === "private"
707 ? (visibilityRaw as Visibility)
708 : "both";
709 const dryRun = Boolean(body.dryRun); // unchecked box = undefined = false
710
711 if (!githubOrg) {
712 return c.redirect("/import/bulk?error=GitHub+org+is+required");
713 }
714 if (!githubToken) {
715 return c.redirect(
716 "/import/bulk?error=GitHub+token+is+required+%28repo%3Aread+scope%29"
717 );
718 }
719
720 // Validate the token has at least read access before we start cloning.
721 // `GET /user` is the cheapest call that requires a valid token. We also
722 // inspect the `X-OAuth-Scopes` header so we can warn early if the token
723 // is missing `repo`/`repo:read`.
724 try {
725 const userRes = await fetch("https://api.github.com/user", {
726 headers: {
727 Accept: "application/vnd.github.v3+json",
728 "User-Agent": "gluecron/1.0",
729 Authorization: `Bearer ${githubToken}`,
730 },
731 });
732 if (!userRes.ok) {
733 return c.redirect(
734 `/import/bulk?error=${encodeURIComponent(
735 `Invalid GitHub token (${userRes.status}). Check scope repo:read.`
736 )}`
737 );
738 }
739 const scopes = (userRes.headers.get("x-oauth-scopes") || "").toLowerCase();
740 if (
741 scopes &&
742 !scopes.includes("repo") &&
743 !scopes.includes("public_repo")
744 ) {
745 return c.redirect(
746 `/import/bulk?error=${encodeURIComponent(
747 "Token is missing repo:read scope. Regenerate with repo (or public_repo) checked."
748 )}`
749 );
750 }
751 } catch (err) {
752 // Network-level failure talking to GitHub. Don't leak err details.
753 return c.redirect(
754 "/import/bulk?error=Could+not+reach+GitHub+to+validate+the+token"
755 );
756 }
757
758 // Pull the repo list.
759 let allRepos: GitHubRepo[];
760 try {
761 allRepos = await fetchOrgRepos(githubOrg, githubToken);
762 } catch (err) {
763 const msg = (err as Error).message || "Unknown error";
764 return c.redirect(
765 `/import/bulk?error=${encodeURIComponent(msg).slice(0, 400)}`
766 );
767 }
768
769 if (allRepos.length === 0) {
770 return c.redirect(
771 `/import/bulk?error=${encodeURIComponent(
772 `No repos visible for org "${githubOrg}" with this token.`
773 )}`
774 );
775 }
776
777 // Apply visibility filter + size cap; track why things were skipped.
778 const candidates: GitHubRepo[] = [];
779 const oversized: { name: string; sizeKB: number }[] = [];
780 for (const r of allRepos) {
781 if (!matchesVisibility(r, visibility)) continue;
782 if (typeof r.size === "number" && r.size > MAX_REPO_SIZE_KB) {
783 oversized.push({ name: r.name, sizeKB: r.size });
784 continue;
785 }
786 candidates.push(r);
787 }
788
789 // Dry run: render a preview + counts, never touch disk or DB.
790 if (dryRun) {
791 return c.html(
792 <Layout title="Bulk import preview" user={user}>
7a99d47Claude793 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
794 <div class="import-bulk-container">
795 <div class="import-bulk-hero">
796 <div class="import-bulk-hero-bg" aria-hidden="true">
797 <div class="import-bulk-hero-orb" />
798 </div>
799 <div class="import-bulk-hero-inner">
800 <div class="import-bulk-hero-eyebrow">
801 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
802 Preview only — nothing imported yet
803 </div>
804 <h1 class="import-bulk-hero-title">
805 Bulk import{" "}
806 <span class="gradient-text">dry run</span>.
807 </h1>
808 <p class="import-bulk-hero-sub">
809 This is what gluecron would clone from{" "}
810 <code>{githubOrg}</code> when you uncheck the dry-run box.
811 </p>
812 </div>
813 </div>
814
815 <div class="import-bulk-summary">
816 <span>
817 Org <code>{githubOrg}</code>
818 </span>
819 <span>
820 Visibility <code>{visibility}</code>
821 </span>
822 <span class="import-bulk-summary-stat">
823 <span class="num">{candidates.length}</span> to import
824 </span>
825 {oversized.length > 0 && (
826 <span class="import-bulk-summary-stat">
827 <span class="num">{oversized.length}</span> skipped (&gt;500MB)
828 </span>
829 )}
830 </div>
14c3cc8Claude831
832 <ResultsTable
833 rows={candidates.map((r) => ({
834 name: sanitizeRepoName(r.name),
835 status: "dry-run",
836 notes: `${r.private ? "private" : "public"} · ${(
837 r.size / 1024
838 ).toFixed(1)} MB`,
839 }))}
840 />
841
842 {oversized.length > 0 && (
843 <>
7a99d47Claude844 <h3 class="import-bulk-subhead">Skipped — over 500MB</h3>
14c3cc8Claude845 <ResultsTable
846 rows={oversized.map((r) => ({
847 name: sanitizeRepoName(r.name),
848 status: "too-large",
849 notes: `${(r.sizeKB / 1024).toFixed(1)} MB`,
850 }))}
851 />
852 </>
853 )}
854
7a99d47Claude855 <div class="import-bulk-callout">
856 Looks good? Go back and uncheck <em>Dry run</em> to actually import.
14c3cc8Claude857 </div>
858
7a99d47Claude859 <div class="import-bulk-actions">
14c3cc8Claude860 <a href="/import/bulk" class="btn btn-primary">
861 Back to form
862 </a>
863 </div>
864 </div>
865 </Layout>
866 );
867 }
868
869 // Real run: clone each candidate sequentially. Collect results.
870 const results: ImportOneRepoResult[] = [];
871 for (const r of candidates) {
872 // eslint-disable-next-line no-await-in-loop
873 const res = await importOneRepo({
874 cloneUrl: r.clone_url,
875 targetName: r.name,
876 ownerId: user.id,
877 ownerUsername: user.username,
878 token: githubToken,
879 description: r.description,
880 isPrivate: r.private,
881 defaultBranch: r.default_branch,
882 });
883 results.push(res);
884 }
885
886 for (const o of oversized) {
887 results.push({
888 status: "failed",
889 name: sanitizeRepoName(o.name),
890 notes: `Skipped — over 500MB (${(o.sizeKB / 1024).toFixed(1)} MB)`,
891 });
892 }
893
894 const counts = results.reduce(
895 (acc, r) => {
896 acc[r.status] = (acc[r.status] || 0) + 1;
897 return acc;
898 },
899 {} as Record<string, number>
900 );
901
902 return c.html(
903 <Layout title="Bulk import results" user={user}>
7a99d47Claude904 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
905 <div class="import-bulk-container">
906 <div class="import-bulk-hero">
907 <div class="import-bulk-hero-bg" aria-hidden="true">
908 <div class="import-bulk-hero-orb" />
909 </div>
910 <div class="import-bulk-hero-inner">
911 <div class="import-bulk-hero-eyebrow">
912 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
913 Bulk migration complete
914 </div>
915 <h1 class="import-bulk-hero-title">
916 Bulk import{" "}
917 <span class="gradient-text">results</span>.
918 </h1>
919 <p class="import-bulk-hero-sub">
920 From <code>{githubOrg}</code> into{" "}
921 <code>{user.username}</code>.
922 </p>
923 </div>
924 </div>
925
926 <div class="import-bulk-summary">
927 <span class="import-bulk-summary-stat">
928 <span class="num">{counts["success"] || 0}</span> imported
929 </span>
930 <span class="import-bulk-summary-stat">
931 <span class="num">{counts["skipped-exists"] || 0}</span> skipped
932 </span>
933 <span class="import-bulk-summary-stat">
934 <span class="num">{counts["failed"] || 0}</span> failed
935 </span>
936 </div>
14c3cc8Claude937
938 <ResultsTable rows={results} />
939
7a99d47Claude940 <div class="import-bulk-actions">
14c3cc8Claude941 <a href={`/${user.username}`} class="btn btn-primary">
942 View my repositories
943 </a>
944 <a href="/import/bulk" class="btn">
945 Run another import
946 </a>
947 </div>
948 </div>
949 </Layout>
950 );
951});
952
953// ─── COMPONENTS ──────────────────────────────────────────────
954
955function ResultsTable({
956 rows,
957}: {
958 rows: { name: string; status: string; notes: string }[];
959}) {
960 if (rows.length === 0) {
961 return (
7a99d47Claude962 <div class="import-bulk-table-wrap">
963 <div class="import-bulk-empty">No rows.</div>
14c3cc8Claude964 </div>
965 );
966 }
967 return (
7a99d47Claude968 <div class="import-bulk-table-wrap">
969 <table class="import-bulk-table">
970 <thead>
14c3cc8Claude971 <tr>
7a99d47Claude972 <th>Name</th>
973 <th>Status</th>
974 <th>Notes</th>
14c3cc8Claude975 </tr>
7a99d47Claude976 </thead>
977 <tbody>
978 {rows.map((r) => (
979 <tr>
980 <td class="import-bulk-table-name">{r.name}</td>
981 <td>
982 <StatusBadge status={r.status} />
983 </td>
984 <td class="import-bulk-table-notes">{r.notes}</td>
985 </tr>
986 ))}
987 </tbody>
988 </table>
989 </div>
14c3cc8Claude990 );
991}
992
993function StatusBadge({ status }: { status: string }) {
7a99d47Claude994 const cls =
14c3cc8Claude995 status === "success"
7a99d47Claude996 ? "import-bulk-badge-success"
14c3cc8Claude997 : status === "skipped-exists"
7a99d47Claude998 ? "import-bulk-badge-warn"
14c3cc8Claude999 : status === "dry-run"
7a99d47Claude1000 ? "import-bulk-badge-info"
14c3cc8Claude1001 : status === "too-large"
7a99d47Claude1002 ? "import-bulk-badge-warn"
1003 : "import-bulk-badge-error";
14c3cc8Claude1004 return (
7a99d47Claude1005 <span class={`import-bulk-badge ${cls}`}>
1006 <span class="import-bulk-badge-dot" aria-hidden="true" />
14c3cc8Claude1007 {status}
1008 </span>
1009 );
1010}
1011
1012export default importBulkRoutes;