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.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.tsxBlame1002 lines · 2 contributors
bdbd0deClaude1/**
2 * GitHub Import — automatic migration from GitHub to gluecron.
3 *
4 * Developer connects GitHub, gluecron pulls ALL their repos
5 * automatically. Issues, descriptions, branches — everything.
6 * One click. Walk away. Come back to everything migrated.
7 */
8
9import { Hono } from "hono";
80bed05Claude10import { and, eq } from "drizzle-orm";
bdbd0deClaude11import { db } from "../db";
80bed05Claude12import { repositories } from "../db/schema";
bdbd0deClaude13import { Layout } from "../views/layout";
14import { softAuth, requireAuth } from "../middleware/auth";
a4f3e24Claude15import { requireAdmin } from "../middleware/admin";
bdbd0deClaude16import type { AuthEnv } from "../middleware/auth";
17import { config } from "../lib/config";
18import { mkdir } from "fs/promises";
19import { join } from "path";
80bed05Claude20import {
21 parseGithubUrl,
22 sanitizeRepoName,
23 buildCloneUrl,
609a27aClaude24 scrubSecrets,
80bed05Claude25} from "../lib/import-helper";
bdbd0deClaude26
27const importRoutes = new Hono<AuthEnv>();
28
29importRoutes.use("*", softAuth);
30
31interface GitHubRepo {
32 name: string;
33 full_name: string;
34 description: string | null;
35 private: boolean;
36 clone_url: string;
37 default_branch: string;
38 stargazers_count: number;
39 fork: boolean;
40 language: string | null;
41}
42
7a99d47Claude43// ─── PAGE-SCOPED CSS ─────────────────────────────────────────
44// All classes prefixed with .import- so the block cannot bleed into
45// neighbouring routes. Mirrors the dashboard-hero + settings polish.
46const importStyles = `
47 .import-container { max-width: 880px; margin: 0 auto; }
48
49 /* ─── Hero ─── */
50 .import-hero {
51 position: relative;
52 margin-bottom: var(--space-6);
53 padding: var(--space-5) var(--space-6);
54 background: var(--bg-elevated);
55 border: 1px solid var(--border);
56 border-radius: 16px;
57 overflow: hidden;
58 }
59 .import-hero::before {
60 content: '';
61 position: absolute;
62 top: 0; left: 0; right: 0;
63 height: 2px;
64 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
65 opacity: 0.75;
66 pointer-events: none;
67 }
68 .import-hero-bg {
69 position: absolute;
70 inset: -20% -10% auto auto;
71 width: 360px; height: 360px;
72 pointer-events: none;
73 z-index: 0;
74 }
75 .import-hero-orb {
76 position: absolute;
77 inset: 0;
78 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
79 filter: blur(80px);
80 opacity: 0.7;
81 animation: importHeroOrb 14s ease-in-out infinite;
82 }
83 @keyframes importHeroOrb {
84 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
85 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
86 }
87 @media (prefers-reduced-motion: reduce) {
88 .import-hero-orb { animation: none; }
89 }
90 .import-hero-inner {
91 position: relative;
92 z-index: 1;
93 max-width: 640px;
94 }
95 .import-hero-eyebrow {
96 font-size: 13px;
97 color: var(--text-muted);
98 margin-bottom: var(--space-2);
99 letter-spacing: -0.005em;
100 }
101 .import-hero-eyebrow-dot {
102 display: inline-block;
103 width: 6px; height: 6px;
104 border-radius: 50%;
105 background: var(--accent);
106 box-shadow: 0 0 8px rgba(140,109,255,0.6);
107 margin-right: 8px;
108 vertical-align: 1px;
109 }
110 .import-hero-title {
111 font-size: clamp(28px, 4vw, 40px);
112 font-family: var(--font-display);
113 font-weight: 800;
114 letter-spacing: -0.028em;
115 line-height: 1.05;
116 margin: 0 0 var(--space-2);
117 color: var(--text-strong);
118 }
119 .import-hero-title .gradient-text {
120 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
121 -webkit-background-clip: text;
122 background-clip: text;
123 -webkit-text-fill-color: transparent;
124 color: transparent;
125 }
126 .import-hero-sub {
127 font-size: 15px;
128 color: var(--text-muted);
129 margin: 0;
130 line-height: 1.55;
131 }
132
133 /* ─── Bulk CTA strip (between hero and options) ─── */
134 .import-bulk-cta {
135 position: relative;
136 display: flex;
137 align-items: center;
138 justify-content: space-between;
139 gap: var(--space-4);
140 padding: 14px 18px;
141 margin-bottom: var(--space-5);
142 background: var(--bg-elevated);
143 border: 1px solid var(--border);
144 border-radius: 14px;
145 text-decoration: none;
146 color: inherit;
147 overflow: hidden;
148 transition: border-color 140ms ease, transform 140ms ease;
149 }
150 .import-bulk-cta:hover {
151 border-color: rgba(140,109,255,0.45);
152 transform: translateY(-1px);
153 }
154 .import-bulk-cta::before {
155 content: '';
156 position: absolute;
157 left: 0; top: 0; bottom: 0;
158 width: 3px;
159 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
160 }
161 .import-bulk-cta-text strong {
162 display: block;
163 font-family: var(--font-display);
164 font-weight: 700;
165 font-size: 15px;
166 color: var(--text-strong);
167 letter-spacing: -0.012em;
168 }
169 .import-bulk-cta-text .import-bulk-cta-sub {
170 font-size: 13px;
171 color: var(--text-muted);
172 margin-top: 3px;
173 line-height: 1.4;
174 }
175 .import-bulk-cta-arrow {
176 flex-shrink: 0;
177 font-size: 18px;
178 color: var(--accent);
179 transition: transform 140ms ease;
180 }
181 .import-bulk-cta:hover .import-bulk-cta-arrow { transform: translateX(3px); }
182
183 /* ─── Banners ─── */
184 .import-banner {
185 position: relative;
186 padding: 14px 16px 14px 44px;
187 margin-bottom: var(--space-5);
188 border-radius: 12px;
189 border: 1px solid var(--border);
190 background: var(--bg-elevated);
191 font-size: 14px;
192 line-height: 1.5;
193 }
194 .import-banner::before {
195 content: '';
196 position: absolute;
197 left: 14px; top: 18px;
198 width: 14px; height: 14px;
199 border-radius: 50%;
200 }
201 .import-banner-success {
202 border-color: rgba(63, 185, 80, 0.32);
203 background: linear-gradient(180deg, rgba(63,185,80,0.06) 0%, var(--bg-elevated) 100%);
204 }
205 .import-banner-success::before {
206 background: radial-gradient(circle, #3fb950 30%, transparent 70%);
207 box-shadow: 0 0 10px rgba(63,185,80,0.5);
208 }
209 .import-banner-error {
210 border-color: rgba(248, 81, 73, 0.32);
211 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
212 }
213 .import-banner-error::before {
214 background: radial-gradient(circle, #f85149 30%, transparent 70%);
215 box-shadow: 0 0 10px rgba(248,81,73,0.5);
216 }
217 .import-banner-title { font-weight: 600; color: var(--text-strong); }
218 .import-banner-detail { color: var(--text-muted); margin-top: 4px; font-size: 13.5px; }
219 .import-banner-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
220
221 /* ─── Progress card with step pipeline ─── */
222 .import-progress-card {
223 position: relative;
224 margin-bottom: var(--space-5);
225 padding: 18px 18px 16px;
226 background: var(--bg-elevated);
227 border: 1px solid var(--border);
228 border-radius: 14px;
229 overflow: hidden;
230 }
231 .import-progress-card::before {
232 content: '';
233 position: absolute;
234 top: 0; left: 0; right: 0;
235 height: 2px;
236 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 50%, #8c6dff 100%);
237 background-size: 200% 100%;
238 animation: importProgressShimmer 2.2s linear infinite;
239 pointer-events: none;
240 }
241 @keyframes importProgressShimmer {
242 0% { background-position: 0% 0%; }
243 100% { background-position: 200% 0%; }
244 }
245 @media (prefers-reduced-motion: reduce) {
246 .import-progress-card::before { animation: none; }
247 }
248 .import-progress-title {
249 font-family: var(--font-display);
250 font-weight: 700;
251 font-size: 16px;
252 letter-spacing: -0.012em;
253 color: var(--text-strong);
254 margin: 0 0 4px;
255 }
256 .import-progress-sub {
257 font-size: 13px;
258 color: var(--text-muted);
259 margin: 0 0 14px;
260 }
261 .import-steps {
262 display: grid;
263 grid-template-columns: repeat(4, 1fr);
264 gap: 8px;
265 }
266 .import-step {
267 position: relative;
268 padding: 10px 12px;
269 background: var(--bg-secondary);
270 border: 1px solid var(--border-subtle);
271 border-radius: 10px;
272 font-size: 12.5px;
273 color: var(--text-muted);
274 display: flex;
275 align-items: center;
276 gap: 8px;
277 }
278 .import-step-dot {
279 width: 16px; height: 16px;
280 border-radius: 50%;
281 background: var(--bg-tertiary, #1a1d2a);
282 border: 1.5px solid var(--border-strong);
283 flex-shrink: 0;
284 position: relative;
285 }
286 .import-step.is-active {
287 border-color: rgba(140,109,255,0.45);
288 color: var(--text-strong);
289 background: rgba(140,109,255,0.07);
290 }
291 .import-step.is-active .import-step-dot {
292 background: var(--accent);
293 border-color: var(--accent);
294 box-shadow: 0 0 10px rgba(140,109,255,0.6);
295 animation: importStepPulse 1.4s ease-in-out infinite;
296 }
297 @keyframes importStepPulse {
298 0%, 100% { box-shadow: 0 0 8px rgba(140,109,255,0.4); }
299 50% { box-shadow: 0 0 14px rgba(140,109,255,0.85); }
300 }
301 @media (prefers-reduced-motion: reduce) {
302 .import-step.is-active .import-step-dot { animation: none; }
303 }
304 .import-step-label { font-weight: 500; }
305 @media (max-width: 640px) {
306 .import-steps { grid-template-columns: repeat(2, 1fr); }
307 }
308
309 /* ─── Option cards ─── */
310 .import-options {
311 display: grid;
312 gap: var(--space-4);
313 }
314 .import-option {
315 position: relative;
316 background: var(--bg-elevated);
317 border: 1px solid var(--border);
318 border-radius: 14px;
319 padding: var(--space-4) var(--space-5);
320 transition: border-color 140ms ease, box-shadow 140ms ease;
321 }
322 .import-option:hover { border-color: var(--border-strong); }
323 .import-option-head {
324 display: flex;
325 align-items: center;
326 gap: 10px;
327 margin-bottom: 4px;
328 }
329 .import-option-badge {
330 display: inline-flex;
331 align-items: center;
332 justify-content: center;
333 width: 24px; height: 24px;
334 border-radius: 8px;
335 background: linear-gradient(135deg, rgba(140,109,255,0.18) 0%, rgba(54,197,214,0.18) 100%);
336 color: var(--accent);
337 font-size: 12px;
338 font-weight: 700;
339 border: 1px solid rgba(140,109,255,0.28);
340 }
341 .import-option-title {
342 font-family: var(--font-display);
343 font-size: 16px;
344 font-weight: 700;
345 letter-spacing: -0.014em;
346 color: var(--text-strong);
347 margin: 0;
348 }
349 .import-option-desc {
350 font-size: 13.5px;
351 color: var(--text-muted);
352 line-height: 1.5;
353 margin: 0 0 14px;
354 }
355 .import-option-form .import-row {
356 display: flex;
357 gap: 8px;
358 }
359 .import-input {
360 flex: 1;
361 width: 100%;
362 padding: 9px 12px;
363 background: var(--bg);
364 border: 1px solid var(--border-strong);
365 border-radius: 8px;
366 color: var(--text);
367 font-size: 14px;
368 font-family: var(--font-sans);
369 outline: none;
370 transition: border-color 120ms ease, box-shadow 120ms ease;
371 }
372 .import-input:focus {
373 border-color: var(--accent);
374 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
375 }
376 .import-input.is-mono { font-family: var(--font-mono); font-size: 13px; }
377 .import-field { margin-top: 10px; }
378 .import-field-label {
379 display: block;
380 font-size: 12.5px;
381 font-weight: 600;
382 color: var(--text-strong);
383 margin-bottom: 4px;
384 letter-spacing: -0.005em;
385 }
386 .import-field-hint {
387 font-size: 12px;
388 color: var(--text-muted);
389 margin-top: 4px;
390 line-height: 1.45;
391 }
392
393 @media (max-width: 600px) {
394 .import-option-form .import-row { flex-direction: column; }
395 .import-option-form .import-row .btn { width: 100%; }
396 }
397`;
398
bdbd0deClaude399// ─── IMPORT PAGE ─────────────────────────────────────────────
400
a4f3e24Claude401importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude402 const user = c.get("user")!;
403 const success = c.req.query("success");
404 const error = c.req.query("error");
405 const imported = c.req.query("imported");
406
80bed05Claude407 // Inline progress banner: the clone subprocess can take 30+s for big
408 // repos, so give the user visible feedback while the POST is in flight.
409 // Pure client-side — no extra routes, no websockets, no polling.
7a99d47Claude410 // Polished step pipeline: clone → analyze → enable gates → done.
80bed05Claude411 const progressScript = `
412 (function () {
413 var forms = document.querySelectorAll('form[data-import-form]');
414 var banner = document.getElementById('import-progress');
415 if (!banner) return;
7a99d47Claude416 var steps = banner.querySelectorAll('[data-step]');
417 function setStep(idx) {
418 steps.forEach(function (el, i) {
419 if (i <= idx) el.classList.add('is-active');
420 else el.classList.remove('is-active');
421 });
422 }
80bed05Claude423 forms.forEach(function (form) {
424 form.addEventListener('submit', function () {
425 // Validate non-empty required fields before showing progress.
426 var req = form.querySelectorAll('[required]');
427 for (var i = 0; i < req.length; i++) {
428 if (!req[i].value || !req[i].value.trim()) return;
429 }
430 banner.style.display = 'block';
7a99d47Claude431 setStep(0);
432 // Walk through 'analyze' and 'gates' steps on a timer so the
433 // pipeline visually advances even before the redirect lands.
434 setTimeout(function () { setStep(1); }, 1800);
435 setTimeout(function () { setStep(2); }, 4200);
80bed05Claude436 var btns = form.querySelectorAll('button[type="submit"]');
437 btns.forEach(function (b) {
438 b.disabled = true;
439 b.textContent = 'Importing…';
440 });
441 // Scroll banner into view so user sees progress above the fold.
442 try { banner.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
443 });
444 });
445 })();
446 `;
447
bdbd0deClaude448 return c.html(
449 <Layout title="Import from GitHub" user={user}>
7a99d47Claude450 <style dangerouslySetInnerHTML={{ __html: importStyles }} />
451 <div class="import-container">
452 {/* ─── Hero ─── */}
453 <div class="import-hero">
454 <div class="import-hero-bg" aria-hidden="true">
455 <div class="import-hero-orb" />
456 </div>
457 <div class="import-hero-inner">
458 <div class="import-hero-eyebrow">
459 <span class="import-hero-eyebrow-dot" aria-hidden="true" />
460 GitHub migration
461 </div>
462 <h1 class="import-hero-title">
463 Import from{" "}
464 <span class="gradient-text">GitHub</span>.
465 </h1>
466 <p class="import-hero-sub">
467 Migrate your repositories from GitHub to gluecron automatically —
468 all branches, all history, all code. One click, walk away, come
469 back to everything migrated.
470 </p>
471 </div>
472 </div>
473
474 {/* ─── Bulk-import shortcut ─── */}
475 <a href="/import/bulk" class="import-bulk-cta">
476 <div class="import-bulk-cta-text">
477 <strong>Migrating a whole org? Try the bulk importer.</strong>
478 <div class="import-bulk-cta-sub">
479 Paste a GitHub org name + token and clone every repo in one shot.
480 </div>
14c3cc8Claude481 </div>
7a99d47Claude482 <div class="import-bulk-cta-arrow" aria-hidden="true">→</div>
14c3cc8Claude483 </a>
7a99d47Claude484
485 {/* ─── Success / error banners ─── */}
bdbd0deClaude486 {success && (
7a99d47Claude487 <div class="import-banner import-banner-success" role="status">
488 <div class="import-banner-title">{decodeURIComponent(success)}</div>
bdbd0deClaude489 {imported && (
7a99d47Claude490 <div class="import-banner-detail">
bdbd0deClaude491 Successfully imported {decodeURIComponent(imported)} repositories.
492 </div>
493 )}
7a99d47Claude494 <div class="import-banner-actions">
495 <a href={`/${user.username}`} class="btn btn-primary">
80bed05Claude496 View my repositories
497 </a>
498 <a href="/explore" class="btn">Explore</a>
499 </div>
bdbd0deClaude500 </div>
501 )}
502 {error && (
7a99d47Claude503 <div class="import-banner import-banner-error" role="alert">
504 <div class="import-banner-title">Import didn't complete</div>
505 <div class="import-banner-detail">{decodeURIComponent(error)}</div>
506 </div>
bdbd0deClaude507 )}
508
7a99d47Claude509 {/* ─── In-flight progress pipeline ─── */}
80bed05Claude510 <div
511 id="import-progress"
7a99d47Claude512 class="import-progress-card"
80bed05Claude513 role="status"
514 aria-live="polite"
7a99d47Claude515 style="display: none"
80bed05Claude516 >
7a99d47Claude517 <div class="import-progress-title">Import in progress</div>
518 <div class="import-progress-sub">
519 Large repositories can take 30+ seconds — don't close this tab.
80bed05Claude520 </div>
7a99d47Claude521 <div class="import-steps">
522 <div class="import-step" data-step="0">
523 <span class="import-step-dot" aria-hidden="true" />
524 <span class="import-step-label">Clone</span>
bdbd0deClaude525 </div>
7a99d47Claude526 <div class="import-step" data-step="1">
527 <span class="import-step-dot" aria-hidden="true" />
528 <span class="import-step-label">Analyze</span>
529 </div>
530 <div class="import-step" data-step="2">
531 <span class="import-step-dot" aria-hidden="true" />
532 <span class="import-step-label">Enable gates</span>
bdbd0deClaude533 </div>
7a99d47Claude534 <div class="import-step" data-step="3">
535 <span class="import-step-dot" aria-hidden="true" />
536 <span class="import-step-label">Done</span>
f390cfaCC LABS App537 </div>
7a99d47Claude538 </div>
bdbd0deClaude539 </div>
540
7a99d47Claude541 {/* ─── Option cards ─── */}
542 <div class="import-options">
543 <div class="import-option">
544 <div class="import-option-head">
545 <span class="import-option-badge" aria-hidden="true">1</span>
546 <h3 class="import-option-title">Import by username</h3>
bdbd0deClaude547 </div>
7a99d47Claude548 <p class="import-option-desc">
549 Import all public repositories from a GitHub user or organization.
550 </p>
551 <form
552 class="import-option-form"
553 method="post"
554 action="/import/github/user"
555 data-import-form
556 >
557 <div class="import-row">
558 <input
559 type="text"
560 name="github_username"
561 required
562 placeholder="GitHub username or org"
563 aria-label="GitHub username or org"
564 class="import-input"
565 />
566 <button type="submit" class="btn btn-primary">
567 Import all repos
568 </button>
569 </div>
570 </form>
571 </div>
572
573 <div class="import-option">
574 <div class="import-option-head">
575 <span class="import-option-badge" aria-hidden="true">2</span>
576 <h3 class="import-option-title">Import a single repo</h3>
577 </div>
578 <p class="import-option-desc">
579 Import one specific repository by URL (https, ssh, or owner/repo).
580 </p>
581 <form
582 class="import-option-form"
583 method="post"
584 action="/import/github/repo"
585 data-import-form
586 >
587 <div class="import-row">
588 <input
589 type="text"
590 name="repo_url"
591 required
592 placeholder="https://github.com/owner/repo"
593 aria-label="Repository URL"
594 class="import-input"
595 />
596 <button type="submit" class="btn btn-primary">
597 Import
598 </button>
599 </div>
600 <div class="import-field">
601 <input
602 type="password"
603 name="github_token"
604 placeholder="Optional: GitHub PAT to also migrate Actions secret names"
605 aria-label="Optional GitHub personal access token for secrets migration"
606 autocomplete="off"
607 class="import-input is-mono"
608 />
609 <div class="import-field-hint">
610 Token is only used in this request — never stored.
611 </div>
612 </div>
613 </form>
614 </div>
615
616 <div class="import-option">
617 <div class="import-option-head">
618 <span class="import-option-badge" aria-hidden="true">3</span>
619 <h3 class="import-option-title">
620 Import with token (private repos)
621 </h3>
bdbd0deClaude622 </div>
7a99d47Claude623 <p class="import-option-desc">
624 Use a GitHub personal access token to import private repositories
625 too. Generate one at{" "}
626 <strong>github.com → Settings → Developer settings → Personal access tokens</strong>.
627 </p>
628 <form
629 class="import-option-form"
630 method="post"
631 action="/import/github/user"
632 data-import-form
633 >
634 <div class="import-field">
635 <label class="import-field-label">GitHub username</label>
636 <input
637 type="text"
638 name="github_username"
639 required
640 placeholder="GitHub username"
641 aria-label="GitHub username"
642 class="import-input"
643 />
644 </div>
645 <div class="import-field">
646 <label class="import-field-label">
647 Personal access token (repo scope)
648 </label>
649 <input
650 type="password"
651 name="github_token"
652 required
653 placeholder="ghp_xxxxxxxxxxxx"
654 aria-label="GitHub personal access token"
655 class="import-input is-mono"
656 />
657 </div>
658 <div class="import-field">
659 <button type="submit" class="btn btn-primary">
660 Import all repos (public + private)
661 </button>
662 </div>
663 </form>
664 </div>
bdbd0deClaude665 </div>
666 </div>
80bed05Claude667 <script dangerouslySetInnerHTML={{ __html: progressScript }} />
bdbd0deClaude668 </Layout>
669 );
670});
671
672// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
673
a4f3e24Claude674importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude675 const user = c.get("user")!;
676 const body = await c.req.parseBody();
677 const githubUsername = String(body.github_username || "").trim();
678 const githubToken = String(body.github_token || "").trim() || null;
679
680 if (!githubUsername) {
681 return c.redirect("/import?error=GitHub+username+is+required");
682 }
683
684 try {
685 // Fetch repos from GitHub API
686 const headers: Record<string, string> = {
687 Accept: "application/vnd.github.v3+json",
688 "User-Agent": "gluecron/1.0",
689 };
690 if (githubToken) {
691 headers.Authorization = `Bearer ${githubToken}`;
692 }
693
694 const repos: GitHubRepo[] = [];
695 let page = 1;
696 while (true) {
697 const url = githubToken
698 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
699 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
700
701 const res = await fetch(url, { headers });
702 if (!res.ok) {
703 const errText = await res.text();
704 return c.redirect(
705 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
706 );
707 }
708 const batch: GitHubRepo[] = await res.json();
709 if (batch.length === 0) break;
710 repos.push(...batch);
711 page++;
712 if (page > 10) break; // safety limit: 1000 repos
713 }
714
715 if (repos.length === 0) {
716 return c.redirect("/import?error=No+repositories+found+for+this+user");
717 }
718
719 // Import each repo
720 let imported = 0;
721 let skipped = 0;
80bed05Claude722 let failed = 0;
bdbd0deClaude723
724 for (const ghRepo of repos) {
80bed05Claude725 const targetName = sanitizeRepoName(ghRepo.name);
726
727 // Check uniqueness in THIS user's namespace (owner+name is the
728 // real unique key — the previous check ignored ownerId and
729 // could skip repos other users happened to share a name with).
bdbd0deClaude730 const [existing] = await db
731 .select()
732 .from(repositories)
733 .where(
80bed05Claude734 and(
735 eq(repositories.ownerId, user.id),
736 eq(repositories.name, targetName)
737 )
bdbd0deClaude738 )
739 .limit(1);
740
80bed05Claude741 if (existing) {
bdbd0deClaude742 skipped++;
743 continue;
744 }
745
746 try {
747 await importSingleRepo(user, ghRepo, githubToken);
748 imported++;
749 } catch (err) {
80bed05Claude750 failed++;
609a27aClaude751 // Belt + suspenders: even though importSingleRepo already redacts
752 // tokens before throwing, scrub here in case a future code path
753 // bypasses that. Tokens in console.error end up in journald and
754 // any log shipper — a single leak is forever.
755 const msg = err instanceof Error ? err.message : String(err);
756 console.error(
757 `[import] failed to import ${ghRepo.full_name}:`,
758 scrubSecrets(msg, githubToken)
759 );
bdbd0deClaude760 }
761 }
762
80bed05Claude763 const summary =
764 `${imported}+imported%2C+${skipped}+skipped` +
765 (failed > 0 ? `%2C+${failed}+failed` : "");
766 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
bdbd0deClaude767 } catch (err) {
768 console.error("[import] error:", err);
769 return c.redirect(
770 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
771 );
772 }
773});
774
775// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
776
a4f3e24Claude777importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude778 const user = c.get("user")!;
779 const body = await c.req.parseBody();
780 const repoUrl = String(body.repo_url || "").trim();
f390cfaCC LABS App781 // Optional PAT — when supplied, used after the import succeeds to also
782 // migrate GitHub Actions secret NAMES (values are never exposed by the
783 // API) into placeholder rows the user can paste values into.
784 const optionalToken = String(body.github_token || "").trim() || null;
bdbd0deClaude785
786 if (!repoUrl) {
787 return c.redirect("/import?error=Repository+URL+is+required");
788 }
789
c63b860Claude790 // P4 — same quota gate as /new + /api/v2/repos. Imports count toward
791 // the user's plan limit.
792 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
793 const gate = await checkRepoCreateAllowed(user.id);
794 if (!gate.ok) {
795 return c.redirect(`/import?error=${encodeURIComponent(gate.reason)}`);
796 }
797
80bed05Claude798 const parsed = parseGithubUrl(repoUrl);
799 if (!parsed) {
800 return c.redirect(
801 "/import?error=" +
802 encodeURIComponent(
803 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
804 )
805 );
bdbd0deClaude806 }
807
80bed05Claude808 const { owner: ghOwner, repo: ghRepo } = parsed;
809
810 // Guard against double-import before we spin up a clone subprocess.
811 const targetName = sanitizeRepoName(ghRepo);
812 const [existing] = await db
813 .select()
814 .from(repositories)
815 .where(
816 and(
817 eq(repositories.ownerId, user.id),
818 eq(repositories.name, targetName)
819 )
820 )
821 .limit(1);
822 if (existing) {
823 return c.redirect(
824 `/import?error=${encodeURIComponent(
825 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
826 )}`
827 );
828 }
bdbd0deClaude829
830 try {
831 // Fetch repo info
f390cfaCC LABS App832 const metaHeaders: Record<string, string> = {
833 Accept: "application/vnd.github.v3+json",
834 "User-Agent": "gluecron/1.0",
835 };
836 if (optionalToken) metaHeaders.Authorization = `Bearer ${optionalToken}`;
bdbd0deClaude837 const res = await fetch(
838 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
f390cfaCC LABS App839 { headers: metaHeaders }
bdbd0deClaude840 );
841
842 if (!res.ok) {
4b66018Claude843 // Try to pull GitHub's actual error message — "Bad credentials",
844 // "Not Found", or rate-limit text is far more useful than a bare code.
845 let detail = "";
846 try {
847 const body = await res.json();
848 detail = body?.message ? ` — ${String(body.message)}` : "";
849 } catch {
850 /* non-JSON body, skip */
851 }
80bed05Claude852 return c.redirect(
853 `/import?error=${encodeURIComponent(
4b66018Claude854 `GitHub said ${res.status}${detail}. Check the URL and that the repo is public (or supply a token).`
80bed05Claude855 )}`
856 );
bdbd0deClaude857 }
858
4b66018Claude859 let ghRepoData: GitHubRepo;
860 try {
861 ghRepoData = (await res.json()) as GitHubRepo;
862 } catch (err) {
863 console.error("[import] non-JSON response from GitHub:", err);
864 return c.redirect(
865 `/import?error=${encodeURIComponent(
866 "GitHub returned a response we couldn't parse. Try again in a moment."
867 )}`
868 );
869 }
bdbd0deClaude870
f390cfaCC LABS App871 await importSingleRepo(user, ghRepoData, optionalToken);
872
873 // Block T1 — opportunistically migrate GitHub Actions secret NAMES
874 // (values are never exposed by GitHub's API). If a token was supplied
875 // on this request, list the secret names + pre-create empty
876 // placeholders, then redirect the user to the paste-each-value
877 // checklist. Fire-and-forget semantics: any failure (no token, network
878 // error, no secrets, DB blip) collapses to "skip the checklist step"
879 // and we redirect straight to the repo home.
4b66018Claude880 //
881 // CRITICAL — never reconstruct the redirect path from local strings:
882 // sanitizeRepoName/case/etc could differ from what importSingleRepo
883 // actually wrote. We had a post-import 404 in production because the
884 // shadowed targetName drifted from the DB row. Read the row back.
885 const [storedRepo] = await db
886 .select({ id: repositories.id, name: repositories.name })
887 .from(repositories)
888 .where(
889 and(
890 eq(repositories.ownerId, user.id),
891 eq(repositories.name, sanitizeRepoName(ghRepoData.name))
892 )
893 )
894 .limit(1);
895
896 if (!storedRepo) {
897 // The clone succeeded but the insert apparently didn't — fail loud
898 // instead of redirecting the user into a 404.
899 console.error(
900 "[import] repo missing after import — owner=" + user.username +
901 " ghRepo=" + ghRepoData.full_name
902 );
903 return c.redirect(
904 `/import?error=${encodeURIComponent(
905 "Import partially completed — please refresh and try again."
906 )}`
907 );
908 }
909
910 const targetName = storedRepo.name;
f390cfaCC LABS App911 let secretsRedirect: string | null = null;
912 if (optionalToken) {
913 try {
4b66018Claude914 const { importSecretsForRepo } = await import(
915 "../lib/github-secrets-import"
916 );
917 const result = await importSecretsForRepo({
918 githubOwner: ghOwner,
919 githubRepo: ghRepo,
920 githubToken: optionalToken,
921 gluecronRepositoryId: storedRepo.id,
922 importedByUserId: user.id,
923 });
924 if (result.imported.length > 0) {
925 secretsRedirect = `/${user.username}/${targetName}/import/secrets`;
f390cfaCC LABS App926 }
927 } catch (err) {
609a27aClaude928 const msg = err instanceof Error ? err.message : String(err);
929 console.error(
930 "[import] secrets migration skipped:",
931 scrubSecrets(msg, optionalToken)
932 );
f390cfaCC LABS App933 }
934 }
bdbd0deClaude935
f390cfaCC LABS App936 return c.redirect(secretsRedirect ?? `/${user.username}/${targetName}`);
bdbd0deClaude937 } catch (err) {
609a27aClaude938 const msg = err instanceof Error ? err.message : String(err);
939 const safe = scrubSecrets(msg, optionalToken);
940 console.error("[import] error:", safe);
bdbd0deClaude941 return c.redirect(
609a27aClaude942 `/import?error=${encodeURIComponent(`Import failed: ${safe}`)}`
bdbd0deClaude943 );
944 }
945});
946
947// ─── CORE IMPORT FUNCTION ────────────────────────────────────
948
949async function importSingleRepo(
950 user: { id: string; username: string },
951 ghRepo: GitHubRepo,
952 token: string | null
953): Promise<void> {
80bed05Claude954 const safeName = sanitizeRepoName(ghRepo.name);
bdbd0deClaude955 const destPath = join(
956 config.gitReposPath,
957 user.username,
80bed05Claude958 `${safeName}.git`
bdbd0deClaude959 );
960
961 // Ensure parent directory exists
962 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
963
964 // Clone bare from GitHub (with token if provided for private repos)
80bed05Claude965 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
bdbd0deClaude966
967 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
968
969 const proc = Bun.spawn(
970 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
971 {
972 stdout: "pipe",
973 stderr: "pipe",
974 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
975 }
976 );
977 const stderr = await new Response(proc.stderr).text();
978 const exitCode = await proc.exited;
979
980 if (exitCode !== 0) {
80bed05Claude981 // Never echo the token back in an error message.
982 const sanitized = token
983 ? stderr.replaceAll(token, "***")
984 : stderr;
985 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
bdbd0deClaude986 }
987
988 // Insert into database
989 await db.insert(repositories).values({
80bed05Claude990 name: safeName,
bdbd0deClaude991 ownerId: user.id,
992 description: ghRepo.description,
993 isPrivate: ghRepo.private,
994 defaultBranch: ghRepo.default_branch || "main",
995 diskPath: destPath,
996 starCount: 0,
997 });
998
999 console.log(`[import] ${ghRepo.full_name} imported successfully`);
1000}
1001
1002export default importRoutes;