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.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.tsxBlame1001 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";
15import type { AuthEnv } from "../middleware/auth";
16import { config } from "../lib/config";
17import { mkdir } from "fs/promises";
18import { join } from "path";
80bed05Claude19import {
20 parseGithubUrl,
21 sanitizeRepoName,
22 buildCloneUrl,
609a27aClaude23 scrubSecrets,
80bed05Claude24} from "../lib/import-helper";
bdbd0deClaude25
26const importRoutes = new Hono<AuthEnv>();
27
28importRoutes.use("*", softAuth);
29
30interface GitHubRepo {
31 name: string;
32 full_name: string;
33 description: string | null;
34 private: boolean;
35 clone_url: string;
36 default_branch: string;
37 stargazers_count: number;
38 fork: boolean;
39 language: string | null;
40}
41
7a99d47Claude42// ─── PAGE-SCOPED CSS ─────────────────────────────────────────
43// All classes prefixed with .import- so the block cannot bleed into
44// neighbouring routes. Mirrors the dashboard-hero + settings polish.
45const importStyles = `
eed4684Claude46 .import-container { max-width: 1200px; margin: 0 auto; }
7a99d47Claude47
48 /* ─── Hero ─── */
49 .import-hero {
50 position: relative;
51 margin-bottom: var(--space-6);
52 padding: var(--space-5) var(--space-6);
53 background: var(--bg-elevated);
54 border: 1px solid var(--border);
55 border-radius: 16px;
56 overflow: hidden;
57 }
58 .import-hero::before {
59 content: '';
60 position: absolute;
61 top: 0; left: 0; right: 0;
62 height: 2px;
63 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
64 opacity: 0.75;
65 pointer-events: none;
66 }
67 .import-hero-bg {
68 position: absolute;
69 inset: -20% -10% auto auto;
70 width: 360px; height: 360px;
71 pointer-events: none;
72 z-index: 0;
73 }
74 .import-hero-orb {
75 position: absolute;
76 inset: 0;
77 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
78 filter: blur(80px);
79 opacity: 0.7;
80 animation: importHeroOrb 14s ease-in-out infinite;
81 }
82 @keyframes importHeroOrb {
83 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
84 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
85 }
86 @media (prefers-reduced-motion: reduce) {
87 .import-hero-orb { animation: none; }
88 }
89 .import-hero-inner {
90 position: relative;
91 z-index: 1;
92 max-width: 640px;
93 }
94 .import-hero-eyebrow {
95 font-size: 13px;
96 color: var(--text-muted);
97 margin-bottom: var(--space-2);
98 letter-spacing: -0.005em;
99 }
100 .import-hero-eyebrow-dot {
101 display: inline-block;
102 width: 6px; height: 6px;
103 border-radius: 50%;
104 background: var(--accent);
105 box-shadow: 0 0 8px rgba(140,109,255,0.6);
106 margin-right: 8px;
107 vertical-align: 1px;
108 }
109 .import-hero-title {
110 font-size: clamp(28px, 4vw, 40px);
111 font-family: var(--font-display);
112 font-weight: 800;
113 letter-spacing: -0.028em;
114 line-height: 1.05;
115 margin: 0 0 var(--space-2);
116 color: var(--text-strong);
117 }
118 .import-hero-title .gradient-text {
119 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
120 -webkit-background-clip: text;
121 background-clip: text;
122 -webkit-text-fill-color: transparent;
123 color: transparent;
124 }
125 .import-hero-sub {
126 font-size: 15px;
127 color: var(--text-muted);
128 margin: 0;
129 line-height: 1.55;
130 }
131
132 /* ─── Bulk CTA strip (between hero and options) ─── */
133 .import-bulk-cta {
134 position: relative;
135 display: flex;
136 align-items: center;
137 justify-content: space-between;
138 gap: var(--space-4);
139 padding: 14px 18px;
140 margin-bottom: var(--space-5);
141 background: var(--bg-elevated);
142 border: 1px solid var(--border);
143 border-radius: 14px;
144 text-decoration: none;
145 color: inherit;
146 overflow: hidden;
147 transition: border-color 140ms ease, transform 140ms ease;
148 }
149 .import-bulk-cta:hover {
150 border-color: rgba(140,109,255,0.45);
151 transform: translateY(-1px);
152 }
153 .import-bulk-cta::before {
154 content: '';
155 position: absolute;
156 left: 0; top: 0; bottom: 0;
157 width: 3px;
158 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
159 }
160 .import-bulk-cta-text strong {
161 display: block;
162 font-family: var(--font-display);
163 font-weight: 700;
164 font-size: 15px;
165 color: var(--text-strong);
166 letter-spacing: -0.012em;
167 }
168 .import-bulk-cta-text .import-bulk-cta-sub {
169 font-size: 13px;
170 color: var(--text-muted);
171 margin-top: 3px;
172 line-height: 1.4;
173 }
174 .import-bulk-cta-arrow {
175 flex-shrink: 0;
176 font-size: 18px;
177 color: var(--accent);
178 transition: transform 140ms ease;
179 }
180 .import-bulk-cta:hover .import-bulk-cta-arrow { transform: translateX(3px); }
181
182 /* ─── Banners ─── */
183 .import-banner {
184 position: relative;
185 padding: 14px 16px 14px 44px;
186 margin-bottom: var(--space-5);
187 border-radius: 12px;
188 border: 1px solid var(--border);
189 background: var(--bg-elevated);
190 font-size: 14px;
191 line-height: 1.5;
192 }
193 .import-banner::before {
194 content: '';
195 position: absolute;
196 left: 14px; top: 18px;
197 width: 14px; height: 14px;
198 border-radius: 50%;
199 }
200 .import-banner-success {
201 border-color: rgba(63, 185, 80, 0.32);
202 background: linear-gradient(180deg, rgba(63,185,80,0.06) 0%, var(--bg-elevated) 100%);
203 }
204 .import-banner-success::before {
205 background: radial-gradient(circle, #3fb950 30%, transparent 70%);
206 box-shadow: 0 0 10px rgba(63,185,80,0.5);
207 }
208 .import-banner-error {
209 border-color: rgba(248, 81, 73, 0.32);
210 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
211 }
212 .import-banner-error::before {
213 background: radial-gradient(circle, #f85149 30%, transparent 70%);
214 box-shadow: 0 0 10px rgba(248,81,73,0.5);
215 }
216 .import-banner-title { font-weight: 600; color: var(--text-strong); }
217 .import-banner-detail { color: var(--text-muted); margin-top: 4px; font-size: 13.5px; }
218 .import-banner-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
219
220 /* ─── Progress card with step pipeline ─── */
221 .import-progress-card {
222 position: relative;
223 margin-bottom: var(--space-5);
224 padding: 18px 18px 16px;
225 background: var(--bg-elevated);
226 border: 1px solid var(--border);
227 border-radius: 14px;
228 overflow: hidden;
229 }
230 .import-progress-card::before {
231 content: '';
232 position: absolute;
233 top: 0; left: 0; right: 0;
234 height: 2px;
235 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 50%, #8c6dff 100%);
236 background-size: 200% 100%;
237 animation: importProgressShimmer 2.2s linear infinite;
238 pointer-events: none;
239 }
240 @keyframes importProgressShimmer {
241 0% { background-position: 0% 0%; }
242 100% { background-position: 200% 0%; }
243 }
244 @media (prefers-reduced-motion: reduce) {
245 .import-progress-card::before { animation: none; }
246 }
247 .import-progress-title {
248 font-family: var(--font-display);
249 font-weight: 700;
250 font-size: 16px;
251 letter-spacing: -0.012em;
252 color: var(--text-strong);
253 margin: 0 0 4px;
254 }
255 .import-progress-sub {
256 font-size: 13px;
257 color: var(--text-muted);
258 margin: 0 0 14px;
259 }
260 .import-steps {
261 display: grid;
262 grid-template-columns: repeat(4, 1fr);
263 gap: 8px;
264 }
265 .import-step {
266 position: relative;
267 padding: 10px 12px;
268 background: var(--bg-secondary);
269 border: 1px solid var(--border-subtle);
270 border-radius: 10px;
271 font-size: 12.5px;
272 color: var(--text-muted);
273 display: flex;
274 align-items: center;
275 gap: 8px;
276 }
277 .import-step-dot {
278 width: 16px; height: 16px;
279 border-radius: 50%;
280 background: var(--bg-tertiary, #1a1d2a);
281 border: 1.5px solid var(--border-strong);
282 flex-shrink: 0;
283 position: relative;
284 }
285 .import-step.is-active {
286 border-color: rgba(140,109,255,0.45);
287 color: var(--text-strong);
288 background: rgba(140,109,255,0.07);
289 }
290 .import-step.is-active .import-step-dot {
291 background: var(--accent);
292 border-color: var(--accent);
293 box-shadow: 0 0 10px rgba(140,109,255,0.6);
294 animation: importStepPulse 1.4s ease-in-out infinite;
295 }
296 @keyframes importStepPulse {
297 0%, 100% { box-shadow: 0 0 8px rgba(140,109,255,0.4); }
298 50% { box-shadow: 0 0 14px rgba(140,109,255,0.85); }
299 }
300 @media (prefers-reduced-motion: reduce) {
301 .import-step.is-active .import-step-dot { animation: none; }
302 }
303 .import-step-label { font-weight: 500; }
304 @media (max-width: 640px) {
305 .import-steps { grid-template-columns: repeat(2, 1fr); }
306 }
307
308 /* ─── Option cards ─── */
309 .import-options {
310 display: grid;
311 gap: var(--space-4);
312 }
313 .import-option {
314 position: relative;
315 background: var(--bg-elevated);
316 border: 1px solid var(--border);
317 border-radius: 14px;
318 padding: var(--space-4) var(--space-5);
319 transition: border-color 140ms ease, box-shadow 140ms ease;
320 }
321 .import-option:hover { border-color: var(--border-strong); }
322 .import-option-head {
323 display: flex;
324 align-items: center;
325 gap: 10px;
326 margin-bottom: 4px;
327 }
328 .import-option-badge {
329 display: inline-flex;
330 align-items: center;
331 justify-content: center;
332 width: 24px; height: 24px;
333 border-radius: 8px;
334 background: linear-gradient(135deg, rgba(140,109,255,0.18) 0%, rgba(54,197,214,0.18) 100%);
335 color: var(--accent);
336 font-size: 12px;
337 font-weight: 700;
338 border: 1px solid rgba(140,109,255,0.28);
339 }
340 .import-option-title {
341 font-family: var(--font-display);
342 font-size: 16px;
343 font-weight: 700;
344 letter-spacing: -0.014em;
345 color: var(--text-strong);
346 margin: 0;
347 }
348 .import-option-desc {
349 font-size: 13.5px;
350 color: var(--text-muted);
351 line-height: 1.5;
352 margin: 0 0 14px;
353 }
354 .import-option-form .import-row {
355 display: flex;
356 gap: 8px;
357 }
358 .import-input {
359 flex: 1;
360 width: 100%;
361 padding: 9px 12px;
362 background: var(--bg);
363 border: 1px solid var(--border-strong);
364 border-radius: 8px;
365 color: var(--text);
366 font-size: 14px;
367 font-family: var(--font-sans);
368 outline: none;
369 transition: border-color 120ms ease, box-shadow 120ms ease;
370 }
371 .import-input:focus {
372 border-color: var(--accent);
373 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
374 }
375 .import-input.is-mono { font-family: var(--font-mono); font-size: 13px; }
376 .import-field { margin-top: 10px; }
377 .import-field-label {
378 display: block;
379 font-size: 12.5px;
380 font-weight: 600;
381 color: var(--text-strong);
382 margin-bottom: 4px;
383 letter-spacing: -0.005em;
384 }
385 .import-field-hint {
386 font-size: 12px;
387 color: var(--text-muted);
388 margin-top: 4px;
389 line-height: 1.45;
390 }
391
392 @media (max-width: 600px) {
393 .import-option-form .import-row { flex-direction: column; }
394 .import-option-form .import-row .btn { width: 100%; }
395 }
396`;
397
bdbd0deClaude398// ─── IMPORT PAGE ─────────────────────────────────────────────
399
34c4488Claude400importRoutes.get("/import", requireAuth, async (c) => {
bdbd0deClaude401 const user = c.get("user")!;
402 const success = c.req.query("success");
403 const error = c.req.query("error");
404 const imported = c.req.query("imported");
405
80bed05Claude406 // Inline progress banner: the clone subprocess can take 30+s for big
407 // repos, so give the user visible feedback while the POST is in flight.
408 // Pure client-side — no extra routes, no websockets, no polling.
7a99d47Claude409 // Polished step pipeline: clone → analyze → enable gates → done.
80bed05Claude410 const progressScript = `
411 (function () {
412 var forms = document.querySelectorAll('form[data-import-form]');
413 var banner = document.getElementById('import-progress');
414 if (!banner) return;
7a99d47Claude415 var steps = banner.querySelectorAll('[data-step]');
416 function setStep(idx) {
417 steps.forEach(function (el, i) {
418 if (i <= idx) el.classList.add('is-active');
419 else el.classList.remove('is-active');
420 });
421 }
80bed05Claude422 forms.forEach(function (form) {
423 form.addEventListener('submit', function () {
424 // Validate non-empty required fields before showing progress.
425 var req = form.querySelectorAll('[required]');
426 for (var i = 0; i < req.length; i++) {
427 if (!req[i].value || !req[i].value.trim()) return;
428 }
429 banner.style.display = 'block';
7a99d47Claude430 setStep(0);
431 // Walk through 'analyze' and 'gates' steps on a timer so the
432 // pipeline visually advances even before the redirect lands.
433 setTimeout(function () { setStep(1); }, 1800);
434 setTimeout(function () { setStep(2); }, 4200);
80bed05Claude435 var btns = form.querySelectorAll('button[type="submit"]');
436 btns.forEach(function (b) {
437 b.disabled = true;
438 b.textContent = 'Importing…';
439 });
440 // Scroll banner into view so user sees progress above the fold.
441 try { banner.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
442 });
443 });
444 })();
445 `;
446
bdbd0deClaude447 return c.html(
448 <Layout title="Import from GitHub" user={user}>
7a99d47Claude449 <style dangerouslySetInnerHTML={{ __html: importStyles }} />
450 <div class="import-container">
451 {/* ─── Hero ─── */}
452 <div class="import-hero">
453 <div class="import-hero-bg" aria-hidden="true">
454 <div class="import-hero-orb" />
455 </div>
456 <div class="import-hero-inner">
457 <div class="import-hero-eyebrow">
458 <span class="import-hero-eyebrow-dot" aria-hidden="true" />
459 GitHub migration
460 </div>
461 <h1 class="import-hero-title">
462 Import from{" "}
463 <span class="gradient-text">GitHub</span>.
464 </h1>
465 <p class="import-hero-sub">
466 Migrate your repositories from GitHub to gluecron automatically —
467 all branches, all history, all code. One click, walk away, come
468 back to everything migrated.
469 </p>
470 </div>
471 </div>
472
473 {/* ─── Bulk-import shortcut ─── */}
474 <a href="/import/bulk" class="import-bulk-cta">
475 <div class="import-bulk-cta-text">
476 <strong>Migrating a whole org? Try the bulk importer.</strong>
477 <div class="import-bulk-cta-sub">
478 Paste a GitHub org name + token and clone every repo in one shot.
479 </div>
14c3cc8Claude480 </div>
7a99d47Claude481 <div class="import-bulk-cta-arrow" aria-hidden="true">→</div>
14c3cc8Claude482 </a>
7a99d47Claude483
484 {/* ─── Success / error banners ─── */}
bdbd0deClaude485 {success && (
7a99d47Claude486 <div class="import-banner import-banner-success" role="status">
487 <div class="import-banner-title">{decodeURIComponent(success)}</div>
bdbd0deClaude488 {imported && (
7a99d47Claude489 <div class="import-banner-detail">
bdbd0deClaude490 Successfully imported {decodeURIComponent(imported)} repositories.
491 </div>
492 )}
7a99d47Claude493 <div class="import-banner-actions">
494 <a href={`/${user.username}`} class="btn btn-primary">
80bed05Claude495 View my repositories
496 </a>
497 <a href="/explore" class="btn">Explore</a>
498 </div>
bdbd0deClaude499 </div>
500 )}
501 {error && (
7a99d47Claude502 <div class="import-banner import-banner-error" role="alert">
503 <div class="import-banner-title">Import didn't complete</div>
504 <div class="import-banner-detail">{decodeURIComponent(error)}</div>
505 </div>
bdbd0deClaude506 )}
507
7a99d47Claude508 {/* ─── In-flight progress pipeline ─── */}
80bed05Claude509 <div
510 id="import-progress"
7a99d47Claude511 class="import-progress-card"
80bed05Claude512 role="status"
513 aria-live="polite"
7a99d47Claude514 style="display: none"
80bed05Claude515 >
7a99d47Claude516 <div class="import-progress-title">Import in progress</div>
517 <div class="import-progress-sub">
518 Large repositories can take 30+ seconds — don't close this tab.
80bed05Claude519 </div>
7a99d47Claude520 <div class="import-steps">
521 <div class="import-step" data-step="0">
522 <span class="import-step-dot" aria-hidden="true" />
523 <span class="import-step-label">Clone</span>
bdbd0deClaude524 </div>
7a99d47Claude525 <div class="import-step" data-step="1">
526 <span class="import-step-dot" aria-hidden="true" />
527 <span class="import-step-label">Analyze</span>
528 </div>
529 <div class="import-step" data-step="2">
530 <span class="import-step-dot" aria-hidden="true" />
531 <span class="import-step-label">Enable gates</span>
bdbd0deClaude532 </div>
7a99d47Claude533 <div class="import-step" data-step="3">
534 <span class="import-step-dot" aria-hidden="true" />
535 <span class="import-step-label">Done</span>
f390cfaCC LABS App536 </div>
7a99d47Claude537 </div>
bdbd0deClaude538 </div>
539
7a99d47Claude540 {/* ─── Option cards ─── */}
541 <div class="import-options">
542 <div class="import-option">
543 <div class="import-option-head">
544 <span class="import-option-badge" aria-hidden="true">1</span>
545 <h3 class="import-option-title">Import by username</h3>
bdbd0deClaude546 </div>
7a99d47Claude547 <p class="import-option-desc">
548 Import all public repositories from a GitHub user or organization.
549 </p>
550 <form
551 class="import-option-form"
552 method="post"
553 action="/import/github/user"
554 data-import-form
555 >
556 <div class="import-row">
557 <input
558 type="text"
559 name="github_username"
560 required
561 placeholder="GitHub username or org"
562 aria-label="GitHub username or org"
563 class="import-input"
564 />
565 <button type="submit" class="btn btn-primary">
566 Import all repos
567 </button>
568 </div>
569 </form>
570 </div>
571
572 <div class="import-option">
573 <div class="import-option-head">
574 <span class="import-option-badge" aria-hidden="true">2</span>
575 <h3 class="import-option-title">Import a single repo</h3>
576 </div>
577 <p class="import-option-desc">
578 Import one specific repository by URL (https, ssh, or owner/repo).
579 </p>
580 <form
581 class="import-option-form"
582 method="post"
583 action="/import/github/repo"
584 data-import-form
585 >
586 <div class="import-row">
587 <input
588 type="text"
589 name="repo_url"
590 required
591 placeholder="https://github.com/owner/repo"
592 aria-label="Repository URL"
593 class="import-input"
594 />
595 <button type="submit" class="btn btn-primary">
596 Import
597 </button>
598 </div>
599 <div class="import-field">
600 <input
601 type="password"
602 name="github_token"
603 placeholder="Optional: GitHub PAT to also migrate Actions secret names"
604 aria-label="Optional GitHub personal access token for secrets migration"
605 autocomplete="off"
606 class="import-input is-mono"
607 />
608 <div class="import-field-hint">
609 Token is only used in this request — never stored.
610 </div>
611 </div>
612 </form>
613 </div>
614
615 <div class="import-option">
616 <div class="import-option-head">
617 <span class="import-option-badge" aria-hidden="true">3</span>
618 <h3 class="import-option-title">
619 Import with token (private repos)
620 </h3>
bdbd0deClaude621 </div>
7a99d47Claude622 <p class="import-option-desc">
623 Use a GitHub personal access token to import private repositories
624 too. Generate one at{" "}
625 <strong>github.com → Settings → Developer settings → Personal access tokens</strong>.
626 </p>
627 <form
628 class="import-option-form"
629 method="post"
630 action="/import/github/user"
631 data-import-form
632 >
633 <div class="import-field">
634 <label class="import-field-label">GitHub username</label>
635 <input
636 type="text"
637 name="github_username"
638 required
639 placeholder="GitHub username"
640 aria-label="GitHub username"
641 class="import-input"
642 />
643 </div>
644 <div class="import-field">
645 <label class="import-field-label">
646 Personal access token (repo scope)
647 </label>
648 <input
649 type="password"
650 name="github_token"
651 required
652 placeholder="ghp_xxxxxxxxxxxx"
653 aria-label="GitHub personal access token"
654 class="import-input is-mono"
655 />
656 </div>
657 <div class="import-field">
658 <button type="submit" class="btn btn-primary">
659 Import all repos (public + private)
660 </button>
661 </div>
662 </form>
663 </div>
bdbd0deClaude664 </div>
665 </div>
80bed05Claude666 <script dangerouslySetInnerHTML={{ __html: progressScript }} />
bdbd0deClaude667 </Layout>
668 );
669});
670
671// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
672
34c4488Claude673importRoutes.post("/import/github/user", requireAuth, async (c) => {
bdbd0deClaude674 const user = c.get("user")!;
675 const body = await c.req.parseBody();
676 const githubUsername = String(body.github_username || "").trim();
677 const githubToken = String(body.github_token || "").trim() || null;
678
679 if (!githubUsername) {
680 return c.redirect("/import?error=GitHub+username+is+required");
681 }
682
683 try {
684 // Fetch repos from GitHub API
685 const headers: Record<string, string> = {
686 Accept: "application/vnd.github.v3+json",
687 "User-Agent": "gluecron/1.0",
688 };
689 if (githubToken) {
690 headers.Authorization = `Bearer ${githubToken}`;
691 }
692
693 const repos: GitHubRepo[] = [];
694 let page = 1;
695 while (true) {
696 const url = githubToken
697 ? `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner`
698 : `https://api.github.com/users/${githubUsername}/repos?per_page=100&page=${page}`;
699
700 const res = await fetch(url, { headers });
701 if (!res.ok) {
702 const errText = await res.text();
703 return c.redirect(
704 `/import?error=${encodeURIComponent(`GitHub API error (${res.status}): ${errText.slice(0, 100)}`)}`
705 );
706 }
707 const batch: GitHubRepo[] = await res.json();
708 if (batch.length === 0) break;
709 repos.push(...batch);
710 page++;
711 if (page > 10) break; // safety limit: 1000 repos
712 }
713
714 if (repos.length === 0) {
715 return c.redirect("/import?error=No+repositories+found+for+this+user");
716 }
717
718 // Import each repo
719 let imported = 0;
720 let skipped = 0;
80bed05Claude721 let failed = 0;
bdbd0deClaude722
723 for (const ghRepo of repos) {
80bed05Claude724 const targetName = sanitizeRepoName(ghRepo.name);
725
726 // Check uniqueness in THIS user's namespace (owner+name is the
727 // real unique key — the previous check ignored ownerId and
728 // could skip repos other users happened to share a name with).
bdbd0deClaude729 const [existing] = await db
730 .select()
731 .from(repositories)
732 .where(
80bed05Claude733 and(
734 eq(repositories.ownerId, user.id),
735 eq(repositories.name, targetName)
736 )
bdbd0deClaude737 )
738 .limit(1);
739
80bed05Claude740 if (existing) {
bdbd0deClaude741 skipped++;
742 continue;
743 }
744
745 try {
746 await importSingleRepo(user, ghRepo, githubToken);
747 imported++;
748 } catch (err) {
80bed05Claude749 failed++;
609a27aClaude750 // Belt + suspenders: even though importSingleRepo already redacts
751 // tokens before throwing, scrub here in case a future code path
752 // bypasses that. Tokens in console.error end up in journald and
753 // any log shipper — a single leak is forever.
754 const msg = err instanceof Error ? err.message : String(err);
755 console.error(
756 `[import] failed to import ${ghRepo.full_name}:`,
757 scrubSecrets(msg, githubToken)
758 );
bdbd0deClaude759 }
760 }
761
80bed05Claude762 const summary =
763 `${imported}+imported%2C+${skipped}+skipped` +
764 (failed > 0 ? `%2C+${failed}+failed` : "");
765 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
bdbd0deClaude766 } catch (err) {
767 console.error("[import] error:", err);
768 return c.redirect(
769 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
770 );
771 }
772});
773
774// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
775
34c4488Claude776importRoutes.post("/import/github/repo", requireAuth, async (c) => {
bdbd0deClaude777 const user = c.get("user")!;
778 const body = await c.req.parseBody();
779 const repoUrl = String(body.repo_url || "").trim();
f390cfaCC LABS App780 // Optional PAT — when supplied, used after the import succeeds to also
781 // migrate GitHub Actions secret NAMES (values are never exposed by the
782 // API) into placeholder rows the user can paste values into.
783 const optionalToken = String(body.github_token || "").trim() || null;
bdbd0deClaude784
785 if (!repoUrl) {
786 return c.redirect("/import?error=Repository+URL+is+required");
787 }
788
c63b860Claude789 // P4 — same quota gate as /new + /api/v2/repos. Imports count toward
790 // the user's plan limit.
791 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
792 const gate = await checkRepoCreateAllowed(user.id);
793 if (!gate.ok) {
794 return c.redirect(`/import?error=${encodeURIComponent(gate.reason)}`);
795 }
796
80bed05Claude797 const parsed = parseGithubUrl(repoUrl);
798 if (!parsed) {
799 return c.redirect(
800 "/import?error=" +
801 encodeURIComponent(
802 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
803 )
804 );
bdbd0deClaude805 }
806
80bed05Claude807 const { owner: ghOwner, repo: ghRepo } = parsed;
808
809 // Guard against double-import before we spin up a clone subprocess.
810 const targetName = sanitizeRepoName(ghRepo);
811 const [existing] = await db
812 .select()
813 .from(repositories)
814 .where(
815 and(
816 eq(repositories.ownerId, user.id),
817 eq(repositories.name, targetName)
818 )
819 )
820 .limit(1);
821 if (existing) {
822 return c.redirect(
823 `/import?error=${encodeURIComponent(
824 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
825 )}`
826 );
827 }
bdbd0deClaude828
829 try {
830 // Fetch repo info
f390cfaCC LABS App831 const metaHeaders: Record<string, string> = {
832 Accept: "application/vnd.github.v3+json",
833 "User-Agent": "gluecron/1.0",
834 };
835 if (optionalToken) metaHeaders.Authorization = `Bearer ${optionalToken}`;
bdbd0deClaude836 const res = await fetch(
837 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
f390cfaCC LABS App838 { headers: metaHeaders }
bdbd0deClaude839 );
840
841 if (!res.ok) {
4b66018Claude842 // Try to pull GitHub's actual error message — "Bad credentials",
843 // "Not Found", or rate-limit text is far more useful than a bare code.
844 let detail = "";
845 try {
846 const body = await res.json();
847 detail = body?.message ? ` — ${String(body.message)}` : "";
848 } catch {
849 /* non-JSON body, skip */
850 }
80bed05Claude851 return c.redirect(
852 `/import?error=${encodeURIComponent(
4b66018Claude853 `GitHub said ${res.status}${detail}. Check the URL and that the repo is public (or supply a token).`
80bed05Claude854 )}`
855 );
bdbd0deClaude856 }
857
4b66018Claude858 let ghRepoData: GitHubRepo;
859 try {
860 ghRepoData = (await res.json()) as GitHubRepo;
861 } catch (err) {
862 console.error("[import] non-JSON response from GitHub:", err);
863 return c.redirect(
864 `/import?error=${encodeURIComponent(
865 "GitHub returned a response we couldn't parse. Try again in a moment."
866 )}`
867 );
868 }
bdbd0deClaude869
f390cfaCC LABS App870 await importSingleRepo(user, ghRepoData, optionalToken);
871
872 // Block T1 — opportunistically migrate GitHub Actions secret NAMES
873 // (values are never exposed by GitHub's API). If a token was supplied
874 // on this request, list the secret names + pre-create empty
875 // placeholders, then redirect the user to the paste-each-value
876 // checklist. Fire-and-forget semantics: any failure (no token, network
877 // error, no secrets, DB blip) collapses to "skip the checklist step"
878 // and we redirect straight to the repo home.
4b66018Claude879 //
880 // CRITICAL — never reconstruct the redirect path from local strings:
881 // sanitizeRepoName/case/etc could differ from what importSingleRepo
882 // actually wrote. We had a post-import 404 in production because the
883 // shadowed targetName drifted from the DB row. Read the row back.
884 const [storedRepo] = await db
885 .select({ id: repositories.id, name: repositories.name })
886 .from(repositories)
887 .where(
888 and(
889 eq(repositories.ownerId, user.id),
890 eq(repositories.name, sanitizeRepoName(ghRepoData.name))
891 )
892 )
893 .limit(1);
894
895 if (!storedRepo) {
896 // The clone succeeded but the insert apparently didn't — fail loud
897 // instead of redirecting the user into a 404.
898 console.error(
899 "[import] repo missing after import — owner=" + user.username +
900 " ghRepo=" + ghRepoData.full_name
901 );
902 return c.redirect(
903 `/import?error=${encodeURIComponent(
904 "Import partially completed — please refresh and try again."
905 )}`
906 );
907 }
908
909 const targetName = storedRepo.name;
f390cfaCC LABS App910 let secretsRedirect: string | null = null;
911 if (optionalToken) {
912 try {
4b66018Claude913 const { importSecretsForRepo } = await import(
914 "../lib/github-secrets-import"
915 );
916 const result = await importSecretsForRepo({
917 githubOwner: ghOwner,
918 githubRepo: ghRepo,
919 githubToken: optionalToken,
920 gluecronRepositoryId: storedRepo.id,
921 importedByUserId: user.id,
922 });
923 if (result.imported.length > 0) {
924 secretsRedirect = `/${user.username}/${targetName}/import/secrets`;
f390cfaCC LABS App925 }
926 } catch (err) {
609a27aClaude927 const msg = err instanceof Error ? err.message : String(err);
928 console.error(
929 "[import] secrets migration skipped:",
930 scrubSecrets(msg, optionalToken)
931 );
f390cfaCC LABS App932 }
933 }
bdbd0deClaude934
f390cfaCC LABS App935 return c.redirect(secretsRedirect ?? `/${user.username}/${targetName}`);
bdbd0deClaude936 } catch (err) {
609a27aClaude937 const msg = err instanceof Error ? err.message : String(err);
938 const safe = scrubSecrets(msg, optionalToken);
939 console.error("[import] error:", safe);
bdbd0deClaude940 return c.redirect(
609a27aClaude941 `/import?error=${encodeURIComponent(`Import failed: ${safe}`)}`
bdbd0deClaude942 );
943 }
944});
945
946// ─── CORE IMPORT FUNCTION ────────────────────────────────────
947
948async function importSingleRepo(
949 user: { id: string; username: string },
950 ghRepo: GitHubRepo,
951 token: string | null
952): Promise<void> {
80bed05Claude953 const safeName = sanitizeRepoName(ghRepo.name);
bdbd0deClaude954 const destPath = join(
955 config.gitReposPath,
956 user.username,
80bed05Claude957 `${safeName}.git`
bdbd0deClaude958 );
959
960 // Ensure parent directory exists
961 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
962
963 // Clone bare from GitHub (with token if provided for private repos)
80bed05Claude964 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
bdbd0deClaude965
966 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
967
968 const proc = Bun.spawn(
969 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
970 {
971 stdout: "pipe",
972 stderr: "pipe",
973 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
974 }
975 );
976 const stderr = await new Response(proc.stderr).text();
977 const exitCode = await proc.exited;
978
979 if (exitCode !== 0) {
80bed05Claude980 // Never echo the token back in an error message.
981 const sanitized = token
982 ? stderr.replaceAll(token, "***")
983 : stderr;
984 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
bdbd0deClaude985 }
986
987 // Insert into database
988 await db.insert(repositories).values({
80bed05Claude989 name: safeName,
bdbd0deClaude990 ownerId: user.id,
991 description: ghRepo.description,
992 isPrivate: ghRepo.private,
993 defaultBranch: ghRepo.default_branch || "main",
994 diskPath: destPath,
995 starCount: 0,
996 });
997
998 console.log(`[import] ${ghRepo.full_name} imported successfully`);
999}
1000
1001export default importRoutes;