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.tsxBlame950 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,
24} 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 = `
46 .import-container { max-width: 880px; margin: 0 auto; }
47
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
a4f3e24Claude400importRoutes.get("/import", requireAuth, requireAdmin, 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
a4f3e24Claude673importRoutes.post("/import/github/user", requireAuth, requireAdmin, 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++;
bdbd0deClaude750 console.error(`[import] failed to import ${ghRepo.full_name}:`, err);
751 }
752 }
753
80bed05Claude754 const summary =
755 `${imported}+imported%2C+${skipped}+skipped` +
756 (failed > 0 ? `%2C+${failed}+failed` : "");
757 return c.redirect(`/import?success=Import+complete&imported=${summary}`);
bdbd0deClaude758 } catch (err) {
759 console.error("[import] error:", err);
760 return c.redirect(
761 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
762 );
763 }
764});
765
766// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
767
a4f3e24Claude768importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
bdbd0deClaude769 const user = c.get("user")!;
770 const body = await c.req.parseBody();
771 const repoUrl = String(body.repo_url || "").trim();
f390cfaCC LABS App772 // Optional PAT — when supplied, used after the import succeeds to also
773 // migrate GitHub Actions secret NAMES (values are never exposed by the
774 // API) into placeholder rows the user can paste values into.
775 const optionalToken = String(body.github_token || "").trim() || null;
bdbd0deClaude776
777 if (!repoUrl) {
778 return c.redirect("/import?error=Repository+URL+is+required");
779 }
780
c63b860Claude781 // P4 — same quota gate as /new + /api/v2/repos. Imports count toward
782 // the user's plan limit.
783 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
784 const gate = await checkRepoCreateAllowed(user.id);
785 if (!gate.ok) {
786 return c.redirect(`/import?error=${encodeURIComponent(gate.reason)}`);
787 }
788
80bed05Claude789 const parsed = parseGithubUrl(repoUrl);
790 if (!parsed) {
791 return c.redirect(
792 "/import?error=" +
793 encodeURIComponent(
794 "Invalid GitHub URL. Use https://github.com/owner/repo or owner/repo."
795 )
796 );
bdbd0deClaude797 }
798
80bed05Claude799 const { owner: ghOwner, repo: ghRepo } = parsed;
800
801 // Guard against double-import before we spin up a clone subprocess.
802 const targetName = sanitizeRepoName(ghRepo);
803 const [existing] = await db
804 .select()
805 .from(repositories)
806 .where(
807 and(
808 eq(repositories.ownerId, user.id),
809 eq(repositories.name, targetName)
810 )
811 )
812 .limit(1);
813 if (existing) {
814 return c.redirect(
815 `/import?error=${encodeURIComponent(
816 `You already have a repository named "${targetName}". Delete it first, or rename on GitHub.`
817 )}`
818 );
819 }
bdbd0deClaude820
821 try {
822 // Fetch repo info
f390cfaCC LABS App823 const metaHeaders: Record<string, string> = {
824 Accept: "application/vnd.github.v3+json",
825 "User-Agent": "gluecron/1.0",
826 };
827 if (optionalToken) metaHeaders.Authorization = `Bearer ${optionalToken}`;
bdbd0deClaude828 const res = await fetch(
829 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
f390cfaCC LABS App830 { headers: metaHeaders }
bdbd0deClaude831 );
832
833 if (!res.ok) {
80bed05Claude834 return c.redirect(
835 `/import?error=${encodeURIComponent(
836 `Repository not found on GitHub (${res.status}). Check the URL and that it's public.`
837 )}`
838 );
bdbd0deClaude839 }
840
841 const ghRepoData: GitHubRepo = await res.json();
842
f390cfaCC LABS App843 await importSingleRepo(user, ghRepoData, optionalToken);
844
845 // Block T1 — opportunistically migrate GitHub Actions secret NAMES
846 // (values are never exposed by GitHub's API). If a token was supplied
847 // on this request, list the secret names + pre-create empty
848 // placeholders, then redirect the user to the paste-each-value
849 // checklist. Fire-and-forget semantics: any failure (no token, network
850 // error, no secrets, DB blip) collapses to "skip the checklist step"
851 // and we redirect straight to the repo home.
852 const targetName = sanitizeRepoName(ghRepoData.name);
853 let secretsRedirect: string | null = null;
854 if (optionalToken) {
855 try {
856 const [newRepoRow] = await db
857 .select({ id: repositories.id })
858 .from(repositories)
859 .where(
860 and(
861 eq(repositories.ownerId, user.id),
862 eq(repositories.name, targetName)
863 )
864 )
865 .limit(1);
866 if (newRepoRow) {
867 const { importSecretsForRepo } = await import(
868 "../lib/github-secrets-import"
869 );
870 const result = await importSecretsForRepo({
871 githubOwner: ghOwner,
872 githubRepo: ghRepo,
873 githubToken: optionalToken,
874 gluecronRepositoryId: newRepoRow.id,
875 importedByUserId: user.id,
876 });
877 if (result.imported.length > 0) {
878 secretsRedirect = `/${user.username}/${targetName}/import/secrets`;
879 }
880 }
881 } catch (err) {
882 console.error("[import] secrets migration skipped:", err);
883 }
884 }
bdbd0deClaude885
f390cfaCC LABS App886 return c.redirect(secretsRedirect ?? `/${user.username}/${targetName}`);
bdbd0deClaude887 } catch (err) {
888 console.error("[import] error:", err);
889 return c.redirect(
890 `/import?error=${encodeURIComponent(`Import failed: ${String(err)}`)}`
891 );
892 }
893});
894
895// ─── CORE IMPORT FUNCTION ────────────────────────────────────
896
897async function importSingleRepo(
898 user: { id: string; username: string },
899 ghRepo: GitHubRepo,
900 token: string | null
901): Promise<void> {
80bed05Claude902 const safeName = sanitizeRepoName(ghRepo.name);
bdbd0deClaude903 const destPath = join(
904 config.gitReposPath,
905 user.username,
80bed05Claude906 `${safeName}.git`
bdbd0deClaude907 );
908
909 // Ensure parent directory exists
910 await mkdir(join(config.gitReposPath, user.username), { recursive: true });
911
912 // Clone bare from GitHub (with token if provided for private repos)
80bed05Claude913 const cloneUrl = buildCloneUrl(ghRepo.clone_url, token);
bdbd0deClaude914
915 console.log(`[import] cloning ${ghRepo.full_name} -> ${destPath}`);
916
917 const proc = Bun.spawn(
918 ["git", "clone", "--bare", "--mirror", cloneUrl, destPath],
919 {
920 stdout: "pipe",
921 stderr: "pipe",
922 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
923 }
924 );
925 const stderr = await new Response(proc.stderr).text();
926 const exitCode = await proc.exited;
927
928 if (exitCode !== 0) {
80bed05Claude929 // Never echo the token back in an error message.
930 const sanitized = token
931 ? stderr.replaceAll(token, "***")
932 : stderr;
933 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
bdbd0deClaude934 }
935
936 // Insert into database
937 await db.insert(repositories).values({
80bed05Claude938 name: safeName,
bdbd0deClaude939 ownerId: user.id,
940 description: ghRepo.description,
941 isPrivate: ghRepo.private,
942 defaultBranch: ghRepo.default_branch || "main",
943 diskPath: destPath,
944 starCount: 0,
945 });
946
947 console.log(`[import] ${ghRepo.full_name} imported successfully`);
948}
949
950export default importRoutes;