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.
| bdbd0de | 1 | /** |
| 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 | ||
| 9 | import { Hono } from "hono"; | |
| 80bed05 | 10 | import { and, eq } from "drizzle-orm"; |
| bdbd0de | 11 | import { db } from "../db"; |
| 80bed05 | 12 | import { repositories } from "../db/schema"; |
| bdbd0de | 13 | import { Layout } from "../views/layout"; |
| 14 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 15 | import type { AuthEnv } from "../middleware/auth"; | |
| 16 | import { config } from "../lib/config"; | |
| 17 | import { mkdir } from "fs/promises"; | |
| 18 | import { join } from "path"; | |
| 80bed05 | 19 | import { |
| 20 | parseGithubUrl, | |
| 21 | sanitizeRepoName, | |
| 22 | buildCloneUrl, | |
| 609a27a | 23 | scrubSecrets, |
| 80bed05 | 24 | } from "../lib/import-helper"; |
| bdbd0de | 25 | |
| 26 | const importRoutes = new Hono<AuthEnv>(); | |
| 27 | ||
| 28 | importRoutes.use("*", softAuth); | |
| 29 | ||
| 30 | interface 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 | ||
| 7a99d47 | 42 | // ─── PAGE-SCOPED CSS ───────────────────────────────────────── |
| 43 | // All classes prefixed with .import- so the block cannot bleed into | |
| 44 | // neighbouring routes. Mirrors the dashboard-hero + settings polish. | |
| 45 | const importStyles = ` | |
| eed4684 | 46 | .import-container { max-width: 1200px; margin: 0 auto; } |
| 7a99d47 | 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 | ||
| bdbd0de | 398 | // ─── IMPORT PAGE ───────────────────────────────────────────── |
| 399 | ||
| 34c4488 | 400 | importRoutes.get("/import", requireAuth, async (c) => { |
| bdbd0de | 401 | 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 | ||
| 80bed05 | 406 | // 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. | |
| 7a99d47 | 409 | // Polished step pipeline: clone → analyze → enable gates → done. |
| 80bed05 | 410 | const progressScript = ` |
| 411 | (function () { | |
| 412 | var forms = document.querySelectorAll('form[data-import-form]'); | |
| 413 | var banner = document.getElementById('import-progress'); | |
| 414 | if (!banner) return; | |
| 7a99d47 | 415 | 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 | } | |
| 80bed05 | 422 | 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'; | |
| 7a99d47 | 430 | 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); | |
| 80bed05 | 435 | 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 | ||
| bdbd0de | 447 | return c.html( |
| 448 | <Layout title="Import from GitHub" user={user}> | |
| 7a99d47 | 449 | <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> | |
| 14c3cc8 | 480 | </div> |
| 7a99d47 | 481 | <div class="import-bulk-cta-arrow" aria-hidden="true">→</div> |
| 14c3cc8 | 482 | </a> |
| 7a99d47 | 483 | |
| 484 | {/* ─── Success / error banners ─── */} | |
| bdbd0de | 485 | {success && ( |
| 7a99d47 | 486 | <div class="import-banner import-banner-success" role="status"> |
| 487 | <div class="import-banner-title">{decodeURIComponent(success)}</div> | |
| bdbd0de | 488 | {imported && ( |
| 7a99d47 | 489 | <div class="import-banner-detail"> |
| bdbd0de | 490 | Successfully imported {decodeURIComponent(imported)} repositories. |
| 491 | </div> | |
| 492 | )} | |
| 7a99d47 | 493 | <div class="import-banner-actions"> |
| 494 | <a href={`/${user.username}`} class="btn btn-primary"> | |
| 80bed05 | 495 | View my repositories |
| 496 | </a> | |
| 497 | <a href="/explore" class="btn">Explore</a> | |
| 498 | </div> | |
| bdbd0de | 499 | </div> |
| 500 | )} | |
| 501 | {error && ( | |
| 7a99d47 | 502 | <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> | |
| bdbd0de | 506 | )} |
| 507 | ||
| 7a99d47 | 508 | {/* ─── In-flight progress pipeline ─── */} |
| 80bed05 | 509 | <div |
| 510 | id="import-progress" | |
| 7a99d47 | 511 | class="import-progress-card" |
| 80bed05 | 512 | role="status" |
| 513 | aria-live="polite" | |
| 7a99d47 | 514 | style="display: none" |
| 80bed05 | 515 | > |
| 7a99d47 | 516 | <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. | |
| 80bed05 | 519 | </div> |
| 7a99d47 | 520 | <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> | |
| bdbd0de | 524 | </div> |
| 7a99d47 | 525 | <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> | |
| bdbd0de | 532 | </div> |
| 7a99d47 | 533 | <div class="import-step" data-step="3"> |
| 534 | <span class="import-step-dot" aria-hidden="true" /> | |
| 535 | <span class="import-step-label">Done</span> | |
| f390cfa | 536 | </div> |
| 7a99d47 | 537 | </div> |
| bdbd0de | 538 | </div> |
| 539 | ||
| 7a99d47 | 540 | {/* ─── 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> | |
| bdbd0de | 546 | </div> |
| 7a99d47 | 547 | <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> | |
| bdbd0de | 621 | </div> |
| 7a99d47 | 622 | <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> | |
| bdbd0de | 664 | </div> |
| 665 | </div> | |
| 80bed05 | 666 | <script dangerouslySetInnerHTML={{ __html: progressScript }} /> |
| bdbd0de | 667 | </Layout> |
| 668 | ); | |
| 669 | }); | |
| 670 | ||
| 671 | // ─── IMPORT ALL REPOS FROM GITHUB USER ─────────────────────── | |
| 672 | ||
| 34c4488 | 673 | importRoutes.post("/import/github/user", requireAuth, async (c) => { |
| bdbd0de | 674 | 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; | |
| 80bed05 | 721 | let failed = 0; |
| bdbd0de | 722 | |
| 723 | for (const ghRepo of repos) { | |
| 80bed05 | 724 | 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). | |
| bdbd0de | 729 | const [existing] = await db |
| 730 | .select() | |
| 731 | .from(repositories) | |
| 732 | .where( | |
| 80bed05 | 733 | and( |
| 734 | eq(repositories.ownerId, user.id), | |
| 735 | eq(repositories.name, targetName) | |
| 736 | ) | |
| bdbd0de | 737 | ) |
| 738 | .limit(1); | |
| 739 | ||
| 80bed05 | 740 | if (existing) { |
| bdbd0de | 741 | skipped++; |
| 742 | continue; | |
| 743 | } | |
| 744 | ||
| 745 | try { | |
| 746 | await importSingleRepo(user, ghRepo, githubToken); | |
| 747 | imported++; | |
| 748 | } catch (err) { | |
| 80bed05 | 749 | failed++; |
| 609a27a | 750 | // 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 | ); | |
| bdbd0de | 759 | } |
| 760 | } | |
| 761 | ||
| 80bed05 | 762 | const summary = |
| 763 | `${imported}+imported%2C+${skipped}+skipped` + | |
| 764 | (failed > 0 ? `%2C+${failed}+failed` : ""); | |
| 765 | return c.redirect(`/import?success=Import+complete&imported=${summary}`); | |
| bdbd0de | 766 | } 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 | ||
| 34c4488 | 776 | importRoutes.post("/import/github/repo", requireAuth, async (c) => { |
| bdbd0de | 777 | const user = c.get("user")!; |
| 778 | const body = await c.req.parseBody(); | |
| 779 | const repoUrl = String(body.repo_url || "").trim(); | |
| f390cfa | 780 | // 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; | |
| bdbd0de | 784 | |
| 785 | if (!repoUrl) { | |
| 786 | return c.redirect("/import?error=Repository+URL+is+required"); | |
| 787 | } | |
| 788 | ||
| c63b860 | 789 | // 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 | ||
| 80bed05 | 797 | 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 | ); | |
| bdbd0de | 805 | } |
| 806 | ||
| 80bed05 | 807 | 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 | } | |
| bdbd0de | 828 | |
| 829 | try { | |
| 830 | // Fetch repo info | |
| f390cfa | 831 | 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}`; | |
| bdbd0de | 836 | const res = await fetch( |
| 837 | `https://api.github.com/repos/${ghOwner}/${ghRepo}`, | |
| f390cfa | 838 | { headers: metaHeaders } |
| bdbd0de | 839 | ); |
| 840 | ||
| 841 | if (!res.ok) { | |
| 4b66018 | 842 | // 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 | } | |
| 80bed05 | 851 | return c.redirect( |
| 852 | `/import?error=${encodeURIComponent( | |
| 4b66018 | 853 | `GitHub said ${res.status}${detail}. Check the URL and that the repo is public (or supply a token).` |
| 80bed05 | 854 | )}` |
| 855 | ); | |
| bdbd0de | 856 | } |
| 857 | ||
| 4b66018 | 858 | 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 | } | |
| bdbd0de | 869 | |
| f390cfa | 870 | 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. | |
| 4b66018 | 879 | // |
| 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; | |
| f390cfa | 910 | let secretsRedirect: string | null = null; |
| 911 | if (optionalToken) { | |
| 912 | try { | |
| 4b66018 | 913 | 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`; | |
| f390cfa | 925 | } |
| 926 | } catch (err) { | |
| 609a27a | 927 | const msg = err instanceof Error ? err.message : String(err); |
| 928 | console.error( | |
| 929 | "[import] secrets migration skipped:", | |
| 930 | scrubSecrets(msg, optionalToken) | |
| 931 | ); | |
| f390cfa | 932 | } |
| 933 | } | |
| bdbd0de | 934 | |
| f390cfa | 935 | return c.redirect(secretsRedirect ?? `/${user.username}/${targetName}`); |
| bdbd0de | 936 | } catch (err) { |
| 609a27a | 937 | const msg = err instanceof Error ? err.message : String(err); |
| 938 | const safe = scrubSecrets(msg, optionalToken); | |
| 939 | console.error("[import] error:", safe); | |
| bdbd0de | 940 | return c.redirect( |
| 609a27a | 941 | `/import?error=${encodeURIComponent(`Import failed: ${safe}`)}` |
| bdbd0de | 942 | ); |
| 943 | } | |
| 944 | }); | |
| 945 | ||
| 946 | // ─── CORE IMPORT FUNCTION ──────────────────────────────────── | |
| 947 | ||
| 948 | async function importSingleRepo( | |
| 949 | user: { id: string; username: string }, | |
| 950 | ghRepo: GitHubRepo, | |
| 951 | token: string | null | |
| 952 | ): Promise<void> { | |
| 80bed05 | 953 | const safeName = sanitizeRepoName(ghRepo.name); |
| bdbd0de | 954 | const destPath = join( |
| 955 | config.gitReposPath, | |
| 956 | user.username, | |
| 80bed05 | 957 | `${safeName}.git` |
| bdbd0de | 958 | ); |
| 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) | |
| 80bed05 | 964 | const cloneUrl = buildCloneUrl(ghRepo.clone_url, token); |
| bdbd0de | 965 | |
| 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) { | |
| 80bed05 | 980 | // 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)}`); | |
| bdbd0de | 985 | } |
| 986 | ||
| 987 | // Insert into database | |
| 988 | await db.insert(repositories).values({ | |
| 80bed05 | 989 | name: safeName, |
| bdbd0de | 990 | 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 | ||
| 1001 | export default importRoutes; |