Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame1011 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 = `
50 .import-bulk-container { max-width: 880px; margin: 0 auto; }
51
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" />
570 Bulk migration
571 </div>
572 <h1 class="import-bulk-hero-title">
573 Import{" "}
574 <span class="gradient-text">many at once</span>.
575 </h1>
576 <p class="import-bulk-hero-sub">
577 Paste a GitHub org + personal access token. Gluecron clones every
578 repo into your namespace as a mirror — sequentially, with per-repo
579 status so one failure can't abort the batch.
580 </p>
581 </div>
582 </div>
583
584 {error && (
585 <div class="import-bulk-banner import-bulk-banner-error" role="alert">
586 <div class="import-bulk-banner-title">Bulk import didn't run</div>
587 <div class="import-bulk-banner-detail">
588 {decodeURIComponent(error)}
589 </div>
590 </div>
591 )}
592
593 {/* ─── What this does (info panel) ─── */}
594 <div class="import-bulk-info">
595 <strong>What this does</strong>
596 <ul>
14c3cc8Claude597 <li>
598 Lists every repo in the org via the GitHub API
599 (<code>/orgs/{"{org}"}/repos</code>, paginated).
600 </li>
601 <li>
602 Clones each one as a bare mirror into your gluecron account
603 (<code>{user.username}/{"{repo}"}</code>).
604 </li>
605 <li>
606 Reports per-repo success / failure / skipped-if-exists at
607 the end. One failure does not abort the batch.
608 </li>
609 <li>
7a99d47Claude610 Hard caps: <code>{MAX_REPOS}</code> repos per run, 500MB per repo.
14c3cc8Claude611 </li>
612 </ul>
613 </div>
614
7a99d47Claude615 {/* ─── Form section ─── */}
616 <div class="import-bulk-section">
617 <div class="import-bulk-section-head">
618 <div class="import-bulk-section-eyebrow">Configure</div>
619 <h2 class="import-bulk-section-title">Org + token</h2>
620 <p class="import-bulk-section-desc">
621 Token is used only in this request — never stored.
622 </p>
14c3cc8Claude623 </div>
7a99d47Claude624 <div class="import-bulk-section-body">
625 <form method="post" action="/import/bulk">
626 <div class="import-bulk-field">
627 <label class="import-bulk-field-label">GitHub org</label>
628 <input
629 type="text"
630 name="githubOrg"
631 required
632 placeholder="my-company"
633 aria-label="GitHub org"
634 class="import-bulk-input"
635 />
636 </div>
637
638 <div class="import-bulk-field">
639 <label class="import-bulk-field-label">
640 Personal access token <code>repo:read</code> scope
641 </label>
642 <input
643 type="password"
644 name="githubToken"
645 required
646 placeholder="ghp_xxxxxxxxxxxx"
647 autocomplete="off"
648 aria-label="GitHub personal access token"
649 class="import-bulk-input is-mono"
650 />
651 </div>
652
653 <div class="import-bulk-field">
654 <label class="import-bulk-field-label">Visibility filter</label>
655 <select
656 name="visibility"
657 aria-label="Visibility filter"
658 class="import-bulk-select"
659 >
660 <option value="both" selected>Both (public + private)</option>
661 <option value="public">Public only</option>
662 <option value="private">Private only</option>
663 </select>
664 </div>
665
666 <div class="import-bulk-field">
667 <label class="import-bulk-toggle">
668 <input type="checkbox" name="dryRun" value="1" checked />
669 <span class="import-bulk-toggle-text">
670 Dry run — preview the list without cloning
671 <span class="import-bulk-toggle-hint">
672 Recommended for the first pass. Uncheck once the preview
673 looks right.
674 </span>
675 </span>
676 </label>
677 </div>
678
679 <div class="import-bulk-actions">
680 <button type="submit" class="btn btn-primary">
681 Run bulk import
682 </button>
683 <a href="/import" class="btn">
684 Back to /import
685 </a>
686 </div>
687 </form>
14c3cc8Claude688 </div>
7a99d47Claude689 </div>
14c3cc8Claude690 </div>
691 </Layout>
692 );
693});
694
695// ─── POST HANDLER ────────────────────────────────────────────
696
697importBulkRoutes.post("/import/bulk", requireAuth, async (c) => {
698 const user = c.get("user")!;
699 const body = await c.req.parseBody();
700
701 const githubOrg = String(body.githubOrg || "").trim();
702 const githubToken = String(body.githubToken || "").trim();
703 const visibilityRaw = String(body.visibility || "both").trim();
704 const visibility: Visibility =
705 visibilityRaw === "public" || visibilityRaw === "private"
706 ? (visibilityRaw as Visibility)
707 : "both";
708 const dryRun = Boolean(body.dryRun); // unchecked box = undefined = false
709
710 if (!githubOrg) {
711 return c.redirect("/import/bulk?error=GitHub+org+is+required");
712 }
713 if (!githubToken) {
714 return c.redirect(
715 "/import/bulk?error=GitHub+token+is+required+%28repo%3Aread+scope%29"
716 );
717 }
718
719 // Validate the token has at least read access before we start cloning.
720 // `GET /user` is the cheapest call that requires a valid token. We also
721 // inspect the `X-OAuth-Scopes` header so we can warn early if the token
722 // is missing `repo`/`repo:read`.
723 try {
724 const userRes = await fetch("https://api.github.com/user", {
725 headers: {
726 Accept: "application/vnd.github.v3+json",
727 "User-Agent": "gluecron/1.0",
728 Authorization: `Bearer ${githubToken}`,
729 },
730 });
731 if (!userRes.ok) {
732 return c.redirect(
733 `/import/bulk?error=${encodeURIComponent(
734 `Invalid GitHub token (${userRes.status}). Check scope repo:read.`
735 )}`
736 );
737 }
738 const scopes = (userRes.headers.get("x-oauth-scopes") || "").toLowerCase();
739 if (
740 scopes &&
741 !scopes.includes("repo") &&
742 !scopes.includes("public_repo")
743 ) {
744 return c.redirect(
745 `/import/bulk?error=${encodeURIComponent(
746 "Token is missing repo:read scope. Regenerate with repo (or public_repo) checked."
747 )}`
748 );
749 }
750 } catch (err) {
751 // Network-level failure talking to GitHub. Don't leak err details.
752 return c.redirect(
753 "/import/bulk?error=Could+not+reach+GitHub+to+validate+the+token"
754 );
755 }
756
757 // Pull the repo list.
758 let allRepos: GitHubRepo[];
759 try {
760 allRepos = await fetchOrgRepos(githubOrg, githubToken);
761 } catch (err) {
762 const msg = (err as Error).message || "Unknown error";
763 return c.redirect(
764 `/import/bulk?error=${encodeURIComponent(msg).slice(0, 400)}`
765 );
766 }
767
768 if (allRepos.length === 0) {
769 return c.redirect(
770 `/import/bulk?error=${encodeURIComponent(
771 `No repos visible for org "${githubOrg}" with this token.`
772 )}`
773 );
774 }
775
776 // Apply visibility filter + size cap; track why things were skipped.
777 const candidates: GitHubRepo[] = [];
778 const oversized: { name: string; sizeKB: number }[] = [];
779 for (const r of allRepos) {
780 if (!matchesVisibility(r, visibility)) continue;
781 if (typeof r.size === "number" && r.size > MAX_REPO_SIZE_KB) {
782 oversized.push({ name: r.name, sizeKB: r.size });
783 continue;
784 }
785 candidates.push(r);
786 }
787
788 // Dry run: render a preview + counts, never touch disk or DB.
789 if (dryRun) {
790 return c.html(
791 <Layout title="Bulk import preview" user={user}>
7a99d47Claude792 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
793 <div class="import-bulk-container">
794 <div class="import-bulk-hero">
795 <div class="import-bulk-hero-bg" aria-hidden="true">
796 <div class="import-bulk-hero-orb" />
797 </div>
798 <div class="import-bulk-hero-inner">
799 <div class="import-bulk-hero-eyebrow">
800 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
801 Preview only — nothing imported yet
802 </div>
803 <h1 class="import-bulk-hero-title">
804 Bulk import{" "}
805 <span class="gradient-text">dry run</span>.
806 </h1>
807 <p class="import-bulk-hero-sub">
808 This is what gluecron would clone from{" "}
809 <code>{githubOrg}</code> when you uncheck the dry-run box.
810 </p>
811 </div>
812 </div>
813
814 <div class="import-bulk-summary">
815 <span>
816 Org <code>{githubOrg}</code>
817 </span>
818 <span>
819 Visibility <code>{visibility}</code>
820 </span>
821 <span class="import-bulk-summary-stat">
822 <span class="num">{candidates.length}</span> to import
823 </span>
824 {oversized.length > 0 && (
825 <span class="import-bulk-summary-stat">
826 <span class="num">{oversized.length}</span> skipped (&gt;500MB)
827 </span>
828 )}
829 </div>
14c3cc8Claude830
831 <ResultsTable
832 rows={candidates.map((r) => ({
833 name: sanitizeRepoName(r.name),
834 status: "dry-run",
835 notes: `${r.private ? "private" : "public"} · ${(
836 r.size / 1024
837 ).toFixed(1)} MB`,
838 }))}
839 />
840
841 {oversized.length > 0 && (
842 <>
7a99d47Claude843 <h3 class="import-bulk-subhead">Skipped — over 500MB</h3>
14c3cc8Claude844 <ResultsTable
845 rows={oversized.map((r) => ({
846 name: sanitizeRepoName(r.name),
847 status: "too-large",
848 notes: `${(r.sizeKB / 1024).toFixed(1)} MB`,
849 }))}
850 />
851 </>
852 )}
853
7a99d47Claude854 <div class="import-bulk-callout">
855 Looks good? Go back and uncheck <em>Dry run</em> to actually import.
14c3cc8Claude856 </div>
857
7a99d47Claude858 <div class="import-bulk-actions">
14c3cc8Claude859 <a href="/import/bulk" class="btn btn-primary">
860 Back to form
861 </a>
862 </div>
863 </div>
864 </Layout>
865 );
866 }
867
868 // Real run: clone each candidate sequentially. Collect results.
869 const results: ImportOneRepoResult[] = [];
870 for (const r of candidates) {
871 // eslint-disable-next-line no-await-in-loop
872 const res = await importOneRepo({
873 cloneUrl: r.clone_url,
874 targetName: r.name,
875 ownerId: user.id,
876 ownerUsername: user.username,
877 token: githubToken,
878 description: r.description,
879 isPrivate: r.private,
880 defaultBranch: r.default_branch,
881 });
882 results.push(res);
883 }
884
885 for (const o of oversized) {
886 results.push({
887 status: "failed",
888 name: sanitizeRepoName(o.name),
889 notes: `Skipped — over 500MB (${(o.sizeKB / 1024).toFixed(1)} MB)`,
890 });
891 }
892
893 const counts = results.reduce(
894 (acc, r) => {
895 acc[r.status] = (acc[r.status] || 0) + 1;
896 return acc;
897 },
898 {} as Record<string, number>
899 );
900
901 return c.html(
902 <Layout title="Bulk import results" user={user}>
7a99d47Claude903 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
904 <div class="import-bulk-container">
905 <div class="import-bulk-hero">
906 <div class="import-bulk-hero-bg" aria-hidden="true">
907 <div class="import-bulk-hero-orb" />
908 </div>
909 <div class="import-bulk-hero-inner">
910 <div class="import-bulk-hero-eyebrow">
911 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
912 Bulk migration complete
913 </div>
914 <h1 class="import-bulk-hero-title">
915 Bulk import{" "}
916 <span class="gradient-text">results</span>.
917 </h1>
918 <p class="import-bulk-hero-sub">
919 From <code>{githubOrg}</code> into{" "}
920 <code>{user.username}</code>.
921 </p>
922 </div>
923 </div>
924
925 <div class="import-bulk-summary">
926 <span class="import-bulk-summary-stat">
927 <span class="num">{counts["success"] || 0}</span> imported
928 </span>
929 <span class="import-bulk-summary-stat">
930 <span class="num">{counts["skipped-exists"] || 0}</span> skipped
931 </span>
932 <span class="import-bulk-summary-stat">
933 <span class="num">{counts["failed"] || 0}</span> failed
934 </span>
935 </div>
14c3cc8Claude936
937 <ResultsTable rows={results} />
938
7a99d47Claude939 <div class="import-bulk-actions">
14c3cc8Claude940 <a href={`/${user.username}`} class="btn btn-primary">
941 View my repositories
942 </a>
943 <a href="/import/bulk" class="btn">
944 Run another import
945 </a>
946 </div>
947 </div>
948 </Layout>
949 );
950});
951
952// ─── COMPONENTS ──────────────────────────────────────────────
953
954function ResultsTable({
955 rows,
956}: {
957 rows: { name: string; status: string; notes: string }[];
958}) {
959 if (rows.length === 0) {
960 return (
7a99d47Claude961 <div class="import-bulk-table-wrap">
962 <div class="import-bulk-empty">No rows.</div>
14c3cc8Claude963 </div>
964 );
965 }
966 return (
7a99d47Claude967 <div class="import-bulk-table-wrap">
968 <table class="import-bulk-table">
969 <thead>
14c3cc8Claude970 <tr>
7a99d47Claude971 <th>Name</th>
972 <th>Status</th>
973 <th>Notes</th>
14c3cc8Claude974 </tr>
7a99d47Claude975 </thead>
976 <tbody>
977 {rows.map((r) => (
978 <tr>
979 <td class="import-bulk-table-name">{r.name}</td>
980 <td>
981 <StatusBadge status={r.status} />
982 </td>
983 <td class="import-bulk-table-notes">{r.notes}</td>
984 </tr>
985 ))}
986 </tbody>
987 </table>
988 </div>
14c3cc8Claude989 );
990}
991
992function StatusBadge({ status }: { status: string }) {
7a99d47Claude993 const cls =
14c3cc8Claude994 status === "success"
7a99d47Claude995 ? "import-bulk-badge-success"
14c3cc8Claude996 : status === "skipped-exists"
7a99d47Claude997 ? "import-bulk-badge-warn"
14c3cc8Claude998 : status === "dry-run"
7a99d47Claude999 ? "import-bulk-badge-info"
14c3cc8Claude1000 : status === "too-large"
7a99d47Claude1001 ? "import-bulk-badge-warn"
1002 : "import-bulk-badge-error";
14c3cc8Claude1003 return (
7a99d47Claude1004 <span class={`import-bulk-badge ${cls}`}>
1005 <span class="import-bulk-badge-dot" aria-hidden="true" />
14c3cc8Claude1006 {status}
1007 </span>
1008 );
1009}
1010
1011export default importBulkRoutes;