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.tsxBlame977 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) {
497 // Never echo the token. Include only the status + first slice of body.
498 const errBody = (await res.text()).slice(0, 200);
499 throw new Error(`GitHub API error (${res.status}): ${errBody}`);
500 }
501 const batch = (await res.json()) as GitHubRepo[];
502 if (!Array.isArray(batch) || batch.length === 0) break;
503 repos.push(...batch);
504 if (batch.length < GITHUB_PER_PAGE) break;
505 page++;
506 if (page > 10) break; // hard page ceiling: 1000 entries, we cap earlier anyway
507 }
508 return repos.slice(0, MAX_REPOS);
509}
510
511function matchesVisibility(repo: GitHubRepo, v: Visibility): boolean {
512 if (v === "both") return true;
513 if (v === "public") return repo.private === false;
514 if (v === "private") return repo.private === true;
515 return true;
516}
517
518// ─── FORM PAGE ───────────────────────────────────────────────
519
520importBulkRoutes.get("/import/bulk", requireAuth, async (c) => {
521 const user = c.get("user")!;
522 const error = c.req.query("error");
523
524 return c.html(
525 <Layout title="Bulk import from GitHub" user={user}>
7a99d47Claude526 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
527 <div class="import-bulk-container">
528 {/* ─── Hero ─── */}
529 <div class="import-bulk-hero">
530 <div class="import-bulk-hero-bg" aria-hidden="true">
531 <div class="import-bulk-hero-orb" />
532 </div>
533 <div class="import-bulk-hero-inner">
534 <div class="import-bulk-hero-eyebrow">
535 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
536 Bulk migration
537 </div>
538 <h1 class="import-bulk-hero-title">
539 Import{" "}
540 <span class="gradient-text">many at once</span>.
541 </h1>
542 <p class="import-bulk-hero-sub">
543 Paste a GitHub org + personal access token. Gluecron clones every
544 repo into your namespace as a mirror — sequentially, with per-repo
545 status so one failure can't abort the batch.
546 </p>
547 </div>
548 </div>
549
550 {error && (
551 <div class="import-bulk-banner import-bulk-banner-error" role="alert">
552 <div class="import-bulk-banner-title">Bulk import didn't run</div>
553 <div class="import-bulk-banner-detail">
554 {decodeURIComponent(error)}
555 </div>
556 </div>
557 )}
558
559 {/* ─── What this does (info panel) ─── */}
560 <div class="import-bulk-info">
561 <strong>What this does</strong>
562 <ul>
14c3cc8Claude563 <li>
564 Lists every repo in the org via the GitHub API
565 (<code>/orgs/{"{org}"}/repos</code>, paginated).
566 </li>
567 <li>
568 Clones each one as a bare mirror into your gluecron account
569 (<code>{user.username}/{"{repo}"}</code>).
570 </li>
571 <li>
572 Reports per-repo success / failure / skipped-if-exists at
573 the end. One failure does not abort the batch.
574 </li>
575 <li>
7a99d47Claude576 Hard caps: <code>{MAX_REPOS}</code> repos per run, 500MB per repo.
14c3cc8Claude577 </li>
578 </ul>
579 </div>
580
7a99d47Claude581 {/* ─── Form section ─── */}
582 <div class="import-bulk-section">
583 <div class="import-bulk-section-head">
584 <div class="import-bulk-section-eyebrow">Configure</div>
585 <h2 class="import-bulk-section-title">Org + token</h2>
586 <p class="import-bulk-section-desc">
587 Token is used only in this request — never stored.
588 </p>
14c3cc8Claude589 </div>
7a99d47Claude590 <div class="import-bulk-section-body">
591 <form method="post" action="/import/bulk">
592 <div class="import-bulk-field">
593 <label class="import-bulk-field-label">GitHub org</label>
594 <input
595 type="text"
596 name="githubOrg"
597 required
598 placeholder="my-company"
599 aria-label="GitHub org"
600 class="import-bulk-input"
601 />
602 </div>
603
604 <div class="import-bulk-field">
605 <label class="import-bulk-field-label">
606 Personal access token <code>repo:read</code> scope
607 </label>
608 <input
609 type="password"
610 name="githubToken"
611 required
612 placeholder="ghp_xxxxxxxxxxxx"
613 autocomplete="off"
614 aria-label="GitHub personal access token"
615 class="import-bulk-input is-mono"
616 />
617 </div>
618
619 <div class="import-bulk-field">
620 <label class="import-bulk-field-label">Visibility filter</label>
621 <select
622 name="visibility"
623 aria-label="Visibility filter"
624 class="import-bulk-select"
625 >
626 <option value="both" selected>Both (public + private)</option>
627 <option value="public">Public only</option>
628 <option value="private">Private only</option>
629 </select>
630 </div>
631
632 <div class="import-bulk-field">
633 <label class="import-bulk-toggle">
634 <input type="checkbox" name="dryRun" value="1" checked />
635 <span class="import-bulk-toggle-text">
636 Dry run — preview the list without cloning
637 <span class="import-bulk-toggle-hint">
638 Recommended for the first pass. Uncheck once the preview
639 looks right.
640 </span>
641 </span>
642 </label>
643 </div>
644
645 <div class="import-bulk-actions">
646 <button type="submit" class="btn btn-primary">
647 Run bulk import
648 </button>
649 <a href="/import" class="btn">
650 Back to /import
651 </a>
652 </div>
653 </form>
14c3cc8Claude654 </div>
7a99d47Claude655 </div>
14c3cc8Claude656 </div>
657 </Layout>
658 );
659});
660
661// ─── POST HANDLER ────────────────────────────────────────────
662
663importBulkRoutes.post("/import/bulk", requireAuth, async (c) => {
664 const user = c.get("user")!;
665 const body = await c.req.parseBody();
666
667 const githubOrg = String(body.githubOrg || "").trim();
668 const githubToken = String(body.githubToken || "").trim();
669 const visibilityRaw = String(body.visibility || "both").trim();
670 const visibility: Visibility =
671 visibilityRaw === "public" || visibilityRaw === "private"
672 ? (visibilityRaw as Visibility)
673 : "both";
674 const dryRun = Boolean(body.dryRun); // unchecked box = undefined = false
675
676 if (!githubOrg) {
677 return c.redirect("/import/bulk?error=GitHub+org+is+required");
678 }
679 if (!githubToken) {
680 return c.redirect(
681 "/import/bulk?error=GitHub+token+is+required+%28repo%3Aread+scope%29"
682 );
683 }
684
685 // Validate the token has at least read access before we start cloning.
686 // `GET /user` is the cheapest call that requires a valid token. We also
687 // inspect the `X-OAuth-Scopes` header so we can warn early if the token
688 // is missing `repo`/`repo:read`.
689 try {
690 const userRes = await fetch("https://api.github.com/user", {
691 headers: {
692 Accept: "application/vnd.github.v3+json",
693 "User-Agent": "gluecron/1.0",
694 Authorization: `Bearer ${githubToken}`,
695 },
696 });
697 if (!userRes.ok) {
698 return c.redirect(
699 `/import/bulk?error=${encodeURIComponent(
700 `Invalid GitHub token (${userRes.status}). Check scope repo:read.`
701 )}`
702 );
703 }
704 const scopes = (userRes.headers.get("x-oauth-scopes") || "").toLowerCase();
705 if (
706 scopes &&
707 !scopes.includes("repo") &&
708 !scopes.includes("public_repo")
709 ) {
710 return c.redirect(
711 `/import/bulk?error=${encodeURIComponent(
712 "Token is missing repo:read scope. Regenerate with repo (or public_repo) checked."
713 )}`
714 );
715 }
716 } catch (err) {
717 // Network-level failure talking to GitHub. Don't leak err details.
718 return c.redirect(
719 "/import/bulk?error=Could+not+reach+GitHub+to+validate+the+token"
720 );
721 }
722
723 // Pull the repo list.
724 let allRepos: GitHubRepo[];
725 try {
726 allRepos = await fetchOrgRepos(githubOrg, githubToken);
727 } catch (err) {
728 const msg = (err as Error).message || "Unknown error";
729 return c.redirect(
730 `/import/bulk?error=${encodeURIComponent(msg).slice(0, 400)}`
731 );
732 }
733
734 if (allRepos.length === 0) {
735 return c.redirect(
736 `/import/bulk?error=${encodeURIComponent(
737 `No repos visible for org "${githubOrg}" with this token.`
738 )}`
739 );
740 }
741
742 // Apply visibility filter + size cap; track why things were skipped.
743 const candidates: GitHubRepo[] = [];
744 const oversized: { name: string; sizeKB: number }[] = [];
745 for (const r of allRepos) {
746 if (!matchesVisibility(r, visibility)) continue;
747 if (typeof r.size === "number" && r.size > MAX_REPO_SIZE_KB) {
748 oversized.push({ name: r.name, sizeKB: r.size });
749 continue;
750 }
751 candidates.push(r);
752 }
753
754 // Dry run: render a preview + counts, never touch disk or DB.
755 if (dryRun) {
756 return c.html(
757 <Layout title="Bulk import preview" user={user}>
7a99d47Claude758 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
759 <div class="import-bulk-container">
760 <div class="import-bulk-hero">
761 <div class="import-bulk-hero-bg" aria-hidden="true">
762 <div class="import-bulk-hero-orb" />
763 </div>
764 <div class="import-bulk-hero-inner">
765 <div class="import-bulk-hero-eyebrow">
766 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
767 Preview only — nothing imported yet
768 </div>
769 <h1 class="import-bulk-hero-title">
770 Bulk import{" "}
771 <span class="gradient-text">dry run</span>.
772 </h1>
773 <p class="import-bulk-hero-sub">
774 This is what gluecron would clone from{" "}
775 <code>{githubOrg}</code> when you uncheck the dry-run box.
776 </p>
777 </div>
778 </div>
779
780 <div class="import-bulk-summary">
781 <span>
782 Org <code>{githubOrg}</code>
783 </span>
784 <span>
785 Visibility <code>{visibility}</code>
786 </span>
787 <span class="import-bulk-summary-stat">
788 <span class="num">{candidates.length}</span> to import
789 </span>
790 {oversized.length > 0 && (
791 <span class="import-bulk-summary-stat">
792 <span class="num">{oversized.length}</span> skipped (&gt;500MB)
793 </span>
794 )}
795 </div>
14c3cc8Claude796
797 <ResultsTable
798 rows={candidates.map((r) => ({
799 name: sanitizeRepoName(r.name),
800 status: "dry-run",
801 notes: `${r.private ? "private" : "public"} · ${(
802 r.size / 1024
803 ).toFixed(1)} MB`,
804 }))}
805 />
806
807 {oversized.length > 0 && (
808 <>
7a99d47Claude809 <h3 class="import-bulk-subhead">Skipped — over 500MB</h3>
14c3cc8Claude810 <ResultsTable
811 rows={oversized.map((r) => ({
812 name: sanitizeRepoName(r.name),
813 status: "too-large",
814 notes: `${(r.sizeKB / 1024).toFixed(1)} MB`,
815 }))}
816 />
817 </>
818 )}
819
7a99d47Claude820 <div class="import-bulk-callout">
821 Looks good? Go back and uncheck <em>Dry run</em> to actually import.
14c3cc8Claude822 </div>
823
7a99d47Claude824 <div class="import-bulk-actions">
14c3cc8Claude825 <a href="/import/bulk" class="btn btn-primary">
826 Back to form
827 </a>
828 </div>
829 </div>
830 </Layout>
831 );
832 }
833
834 // Real run: clone each candidate sequentially. Collect results.
835 const results: ImportOneRepoResult[] = [];
836 for (const r of candidates) {
837 // eslint-disable-next-line no-await-in-loop
838 const res = await importOneRepo({
839 cloneUrl: r.clone_url,
840 targetName: r.name,
841 ownerId: user.id,
842 ownerUsername: user.username,
843 token: githubToken,
844 description: r.description,
845 isPrivate: r.private,
846 defaultBranch: r.default_branch,
847 });
848 results.push(res);
849 }
850
851 for (const o of oversized) {
852 results.push({
853 status: "failed",
854 name: sanitizeRepoName(o.name),
855 notes: `Skipped — over 500MB (${(o.sizeKB / 1024).toFixed(1)} MB)`,
856 });
857 }
858
859 const counts = results.reduce(
860 (acc, r) => {
861 acc[r.status] = (acc[r.status] || 0) + 1;
862 return acc;
863 },
864 {} as Record<string, number>
865 );
866
867 return c.html(
868 <Layout title="Bulk import results" user={user}>
7a99d47Claude869 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
870 <div class="import-bulk-container">
871 <div class="import-bulk-hero">
872 <div class="import-bulk-hero-bg" aria-hidden="true">
873 <div class="import-bulk-hero-orb" />
874 </div>
875 <div class="import-bulk-hero-inner">
876 <div class="import-bulk-hero-eyebrow">
877 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
878 Bulk migration complete
879 </div>
880 <h1 class="import-bulk-hero-title">
881 Bulk import{" "}
882 <span class="gradient-text">results</span>.
883 </h1>
884 <p class="import-bulk-hero-sub">
885 From <code>{githubOrg}</code> into{" "}
886 <code>{user.username}</code>.
887 </p>
888 </div>
889 </div>
890
891 <div class="import-bulk-summary">
892 <span class="import-bulk-summary-stat">
893 <span class="num">{counts["success"] || 0}</span> imported
894 </span>
895 <span class="import-bulk-summary-stat">
896 <span class="num">{counts["skipped-exists"] || 0}</span> skipped
897 </span>
898 <span class="import-bulk-summary-stat">
899 <span class="num">{counts["failed"] || 0}</span> failed
900 </span>
901 </div>
14c3cc8Claude902
903 <ResultsTable rows={results} />
904
7a99d47Claude905 <div class="import-bulk-actions">
14c3cc8Claude906 <a href={`/${user.username}`} class="btn btn-primary">
907 View my repositories
908 </a>
909 <a href="/import/bulk" class="btn">
910 Run another import
911 </a>
912 </div>
913 </div>
914 </Layout>
915 );
916});
917
918// ─── COMPONENTS ──────────────────────────────────────────────
919
920function ResultsTable({
921 rows,
922}: {
923 rows: { name: string; status: string; notes: string }[];
924}) {
925 if (rows.length === 0) {
926 return (
7a99d47Claude927 <div class="import-bulk-table-wrap">
928 <div class="import-bulk-empty">No rows.</div>
14c3cc8Claude929 </div>
930 );
931 }
932 return (
7a99d47Claude933 <div class="import-bulk-table-wrap">
934 <table class="import-bulk-table">
935 <thead>
14c3cc8Claude936 <tr>
7a99d47Claude937 <th>Name</th>
938 <th>Status</th>
939 <th>Notes</th>
14c3cc8Claude940 </tr>
7a99d47Claude941 </thead>
942 <tbody>
943 {rows.map((r) => (
944 <tr>
945 <td class="import-bulk-table-name">{r.name}</td>
946 <td>
947 <StatusBadge status={r.status} />
948 </td>
949 <td class="import-bulk-table-notes">{r.notes}</td>
950 </tr>
951 ))}
952 </tbody>
953 </table>
954 </div>
14c3cc8Claude955 );
956}
957
958function StatusBadge({ status }: { status: string }) {
7a99d47Claude959 const cls =
14c3cc8Claude960 status === "success"
7a99d47Claude961 ? "import-bulk-badge-success"
14c3cc8Claude962 : status === "skipped-exists"
7a99d47Claude963 ? "import-bulk-badge-warn"
14c3cc8Claude964 : status === "dry-run"
7a99d47Claude965 ? "import-bulk-badge-info"
14c3cc8Claude966 : status === "too-large"
7a99d47Claude967 ? "import-bulk-badge-warn"
968 : "import-bulk-badge-error";
14c3cc8Claude969 return (
7a99d47Claude970 <span class={`import-bulk-badge ${cls}`}>
971 <span class="import-bulk-badge-dot" aria-hidden="true" />
14c3cc8Claude972 {status}
973 </span>
974 );
975}
976
977export default importBulkRoutes;