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.tsxBlame1154 lines · 2 contributors
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;
6fd5915Claude67 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7a99d47Claude68 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;
6fd5915Claude81 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
7a99d47Claude82 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);
6fd5915Claude109 box-shadow: 0 0 8px rgba(91,110,232,0.6);
7a99d47Claude110 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 {
6fd5915Claude123 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7a99d47Claude124 -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);
6fd5915Claude237 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
7a99d47Claude238 }
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
8ae1b1fccantynz-alt270 /* Post-import onboarding report. Scoped like the rest of this file so it
271 cannot bleed into the shared layout. */
272 .import-bulk-migration {
273 margin-top: var(--space-6);
274 padding-top: var(--space-5);
275 border-top: 1px dashed var(--border-strong);
276 }
277 .import-bulk-migration-title {
278 font-family: var(--font-display);
279 font-size: 20px;
280 font-weight: 700;
281 letter-spacing: -0.02em;
282 margin: 0 0 6px;
283 color: var(--text-strong);
284 }
285 .import-bulk-migration-sub {
286 margin: 0 0 var(--space-4);
287 font-size: 14px;
288 color: var(--text-muted);
289 line-height: 1.55;
290 max-width: 68ch;
291 }
292
7a99d47Claude293 .import-bulk-actions {
294 display: flex;
295 gap: 8px;
296 flex-wrap: wrap;
297 align-items: center;
298 margin-top: var(--space-4);
299 }
300
301 /* ─── Info / "what this does" panel ─── */
302 .import-bulk-info {
303 position: relative;
304 padding: 14px 16px 14px 18px;
305 margin-bottom: var(--space-5);
306 background: var(--bg-elevated);
307 border: 1px solid var(--border);
308 border-radius: 12px;
309 overflow: hidden;
310 }
311 .import-bulk-info::before {
312 content: '';
313 position: absolute;
314 left: 0; top: 0; bottom: 0;
315 width: 3px;
6fd5915Claude316 background: linear-gradient(180deg, #5b6ee8 0%, #5f8fa0 100%);
7a99d47Claude317 }
318 .import-bulk-info strong {
319 display: block;
320 font-family: var(--font-display);
321 font-weight: 700;
322 font-size: 14px;
323 color: var(--text-strong);
324 letter-spacing: -0.012em;
325 margin-bottom: 6px;
326 }
327 .import-bulk-info ul {
328 margin: 0;
329 padding-left: 18px;
330 font-size: 13px;
331 color: var(--text-muted);
332 line-height: 1.6;
333 }
334 .import-bulk-info code {
335 font-size: 12px;
336 background: var(--bg-secondary);
337 padding: 1px 5px;
338 border-radius: 4px;
339 color: var(--text);
340 }
341
342 /* ─── Summary strip (counts) ─── */
343 .import-bulk-summary {
344 display: flex;
345 align-items: center;
346 gap: var(--space-3);
347 flex-wrap: wrap;
348 padding: 12px 16px;
349 margin-bottom: var(--space-4);
350 background: var(--bg-elevated);
351 border: 1px solid var(--border);
352 border-radius: 12px;
353 font-size: 13.5px;
354 color: var(--text-muted);
355 }
356 .import-bulk-summary code {
357 font-size: 12.5px;
358 background: var(--bg-secondary);
359 padding: 1px 6px;
360 border-radius: 4px;
361 color: var(--text);
362 }
363 .import-bulk-summary-stat {
364 display: inline-flex;
365 align-items: center;
366 gap: 6px;
367 padding: 3px 10px;
368 border-radius: 9999px;
369 background: var(--bg-secondary);
370 border: 1px solid var(--border-subtle);
371 font-weight: 500;
372 color: var(--text-strong);
373 font-size: 12.5px;
374 }
375 .import-bulk-summary-stat .num { color: var(--accent); font-weight: 700; }
376
377 /* ─── Results table ─── */
378 .import-bulk-table-wrap {
379 background: var(--bg-elevated);
380 border: 1px solid var(--border);
381 border-radius: 14px;
382 overflow: hidden;
383 }
384 .import-bulk-table {
385 width: 100%;
386 border-collapse: collapse;
387 font-size: 13.5px;
388 }
389 .import-bulk-table thead tr {
390 background: var(--bg-secondary);
391 text-align: left;
392 }
393 .import-bulk-table th {
394 padding: 10px 14px;
395 border-bottom: 1px solid var(--border);
396 font-size: 11.5px;
397 font-weight: 600;
398 letter-spacing: 0.06em;
399 text-transform: uppercase;
400 color: var(--text-muted);
401 }
402 .import-bulk-table tbody tr {
403 transition: background 120ms ease;
404 }
405 .import-bulk-table tbody tr:hover {
406 background: rgba(255,255,255,0.018);
407 }
408 .import-bulk-table td {
409 padding: 9px 14px;
410 border-bottom: 1px solid var(--border-subtle);
411 vertical-align: middle;
412 }
413 .import-bulk-table tbody tr:last-child td { border-bottom: 0; }
414 .import-bulk-table-name {
415 font-family: var(--font-mono);
416 color: var(--text-strong);
417 font-size: 13px;
418 }
419 .import-bulk-table-notes {
420 color: var(--text-muted);
421 font-size: 12.5px;
422 }
423
424 /* ─── Status badge ─── */
425 .import-bulk-badge {
426 display: inline-flex;
427 align-items: center;
428 gap: 6px;
429 padding: 2px 9px;
430 border-radius: 9999px;
431 font-size: 12px;
432 font-weight: 600;
433 letter-spacing: -0.005em;
434 }
435 .import-bulk-badge-dot {
436 width: 6px; height: 6px;
437 border-radius: 50%;
438 flex-shrink: 0;
439 }
440 .import-bulk-badge-success {
e589f77ccantynz-alt441 color: var(--green);
7a99d47Claude442 background: rgba(63,185,80,0.13);
443 border: 1px solid rgba(63,185,80,0.28);
444 }
445 .import-bulk-badge-success .import-bulk-badge-dot {
e589f77ccantynz-alt446 background: var(--green);
7a99d47Claude447 box-shadow: 0 0 6px rgba(63,185,80,0.7);
448 }
449 .import-bulk-badge-warn {
450 color: #f0b429;
451 background: rgba(240,180,41,0.12);
452 border: 1px solid rgba(240,180,41,0.28);
453 }
454 .import-bulk-badge-warn .import-bulk-badge-dot { background: #f0b429; }
455 .import-bulk-badge-info {
456 color: #58a6ff;
457 background: rgba(88,166,255,0.12);
458 border: 1px solid rgba(88,166,255,0.28);
459 }
460 .import-bulk-badge-info .import-bulk-badge-dot { background: #58a6ff; }
461 .import-bulk-badge-error {
462 color: #f85149;
463 background: rgba(248,81,73,0.12);
464 border: 1px solid rgba(248,81,73,0.28);
465 }
466 .import-bulk-badge-error .import-bulk-badge-dot { background: #f85149; }
467
468 .import-bulk-empty {
469 padding: 18px;
470 text-align: center;
471 color: var(--text-muted);
472 font-size: 13.5px;
473 }
474
475 .import-bulk-callout {
476 margin-top: var(--space-4);
477 padding: 12px 14px;
478 background: var(--bg-elevated);
479 border: 1px solid var(--border);
480 border-left: 3px solid var(--accent);
481 border-radius: 10px;
482 font-size: 13px;
483 color: var(--text-muted);
484 }
485 .import-bulk-callout em { color: var(--text-strong); font-style: normal; font-weight: 600; }
486
487 .import-bulk-subhead {
488 font-family: var(--font-display);
489 font-size: 16px;
490 font-weight: 700;
491 letter-spacing: -0.014em;
492 color: var(--text-strong);
493 margin: var(--space-5) 0 var(--space-3);
494 }
495`;
496
14c3cc8Claude497/**
498 * Paginate the GitHub "list org repos" endpoint. Caps at MAX_REPOS so a
499 * single request can't fan out indefinitely. Throws on non-2xx so the
500 * caller can surface a friendly error.
501 */
502async function fetchOrgRepos(
503 org: string,
504 token: string
505): Promise<GitHubRepo[]> {
506 const headers: Record<string, string> = {
507 Accept: "application/vnd.github.v3+json",
508 "User-Agent": "gluecron/1.0",
509 Authorization: `Bearer ${token}`,
510 };
511
512 const repos: GitHubRepo[] = [];
513 let page = 1;
514 while (repos.length < MAX_REPOS) {
515 const url = `https://api.github.com/orgs/${encodeURIComponent(
516 org
517 )}/repos?per_page=${GITHUB_PER_PAGE}&page=${page}&type=all`;
518 const res = await fetch(url, { headers });
519 if (!res.ok) {
609a27aClaude520 // Distinguish the three real failure modes the operator can act on
521 // instead of dumping a raw GitHub body that hides the actual cause.
522 let detail = "";
523 try {
524 const body = await res.json();
525 detail = body?.message ? ` — ${String(body.message)}` : "";
526 } catch {
527 /* non-JSON body */
528 }
529 if (res.status === 404) {
530 throw new Error(
531 `Organization "${org}" not found on GitHub (404)${detail}. Check the spelling.`
532 );
533 }
534 if (res.status === 401) {
535 throw new Error(
536 `GitHub rejected the token (401)${detail}. The PAT is invalid or expired — mint a new one at github.com/settings/tokens.`
537 );
538 }
539 if (res.status === 403) {
540 throw new Error(
541 `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.`
542 );
543 }
544 throw new Error(`GitHub API error (${res.status})${detail}`);
545 }
546 let batch: GitHubRepo[];
547 try {
548 batch = (await res.json()) as GitHubRepo[];
549 } catch (err) {
550 // A malformed batch shouldn't kill the whole bulk import. Log it
551 // (without token leak) and stop pagination — the operator will see
552 // whatever we managed to collect so far.
553 console.error(
554 `[import-bulk] non-JSON response on page ${page} for org ${org}:`,
555 err instanceof Error ? err.message : err
556 );
557 break;
14c3cc8Claude558 }
559 if (!Array.isArray(batch) || batch.length === 0) break;
560 repos.push(...batch);
561 if (batch.length < GITHUB_PER_PAGE) break;
562 page++;
563 if (page > 10) break; // hard page ceiling: 1000 entries, we cap earlier anyway
564 }
565 return repos.slice(0, MAX_REPOS);
566}
567
568function matchesVisibility(repo: GitHubRepo, v: Visibility): boolean {
569 if (v === "both") return true;
570 if (v === "public") return repo.private === false;
571 if (v === "private") return repo.private === true;
572 return true;
573}
574
575// ─── FORM PAGE ───────────────────────────────────────────────
576
577importBulkRoutes.get("/import/bulk", requireAuth, async (c) => {
578 const user = c.get("user")!;
579 const error = c.req.query("error");
580
581 return c.html(
582 <Layout title="Bulk import from GitHub" user={user}>
7a99d47Claude583 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
584 <div class="import-bulk-container">
585 {/* ─── Hero ─── */}
586 <div class="import-bulk-hero">
587 <div class="import-bulk-hero-bg" aria-hidden="true">
588 <div class="import-bulk-hero-orb" />
589 </div>
590 <div class="import-bulk-hero-inner">
591 <div class="import-bulk-hero-eyebrow">
592 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
adf5e18Claude593 GitHub migration
7a99d47Claude594 </div>
595 <h1 class="import-bulk-hero-title">
adf5e18Claude596 Migrate your GitHub org{" "}
597 <span class="gradient-text">in 60 seconds</span>.
7a99d47Claude598 </h1>
599 <p class="import-bulk-hero-sub">
adf5e18Claude600 All repos, issues, and history — paste a GitHub org + personal access
601 token and Gluecron imports everything into your namespace
602 sequentially, with per-repo status so one failure can't abort the
603 batch.
7a99d47Claude604 </p>
605 </div>
606 </div>
607
608 {error && (
609 <div class="import-bulk-banner import-bulk-banner-error" role="alert">
610 <div class="import-bulk-banner-title">Bulk import didn't run</div>
611 <div class="import-bulk-banner-detail">
612 {decodeURIComponent(error)}
613 </div>
614 </div>
615 )}
616
617 {/* ─── What this does (info panel) ─── */}
618 <div class="import-bulk-info">
619 <strong>What this does</strong>
620 <ul>
14c3cc8Claude621 <li>
622 Lists every repo in the org via the GitHub API
623 (<code>/orgs/{"{org}"}/repos</code>, paginated).
624 </li>
625 <li>
626 Clones each one as a bare mirror into your gluecron account
627 (<code>{user.username}/{"{repo}"}</code>).
628 </li>
629 <li>
630 Reports per-repo success / failure / skipped-if-exists at
631 the end. One failure does not abort the batch.
632 </li>
633 <li>
7a99d47Claude634 Hard caps: <code>{MAX_REPOS}</code> repos per run, 500MB per repo.
14c3cc8Claude635 </li>
636 </ul>
637 </div>
638
7a99d47Claude639 {/* ─── Form section ─── */}
640 <div class="import-bulk-section">
641 <div class="import-bulk-section-head">
642 <div class="import-bulk-section-eyebrow">Configure</div>
643 <h2 class="import-bulk-section-title">Org + token</h2>
644 <p class="import-bulk-section-desc">
645 Token is used only in this request — never stored.
646 </p>
14c3cc8Claude647 </div>
7a99d47Claude648 <div class="import-bulk-section-body">
649 <form method="post" action="/import/bulk">
650 <div class="import-bulk-field">
651 <label class="import-bulk-field-label">GitHub org</label>
652 <input
653 type="text"
654 name="githubOrg"
655 required
656 placeholder="my-company"
657 aria-label="GitHub org"
658 class="import-bulk-input"
659 />
660 </div>
661
662 <div class="import-bulk-field">
663 <label class="import-bulk-field-label">
664 Personal access token <code>repo:read</code> scope
665 </label>
666 <input
667 type="password"
668 name="githubToken"
669 required
670 placeholder="ghp_xxxxxxxxxxxx"
671 autocomplete="off"
672 aria-label="GitHub personal access token"
673 class="import-bulk-input is-mono"
674 />
675 </div>
676
677 <div class="import-bulk-field">
678 <label class="import-bulk-field-label">Visibility filter</label>
679 <select
680 name="visibility"
681 aria-label="Visibility filter"
682 class="import-bulk-select"
683 >
684 <option value="both" selected>Both (public + private)</option>
685 <option value="public">Public only</option>
686 <option value="private">Private only</option>
687 </select>
688 </div>
689
690 <div class="import-bulk-field">
691 <label class="import-bulk-toggle">
692 <input type="checkbox" name="dryRun" value="1" checked />
693 <span class="import-bulk-toggle-text">
694 Dry run — preview the list without cloning
695 <span class="import-bulk-toggle-hint">
696 Recommended for the first pass. Uncheck once the preview
697 looks right.
698 </span>
699 </span>
700 </label>
701 </div>
702
703 <div class="import-bulk-actions">
704 <button type="submit" class="btn btn-primary">
705 Run bulk import
706 </button>
707 <a href="/import" class="btn">
708 Back to /import
709 </a>
710 </div>
711 </form>
14c3cc8Claude712 </div>
7a99d47Claude713 </div>
14c3cc8Claude714 </div>
715 </Layout>
716 );
717});
718
719// ─── POST HANDLER ────────────────────────────────────────────
720
721importBulkRoutes.post("/import/bulk", requireAuth, async (c) => {
722 const user = c.get("user")!;
723 const body = await c.req.parseBody();
724
725 const githubOrg = String(body.githubOrg || "").trim();
726 const githubToken = String(body.githubToken || "").trim();
727 const visibilityRaw = String(body.visibility || "both").trim();
728 const visibility: Visibility =
729 visibilityRaw === "public" || visibilityRaw === "private"
730 ? (visibilityRaw as Visibility)
731 : "both";
732 const dryRun = Boolean(body.dryRun); // unchecked box = undefined = false
733
734 if (!githubOrg) {
735 return c.redirect("/import/bulk?error=GitHub+org+is+required");
736 }
737 if (!githubToken) {
738 return c.redirect(
739 "/import/bulk?error=GitHub+token+is+required+%28repo%3Aread+scope%29"
740 );
741 }
742
743 // Validate the token has at least read access before we start cloning.
744 // `GET /user` is the cheapest call that requires a valid token. We also
745 // inspect the `X-OAuth-Scopes` header so we can warn early if the token
746 // is missing `repo`/`repo:read`.
747 try {
748 const userRes = await fetch("https://api.github.com/user", {
749 headers: {
750 Accept: "application/vnd.github.v3+json",
751 "User-Agent": "gluecron/1.0",
752 Authorization: `Bearer ${githubToken}`,
753 },
754 });
755 if (!userRes.ok) {
756 return c.redirect(
757 `/import/bulk?error=${encodeURIComponent(
758 `Invalid GitHub token (${userRes.status}). Check scope repo:read.`
759 )}`
760 );
761 }
762 const scopes = (userRes.headers.get("x-oauth-scopes") || "").toLowerCase();
763 if (
764 scopes &&
765 !scopes.includes("repo") &&
766 !scopes.includes("public_repo")
767 ) {
768 return c.redirect(
769 `/import/bulk?error=${encodeURIComponent(
770 "Token is missing repo:read scope. Regenerate with repo (or public_repo) checked."
771 )}`
772 );
773 }
774 } catch (err) {
775 // Network-level failure talking to GitHub. Don't leak err details.
776 return c.redirect(
777 "/import/bulk?error=Could+not+reach+GitHub+to+validate+the+token"
778 );
779 }
780
781 // Pull the repo list.
782 let allRepos: GitHubRepo[];
783 try {
784 allRepos = await fetchOrgRepos(githubOrg, githubToken);
785 } catch (err) {
786 const msg = (err as Error).message || "Unknown error";
787 return c.redirect(
788 `/import/bulk?error=${encodeURIComponent(msg).slice(0, 400)}`
789 );
790 }
791
792 if (allRepos.length === 0) {
793 return c.redirect(
794 `/import/bulk?error=${encodeURIComponent(
795 `No repos visible for org "${githubOrg}" with this token.`
796 )}`
797 );
798 }
799
800 // Apply visibility filter + size cap; track why things were skipped.
801 const candidates: GitHubRepo[] = [];
802 const oversized: { name: string; sizeKB: number }[] = [];
803 for (const r of allRepos) {
804 if (!matchesVisibility(r, visibility)) continue;
805 if (typeof r.size === "number" && r.size > MAX_REPO_SIZE_KB) {
806 oversized.push({ name: r.name, sizeKB: r.size });
807 continue;
808 }
809 candidates.push(r);
810 }
811
812 // Dry run: render a preview + counts, never touch disk or DB.
813 if (dryRun) {
814 return c.html(
815 <Layout title="Bulk import preview" user={user}>
7a99d47Claude816 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
817 <div class="import-bulk-container">
818 <div class="import-bulk-hero">
819 <div class="import-bulk-hero-bg" aria-hidden="true">
820 <div class="import-bulk-hero-orb" />
821 </div>
822 <div class="import-bulk-hero-inner">
823 <div class="import-bulk-hero-eyebrow">
824 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
825 Preview only — nothing imported yet
826 </div>
827 <h1 class="import-bulk-hero-title">
828 Bulk import{" "}
829 <span class="gradient-text">dry run</span>.
830 </h1>
831 <p class="import-bulk-hero-sub">
832 This is what gluecron would clone from{" "}
833 <code>{githubOrg}</code> when you uncheck the dry-run box.
834 </p>
835 </div>
836 </div>
837
838 <div class="import-bulk-summary">
839 <span>
840 Org <code>{githubOrg}</code>
841 </span>
842 <span>
843 Visibility <code>{visibility}</code>
844 </span>
845 <span class="import-bulk-summary-stat">
846 <span class="num">{candidates.length}</span> to import
847 </span>
848 {oversized.length > 0 && (
849 <span class="import-bulk-summary-stat">
850 <span class="num">{oversized.length}</span> skipped (&gt;500MB)
851 </span>
852 )}
853 </div>
14c3cc8Claude854
855 <ResultsTable
856 rows={candidates.map((r) => ({
857 name: sanitizeRepoName(r.name),
858 status: "dry-run",
859 notes: `${r.private ? "private" : "public"} · ${(
860 r.size / 1024
861 ).toFixed(1)} MB`,
862 }))}
863 />
864
865 {oversized.length > 0 && (
866 <>
7a99d47Claude867 <h3 class="import-bulk-subhead">Skipped — over 500MB</h3>
14c3cc8Claude868 <ResultsTable
869 rows={oversized.map((r) => ({
870 name: sanitizeRepoName(r.name),
871 status: "too-large",
872 notes: `${(r.sizeKB / 1024).toFixed(1)} MB`,
873 }))}
874 />
875 </>
876 )}
877
7a99d47Claude878 <div class="import-bulk-callout">
879 Looks good? Go back and uncheck <em>Dry run</em> to actually import.
14c3cc8Claude880 </div>
881
7a99d47Claude882 <div class="import-bulk-actions">
14c3cc8Claude883 <a href="/import/bulk" class="btn btn-primary">
884 Back to form
885 </a>
886 </div>
887 </div>
888 </Layout>
889 );
890 }
891
892 // Real run: clone each candidate sequentially. Collect results.
893 const results: ImportOneRepoResult[] = [];
894 for (const r of candidates) {
895 // eslint-disable-next-line no-await-in-loop
896 const res = await importOneRepo({
897 cloneUrl: r.clone_url,
898 targetName: r.name,
899 ownerId: user.id,
900 ownerUsername: user.username,
901 token: githubToken,
902 description: r.description,
903 isPrivate: r.private,
904 defaultBranch: r.default_branch,
905 });
906 results.push(res);
907 }
908
909 for (const o of oversized) {
910 results.push({
911 status: "failed",
912 name: sanitizeRepoName(o.name),
913 notes: `Skipped — over 500MB (${(o.sizeKB / 1024).toFixed(1)} MB)`,
914 });
915 }
916
917 const counts = results.reduce(
918 (acc, r) => {
919 acc[r.status] = (acc[r.status] || 0) + 1;
920 return acc;
921 },
922 {} as Record<string, number>
923 );
924
8ae1b1fccantynz-alt925 // Post-import onboarding. Until now a bulk import stopped at "cloned": a
926 // team that migrated an org got bare repositories with no branch
927 // protection, no labels, no gate settings, and no evidence any of it was
928 // worth doing. Bootstrap green defaults and scan what they just brought
929 // over, so the results page shows findings rather than a list of names.
930 //
931 // ImportOneRepoResult deliberately carries only status/name/notes, so the
932 // rows are looked up here rather than widening that contract for every
933 // caller of importOneRepo.
934 // Only newly cloned repos. "skipped-exists" means the repo was already on
935 // the platform, so it has been through this once already — re-bootstrapping
936 // would fight whatever settings its owner has since chosen.
937 const importedNames = results
938 .filter((r) => r.status === "success")
939 .map((r) => r.name);
940
941 let migration: import("../lib/migration-onboarding").MigrationReport | null = null;
942 if (importedNames.length > 0) {
943 try {
944 const { inArray, and: andOp, eq: eqOp } = await import("drizzle-orm");
945 const { db } = await import("../db");
946 const { repositories } = await import("../db/schema");
947 const rows = await db
948 .select({
949 id: repositories.id,
950 name: repositories.name,
951 defaultBranch: repositories.defaultBranch,
952 })
953 .from(repositories)
954 .where(
955 andOp(
956 eqOp(repositories.ownerId, user.id),
957 inArray(repositories.name, importedNames)
958 )
959 );
960
961 const { runMigrationOnboarding } = await import("../lib/migration-onboarding");
962 migration = await runMigrationOnboarding(
963 rows.map((r) => ({
964 id: r.id,
965 owner: user.username,
966 name: r.name,
967 defaultBranch: r.defaultBranch,
968 })),
969 user.id
970 );
971 } catch (err) {
972 // Never fail a completed migration because the follow-up work threw —
973 // the repositories are already imported and that must be reported.
974 console.error("[import-bulk] migration onboarding failed:", err);
975 }
976 }
977
14c3cc8Claude978 return c.html(
979 <Layout title="Bulk import results" user={user}>
7a99d47Claude980 <style dangerouslySetInnerHTML={{ __html: importBulkStyles }} />
981 <div class="import-bulk-container">
982 <div class="import-bulk-hero">
983 <div class="import-bulk-hero-bg" aria-hidden="true">
984 <div class="import-bulk-hero-orb" />
985 </div>
986 <div class="import-bulk-hero-inner">
987 <div class="import-bulk-hero-eyebrow">
988 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
989 Bulk migration complete
990 </div>
991 <h1 class="import-bulk-hero-title">
992 Bulk import{" "}
993 <span class="gradient-text">results</span>.
994 </h1>
995 <p class="import-bulk-hero-sub">
996 From <code>{githubOrg}</code> into{" "}
997 <code>{user.username}</code>.
998 </p>
999 </div>
1000 </div>
1001
1002 <div class="import-bulk-summary">
1003 <span class="import-bulk-summary-stat">
1004 <span class="num">{counts["success"] || 0}</span> imported
1005 </span>
1006 <span class="import-bulk-summary-stat">
1007 <span class="num">{counts["skipped-exists"] || 0}</span> skipped
1008 </span>
1009 <span class="import-bulk-summary-stat">
1010 <span class="num">{counts["failed"] || 0}</span> failed
1011 </span>
1012 </div>
14c3cc8Claude1013
1014 <ResultsTable rows={results} />
1015
8ae1b1fccantynz-alt1016 {migration && migration.totalRepos > 0 && (
1017 <div class="import-bulk-migration">
1018 <h2 class="import-bulk-migration-title">
1019 What we did with them
1020 </h2>
1021 <p class="import-bulk-migration-sub">
1022 Branch protection, labels and gates were applied to every
1023 repository, then we scanned the code you just brought over. You
1024 did not have to configure anything.
1025 </p>
1026
1027 <div class="import-bulk-summary">
1028 <span class="import-bulk-summary-stat">
1029 <span class="num">{migration.reposBootstrapped}</span> secured
1030 with green defaults
1031 </span>
1032 <span class="import-bulk-summary-stat">
1033 <span class="num">{migration.totalSecrets}</span> committed
1034 secret{migration.totalSecrets === 1 ? "" : "s"} found
1035 </span>
1036 <span class="import-bulk-summary-stat">
1037 <span class="num">{migration.totalSecurityIssues}</span>{" "}
1038 security finding
1039 {migration.totalSecurityIssues === 1 ? "" : "s"}
1040 </span>
5d13cf5ccantynz-alt1041 <span class="import-bulk-summary-stat">
1042 <span class="num">{migration.totalFilesIndexed}</span> files
1043 indexed for search
1044 </span>
8ae1b1fccantynz-alt1045 </div>
1046
1047 {migration.reposWithFindings > 0 ? (
1048 <table class="import-bulk-table">
1049 <thead>
1050 <tr>
1051 <th>Repository</th>
1052 <th>Secrets</th>
1053 <th>Security</th>
1054 <th>Detail</th>
1055 </tr>
1056 </thead>
1057 <tbody>
1058 {migration.repos
1059 .filter((r) => r.secretsFound > 0 || r.securityIssues > 0)
1060 .map((r) => (
1061 <tr>
1062 <td>
1063 <a href={`/${r.owner}/${r.name}/security`}>{r.name}</a>
1064 </td>
1065 <td>{r.secretsFound}</td>
1066 <td>{r.securityIssues}</td>
1067 <td class="import-bulk-note">{r.scanDetail}</td>
1068 </tr>
1069 ))}
1070 </tbody>
1071 </table>
1072 ) : (
1073 <p class="import-bulk-note">
1074 No committed secrets or security findings in the imported code.
1075 Gates are now active on every repository, so anything new gets
1076 caught at push time.
1077 </p>
1078 )}
1079 </div>
1080 )}
1081
7a99d47Claude1082 <div class="import-bulk-actions">
14c3cc8Claude1083 <a href={`/${user.username}`} class="btn btn-primary">
1084 View my repositories
1085 </a>
1086 <a href="/import/bulk" class="btn">
1087 Run another import
1088 </a>
1089 </div>
1090 </div>
1091 </Layout>
1092 );
1093});
1094
1095// ─── COMPONENTS ──────────────────────────────────────────────
1096
1097function ResultsTable({
1098 rows,
1099}: {
1100 rows: { name: string; status: string; notes: string }[];
1101}) {
1102 if (rows.length === 0) {
1103 return (
7a99d47Claude1104 <div class="import-bulk-table-wrap">
1105 <div class="import-bulk-empty">No rows.</div>
14c3cc8Claude1106 </div>
1107 );
1108 }
1109 return (
7a99d47Claude1110 <div class="import-bulk-table-wrap">
1111 <table class="import-bulk-table">
1112 <thead>
14c3cc8Claude1113 <tr>
7a99d47Claude1114 <th>Name</th>
1115 <th>Status</th>
1116 <th>Notes</th>
14c3cc8Claude1117 </tr>
7a99d47Claude1118 </thead>
1119 <tbody>
1120 {rows.map((r) => (
1121 <tr>
1122 <td class="import-bulk-table-name">{r.name}</td>
1123 <td>
1124 <StatusBadge status={r.status} />
1125 </td>
1126 <td class="import-bulk-table-notes">{r.notes}</td>
1127 </tr>
1128 ))}
1129 </tbody>
1130 </table>
1131 </div>
14c3cc8Claude1132 );
1133}
1134
1135function StatusBadge({ status }: { status: string }) {
7a99d47Claude1136 const cls =
14c3cc8Claude1137 status === "success"
7a99d47Claude1138 ? "import-bulk-badge-success"
14c3cc8Claude1139 : status === "skipped-exists"
7a99d47Claude1140 ? "import-bulk-badge-warn"
14c3cc8Claude1141 : status === "dry-run"
7a99d47Claude1142 ? "import-bulk-badge-info"
14c3cc8Claude1143 : status === "too-large"
7a99d47Claude1144 ? "import-bulk-badge-warn"
1145 : "import-bulk-badge-error";
14c3cc8Claude1146 return (
7a99d47Claude1147 <span class={`import-bulk-badge ${cls}`}>
1148 <span class="import-bulk-badge-dot" aria-hidden="true" />
14c3cc8Claude1149 {status}
1150 </span>
1151 );
1152}
1153
1154export default importBulkRoutes;