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

migrate.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.

migrate.tsxBlame1018 lines · 1 contributor
9a8b8deClaude1/**
2 * GitHub Org Migration Wizard — bulk import every repo from a GitHub org or
3 * personal account with live per-repo progress tracking.
4 *
5 * Routes:
6 * GET /migrate — landing page with wizard form
7 * POST /migrate/start — validate PAT, list repos, create session, redirect
8 * GET /migrate/:sessionId — live progress page (meta-refresh every 3s)
9 * GET /migrate/:sessionId/status — JSON status endpoint
10 *
11 * Sessions live in-memory (Map). Repos use the existing `repositories` table.
12 * Token is never stored — it lives only in the in-memory session for the
13 * duration of cloning, then is scrubbed.
14 */
15
16import { Hono } from "hono";
17import { and, eq } from "drizzle-orm";
18import { db } from "../db";
19import { repositories } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { config } from "../lib/config";
24import { mkdir } from "fs/promises";
25import { join } from "path";
26import { sanitizeRepoName } from "../lib/import-helper";
27
28const migrateRoutes = new Hono<AuthEnv>();
29
30// ─── IN-MEMORY SESSION STORE ─────────────────────────────────
31
32interface MigrationRepo {
33 name: string;
34 status: "pending" | "cloning" | "done" | "failed";
35 error?: string;
36 repoId?: string;
37}
38
39interface MigrationSession {
40 id: string;
41 userId: string;
42 username: string;
43 githubOrg: string;
44 githubToken: string; // scrubbed after all clones complete
45 useOrgEndpoint: boolean;
46 repos: MigrationRepo[];
47 createdAt: Date;
48 startedAt?: Date;
49 completedAt?: Date;
50}
51
52const migrationSessions = new Map<string, MigrationSession>();
53
54// Purge sessions older than 2 hours to avoid unbounded growth.
55function purgeStaleSessions(): void {
56 const cutoff = Date.now() - 2 * 60 * 60 * 1000;
57 for (const [id, session] of migrationSessions) {
58 if (session.createdAt.getTime() < cutoff) {
59 migrationSessions.delete(id);
60 }
61 }
62}
63
64function randomId(): string {
65 return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
66}
67
68// ─── GITHUB API HELPERS ──────────────────────────────────────
69
70interface GitHubRepo {
71 name: string;
72 full_name: string;
73 description: string | null;
74 private: boolean;
75 clone_url: string;
76 default_branch: string;
77}
78
79const GITHUB_HEADERS = (token: string): Record<string, string> => ({
80 Accept: "application/vnd.github.v3+json",
81 "User-Agent": "gluecron/1.0",
82 Authorization: `Bearer ${token}`,
83 "X-GitHub-Api-Version": "2022-11-28",
84});
85
86async function fetchGithubRepos(
87 org: string,
88 token: string,
89 useOrgEndpoint: boolean
90): Promise<GitHubRepo[]> {
91 const repos: GitHubRepo[] = [];
92 let page = 1;
93 const perPage = 100;
94
95 while (true) {
96 const url = useOrgEndpoint
97 ? `https://api.github.com/orgs/${encodeURIComponent(org)}/repos?per_page=${perPage}&page=${page}&type=all`
98 : `https://api.github.com/user/repos?type=all&per_page=${perPage}&page=${page}`;
99
100 const res = await fetch(url, { headers: GITHUB_HEADERS(token) });
101 if (!res.ok) {
102 let detail = "";
103 try {
104 const body = (await res.json()) as { message?: string };
105 detail = body?.message ? ` — ${body.message}` : "";
106 } catch {
107 /* non-JSON */
108 }
109 throw new Error(`GitHub API error (${res.status})${detail}`);
110 }
111 const batch = (await res.json()) as GitHubRepo[];
112 if (!Array.isArray(batch) || batch.length === 0) break;
113 repos.push(...batch);
114 if (batch.length < perPage) break;
115 page++;
116 if (page > 10) break; // hard cap: 1000 repos
117 }
118 return repos;
119}
120
121// ─── BACKGROUND CLONE WORKER ─────────────────────────────────
122
123/**
124 * Runs sequentially in the background after the session is created.
125 * Never throws — all errors are captured per-repo.
126 */
127async function runMigration(session: MigrationSession): Promise<void> {
128 session.startedAt = new Date();
129 const { githubOrg, githubToken, username } = session;
130
131 for (const repo of session.repos) {
132 repo.status = "cloning";
133 try {
134 const safeName = sanitizeRepoName(repo.name);
135
136 // Check for existing repo in this user's namespace.
137 const [existing] = await db
138 .select({ id: repositories.id, name: repositories.name })
139 .from(repositories)
140 .where(
141 and(
142 eq(repositories.ownerId, session.userId),
143 eq(repositories.name, safeName)
144 )
145 )
146 .limit(1);
147
148 let finalName = safeName;
149 if (existing) {
150 // Rename with -2 suffix (or increment until unique).
151 finalName = `${safeName}-2`;
152 const [existing2] = await db
153 .select({ id: repositories.id })
154 .from(repositories)
155 .where(
156 and(
157 eq(repositories.ownerId, session.userId),
158 eq(repositories.name, finalName)
159 )
160 )
161 .limit(1);
162 if (existing2) {
163 // Skip rather than loop indefinitely.
164 repo.status = "failed";
165 repo.error = `Repo "${safeName}" already exists (tried "${finalName}" too)`;
166 continue;
167 }
168 }
169
170 const destDir = join(config.gitReposPath, username);
171 await mkdir(destDir, { recursive: true });
172 const destPath = join(destDir, `${finalName}.git`);
173
174 // Build authenticated clone URL.
175 const cloneUrl = `https://${encodeURIComponent(githubToken)}@github.com/${githubOrg}/${repo.name}.git`;
176
177 const proc = Bun.spawn(
178 ["git", "clone", "--mirror", cloneUrl, destPath],
179 {
180 stdout: "pipe",
181 stderr: "pipe",
182 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
183 }
184 );
185 const stderr = await new Response(proc.stderr).text();
186 const exitCode = await proc.exited;
187
188 if (exitCode !== 0) {
189 const safe = stderr.replaceAll(githubToken, "***").slice(0, 400);
190 throw new Error(`git clone failed: ${safe}`);
191 }
192
193 // Insert into DB.
194 const inserted = await db
195 .insert(repositories)
196 .values({
197 name: finalName,
198 ownerId: session.userId,
199 description: null,
200 isPrivate: false, // unknown at this point without per-repo metadata
201 defaultBranch: "main",
202 diskPath: destPath,
203 starCount: 0,
204 })
205 .returning({ id: repositories.id });
206
207 repo.repoId = inserted[0]?.id;
208 repo.name = finalName; // update to actual stored name
209 repo.status = "done";
210 } catch (err) {
211 repo.status = "failed";
212 const msg = err instanceof Error ? err.message : String(err);
213 repo.error = msg.replaceAll(session.githubToken, "***").slice(0, 300);
214 }
215 }
216
217 session.completedAt = new Date();
218 // Scrub the token from the session now that cloning is done.
219 (session as { githubToken: string }).githubToken = "***";
220}
221
222// ─── SCOPED CSS ──────────────────────────────────────────────
223
224const migrateStyles = `
225 .mig-wiz-wrap { max-width: 900px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
226
227 /* Hero */
228 .mig-wiz-hero {
229 position: relative;
230 margin-bottom: var(--space-6);
231 padding: var(--space-5) var(--space-6);
232 background: var(--bg-elevated);
233 border: 1px solid var(--border);
234 border-radius: 16px;
235 overflow: hidden;
236 }
237 .mig-wiz-hero::before {
238 content: '';
239 position: absolute;
240 top: 0; left: 0; right: 0;
241 height: 2px;
242 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
243 pointer-events: none;
244 }
245 .mig-wiz-hero-orb-wrap {
246 position: absolute;
247 inset: -20% -10% auto auto;
248 width: 340px; height: 340px;
249 pointer-events: none;
250 z-index: 0;
251 }
252 .mig-wiz-hero-orb {
253 position: absolute;
254 inset: 0;
255 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
256 filter: blur(80px);
257 opacity: 0.7;
258 animation: migWizOrb 14s ease-in-out infinite;
259 }
260 @keyframes migWizOrb {
261 0%, 100% { transform: scale(1); opacity: 0.6; }
262 50% { transform: scale(1.1) translate(-8px, 6px); opacity: 0.85; }
263 }
264 @media (prefers-reduced-motion: reduce) { .mig-wiz-hero-orb { animation: none; } }
265 .mig-wiz-hero-inner { position: relative; z-index: 1; max-width: 600px; }
266 .mig-wiz-eyebrow {
267 font-size: 12.5px;
268 color: var(--text-muted);
269 margin-bottom: var(--space-2);
270 letter-spacing: -0.005em;
271 }
272 .mig-wiz-eyebrow-dot {
273 display: inline-block;
274 width: 6px; height: 6px;
275 border-radius: 50%;
276 background: var(--accent);
277 box-shadow: 0 0 8px rgba(140,109,255,0.6);
278 margin-right: 8px;
279 vertical-align: 1px;
280 }
281 .mig-wiz-title {
282 font-size: clamp(26px, 4vw, 38px);
283 font-family: var(--font-display);
284 font-weight: 800;
285 letter-spacing: -0.028em;
286 line-height: 1.05;
287 margin: 0 0 var(--space-2);
288 color: var(--text-strong);
289 }
290 .mig-wiz-title .gradient-text {
291 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
292 -webkit-background-clip: text;
293 background-clip: text;
294 -webkit-text-fill-color: transparent;
295 color: transparent;
296 }
297 .mig-wiz-sub {
298 font-size: 15px;
299 color: var(--text-muted);
300 margin: 0;
301 line-height: 1.55;
302 }
303
304 /* Form card */
305 .mig-wiz-card {
306 background: var(--bg-elevated);
307 border: 1px solid var(--border);
308 border-radius: 14px;
309 margin-bottom: var(--space-5);
310 overflow: hidden;
311 }
312 .mig-wiz-card-head {
313 padding: var(--space-4) var(--space-5) var(--space-3);
314 border-bottom: 1px solid var(--border);
315 }
316 .mig-wiz-card-eyebrow {
317 font-size: 11px;
318 font-weight: 600;
319 letter-spacing: 0.08em;
320 text-transform: uppercase;
321 color: var(--accent);
322 margin-bottom: 5px;
323 }
324 .mig-wiz-card-title {
325 font-family: var(--font-display);
326 font-size: 17px;
327 font-weight: 700;
328 letter-spacing: -0.016em;
329 color: var(--text-strong);
330 margin: 0 0 3px;
331 }
332 .mig-wiz-card-desc {
333 font-size: 13px;
334 color: var(--text-muted);
335 margin: 0;
336 line-height: 1.5;
337 }
338 .mig-wiz-card-body { padding: var(--space-4) var(--space-5); }
339
340 /* Fields */
341 .mig-wiz-field { margin-bottom: var(--space-4); }
342 .mig-wiz-field:last-child { margin-bottom: 0; }
343 .mig-wiz-label {
344 display: block;
345 font-size: 13px;
346 font-weight: 600;
347 color: var(--text-strong);
348 margin-bottom: 5px;
349 letter-spacing: -0.005em;
350 }
351 .mig-wiz-hint {
352 font-size: 12px;
353 color: var(--text-muted);
354 margin-top: 4px;
355 line-height: 1.45;
356 }
357 .mig-wiz-input {
358 width: 100%;
359 padding: 9px 12px;
360 font-size: 14px;
361 color: var(--text);
362 background: var(--bg);
363 border: 1px solid var(--border-strong);
364 border-radius: 8px;
365 outline: none;
366 font-family: var(--font-sans);
367 transition: border-color 120ms ease, box-shadow 120ms ease;
368 box-sizing: border-box;
369 }
370 .mig-wiz-input.is-mono { font-family: var(--font-mono); font-size: 13px; }
371 .mig-wiz-input:focus {
372 border-color: var(--accent);
373 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
374 }
375 .mig-wiz-toggle {
376 display: flex;
377 align-items: flex-start;
378 gap: 10px;
379 padding: 11px 13px;
380 border-radius: 9px;
381 border: 1px solid var(--border-subtle);
382 background: var(--bg-secondary);
383 }
384 .mig-wiz-toggle input[type="checkbox"] {
385 margin-top: 2px;
386 width: 15px; height: 15px;
387 accent-color: var(--accent);
388 cursor: pointer;
389 }
390 .mig-wiz-toggle-text { font-size: 13.5px; color: var(--text); line-height: 1.45; }
391 .mig-wiz-toggle-hint { display: block; margin-top: 2px; font-size: 12px; color: var(--text-muted); }
392 .mig-wiz-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: var(--space-4); }
393
394 /* Banner */
395 .mig-wiz-banner {
396 position: relative;
397 padding: 13px 15px 13px 42px;
398 margin-bottom: var(--space-5);
399 border-radius: 12px;
400 border: 1px solid var(--border);
401 background: var(--bg-elevated);
402 font-size: 14px;
403 line-height: 1.5;
404 }
405 .mig-wiz-banner::before {
406 content: '';
407 position: absolute;
408 left: 13px; top: 17px;
409 width: 13px; height: 13px;
410 border-radius: 50%;
411 }
412 .mig-wiz-banner-error {
413 border-color: rgba(248,81,73,0.32);
414 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
415 }
416 .mig-wiz-banner-error::before { background: #f85149; box-shadow: 0 0 10px rgba(248,81,73,0.5); }
417 .mig-wiz-banner-title { font-weight: 600; color: var(--text-strong); }
418 .mig-wiz-banner-detail { color: var(--text-muted); margin-top: 4px; font-size: 13px; }
419
420 /* Progress page */
421 .mig-prog-header {
422 display: flex;
423 align-items: center;
424 justify-content: space-between;
425 flex-wrap: wrap;
426 gap: var(--space-3);
427 margin-bottom: var(--space-5);
428 }
429 .mig-prog-title {
430 font-family: var(--font-display);
431 font-size: clamp(20px, 3vw, 28px);
432 font-weight: 800;
433 letter-spacing: -0.024em;
434 color: var(--text-strong);
435 margin: 0;
436 }
437 .mig-prog-count {
438 font-size: 14px;
439 color: var(--text-muted);
440 margin-top: 4px;
441 }
442 .mig-prog-count strong { color: var(--text-strong); }
443
444 /* Progress bar */
445 .mig-prog-bar-wrap {
446 background: var(--bg-elevated);
447 border: 1px solid var(--border);
448 border-radius: 12px;
449 padding: 16px 18px;
450 margin-bottom: var(--space-5);
451 }
452 .mig-prog-bar-label {
453 display: flex;
454 justify-content: space-between;
455 font-size: 13px;
456 color: var(--text-muted);
457 margin-bottom: 10px;
458 }
459 .mig-prog-bar-label strong { color: var(--text-strong); }
460 .mig-prog-bar-track {
461 height: 8px;
462 border-radius: 9999px;
463 background: var(--bg-secondary);
464 overflow: hidden;
465 }
466 .mig-prog-bar-fill {
467 height: 100%;
468 border-radius: 9999px;
469 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
470 transition: width 0.8s ease;
471 min-width: 4px;
472 }
473
474 /* Repo list */
475 .mig-prog-list {
476 background: var(--bg-elevated);
477 border: 1px solid var(--border);
478 border-radius: 14px;
479 overflow: hidden;
480 margin-bottom: var(--space-5);
481 }
482 .mig-prog-list-head {
483 padding: 10px 16px;
484 background: var(--bg-secondary);
485 border-bottom: 1px solid var(--border);
486 font-size: 11.5px;
487 font-weight: 600;
488 letter-spacing: 0.06em;
489 text-transform: uppercase;
490 color: var(--text-muted);
491 }
492 .mig-prog-item {
493 display: flex;
494 align-items: center;
495 gap: 10px;
496 padding: 10px 16px;
497 border-bottom: 1px solid var(--border-subtle);
498 font-size: 13.5px;
499 transition: background 100ms ease;
500 }
501 .mig-prog-item:last-child { border-bottom: 0; }
502 .mig-prog-item:hover { background: rgba(255,255,255,0.015); }
503 .mig-prog-icon { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; }
504 .mig-prog-name { flex: 1; min-width: 0; font-family: var(--font-mono); font-size: 13px; color: var(--text-strong); }
505 .mig-prog-name a { color: var(--accent); text-decoration: none; }
506 .mig-prog-name a:hover { text-decoration: underline; }
507 .mig-prog-status {
508 font-size: 12px;
509 font-weight: 600;
510 padding: 2px 9px;
511 border-radius: 9999px;
512 }
513 .mig-prog-status-pending { color: var(--text-muted); background: var(--bg-secondary); }
514 .mig-prog-status-cloning {
515 color: #f0b429;
516 background: rgba(240,180,41,0.13);
517 border: 1px solid rgba(240,180,41,0.3);
518 animation: migCloningPulse 1.3s ease-in-out infinite;
519 }
520 @keyframes migCloningPulse {
521 0%, 100% { opacity: 1; }
522 50% { opacity: 0.55; }
523 }
524 @media (prefers-reduced-motion: reduce) { .mig-prog-status-cloning { animation: none; } }
525 .mig-prog-status-done { color: #3fb950; background: rgba(63,185,80,0.13); border: 1px solid rgba(63,185,80,0.28); }
526 .mig-prog-status-failed { color: #f85149; background: rgba(248,81,73,0.13); border: 1px solid rgba(248,81,73,0.28); }
527 .mig-prog-error { font-size: 11.5px; color: #f85149; margin-top: 2px; word-break: break-all; }
528
529 /* Complete banner */
530 .mig-prog-complete {
531 padding: 24px 20px;
532 background: linear-gradient(135deg, rgba(63,185,80,0.08) 0%, var(--bg-elevated) 100%);
533 border: 1px solid rgba(63,185,80,0.32);
534 border-radius: 14px;
535 margin-bottom: var(--space-5);
536 text-align: center;
537 }
538 .mig-prog-complete-icon { font-size: 36px; margin-bottom: 8px; }
539 .mig-prog-complete-title {
540 font-family: var(--font-display);
541 font-size: 24px;
542 font-weight: 800;
543 letter-spacing: -0.022em;
544 color: #3fb950;
545 margin: 0 0 8px;
546 }
547 .mig-prog-complete-sub { font-size: 14px; color: var(--text-muted); margin: 0 0 16px; }
548`;
549
550// ─── GET /migrate — wizard form ──────────────────────────────
551
552migrateRoutes.get("/migrate", requireAuth, async (c) => {
553 const user = c.get("user")!;
554 const error = c.req.query("error");
555
556 return c.html(
557 <Layout title="GitHub Org Migration" user={user}>
558 <style dangerouslySetInnerHTML={{ __html: migrateStyles }} />
559 <div class="mig-wiz-wrap">
560 {/* Hero */}
561 <div class="mig-wiz-hero">
562 <div class="mig-wiz-hero-orb-wrap" aria-hidden="true">
563 <div class="mig-wiz-hero-orb" />
564 </div>
565 <div class="mig-wiz-hero-inner">
566 <div class="mig-wiz-eyebrow">
567 <span class="mig-wiz-eyebrow-dot" aria-hidden="true" />
568 GitHub Migration Wizard
569 </div>
570 <h1 class="mig-wiz-title">
571 Import your entire{" "}
572 <span class="gradient-text">GitHub org</span>.
573 </h1>
574 <p class="mig-wiz-sub">
575 Paste your GitHub PAT and org name — Gluecron clones every repo
576 sequentially with live progress. Come back to everything migrated.
577 </p>
578 </div>
579 </div>
580
581 {error && (
582 <div class="mig-wiz-banner mig-wiz-banner-error" role="alert">
583 <div class="mig-wiz-banner-title">Migration could not start</div>
584 <div class="mig-wiz-banner-detail">
585 {decodeURIComponent(error)}
586 </div>
587 </div>
588 )}
589
590 {/* Form card */}
591 <div class="mig-wiz-card">
592 <div class="mig-wiz-card-head">
593 <div class="mig-wiz-card-eyebrow">Configure</div>
594 <h2 class="mig-wiz-card-title">GitHub credentials</h2>
595 <p class="mig-wiz-card-desc">
596 Your token is used only for this session — it is never written to
597 disk or the database.
598 </p>
599 </div>
600 <div class="mig-wiz-card-body">
601 <form method="post" action="/migrate/start">
602 <div class="mig-wiz-field">
603 <label class="mig-wiz-label">GitHub Personal Access Token</label>
604 <input
605 type="password"
606 name="githubToken"
607 required
608 placeholder="ghp_xxxxxxxxxxxx"
609 autocomplete="off"
610 aria-label="GitHub personal access token"
611 class="mig-wiz-input is-mono"
612 />
613 <div class="mig-wiz-hint">
614 Needs <strong>repo</strong> scope for private repos, or
615 <strong> public_repo</strong> for public only. Generate at{" "}
616 <a
617 href="https://github.com/settings/tokens/new?scopes=repo"
618 target="_blank"
619 rel="noopener noreferrer"
620 >
621 github.com/settings/tokens
622 </a>
623 .
624 </div>
625 </div>
626
627 <div class="mig-wiz-field">
628 <label class="mig-wiz-label">
629 GitHub org or personal account name
630 </label>
631 <input
632 type="text"
633 name="githubOrg"
634 placeholder="my-company or my-username"
635 aria-label="GitHub org or username"
636 class="mig-wiz-input"
637 />
638 <div class="mig-wiz-hint">
639 Leave blank to import your own account's repos
640 (<code>GET /user/repos</code>). Fill in to import all repos
641 from an org (<code>GET /orgs/:org/repos</code>).
642 </div>
643 </div>
644
645 <div class="mig-wiz-field">
646 <label class="mig-wiz-toggle">
647 <input type="checkbox" name="useOrgEndpoint" value="1" />
648 <span class="mig-wiz-toggle-text">
649 This is an org (not a personal account)
650 <span class="mig-wiz-toggle-hint">
651 Uses <code>/orgs/:org/repos</code> endpoint. Uncheck for
652 personal accounts.
653 </span>
654 </span>
655 </label>
656 </div>
657
658 <div class="mig-wiz-actions">
659 <button type="submit" class="btn btn-primary">
660 Start migration
661 </button>
662 <a href="/import/bulk" class="btn">
663 Try bulk importer instead
664 </a>
665 </div>
666 </form>
667 </div>
668 </div>
669 </div>
670 </Layout>
671 );
672});
673
674// ─── POST /migrate/start ─────────────────────────────────────
675
676migrateRoutes.post("/migrate/start", requireAuth, async (c) => {
677 const user = c.get("user")!;
678 const body = await c.req.parseBody();
679
680 const githubToken = String(body.githubToken || "").trim();
681 const githubOrg = String(body.githubOrg || "").trim();
682 const useOrgEndpoint = Boolean(body.useOrgEndpoint);
683
684 if (!githubToken) {
685 return c.redirect(
686 "/migrate?error=" + encodeURIComponent("GitHub PAT is required.")
687 );
688 }
689
690 // Validate the token by calling /user.
691 let tokenScopes = "";
692 try {
693 const checkRes = await fetch("https://api.github.com/user", {
694 headers: GITHUB_HEADERS(githubToken),
695 });
696 if (!checkRes.ok) {
697 const msg = `GitHub rejected the token (${checkRes.status}). Check scope + expiry.`;
698 return c.redirect("/migrate?error=" + encodeURIComponent(msg));
699 }
700 tokenScopes = (checkRes.headers.get("x-oauth-scopes") || "").toLowerCase();
701 } catch {
702 return c.redirect(
703 "/migrate?error=" +
704 encodeURIComponent("Could not reach GitHub to validate the token.")
705 );
706 }
707
708 // Warn if the token is missing repo scope.
709 if (
710 tokenScopes &&
711 !tokenScopes.includes("repo") &&
712 !tokenScopes.includes("public_repo")
713 ) {
714 return c.redirect(
715 "/migrate?error=" +
716 encodeURIComponent(
717 "Token is missing repo / public_repo scope. Regenerate at github.com/settings/tokens."
718 )
719 );
720 }
721
722 // If org endpoint requested, require an org name.
723 if (useOrgEndpoint && !githubOrg) {
724 return c.redirect(
725 "/migrate?error=" +
726 encodeURIComponent(
727 "Org name is required when the org endpoint is selected."
728 )
729 );
730 }
731
732 // Fetch the repo list.
733 let ghRepos: GitHubRepo[];
734 try {
735 ghRepos = await fetchGithubRepos(githubOrg, githubToken, useOrgEndpoint);
736 } catch (err) {
737 const msg = err instanceof Error ? err.message : String(err);
738 return c.redirect(
739 "/migrate?error=" + encodeURIComponent(msg.slice(0, 300))
740 );
741 }
742
743 if (ghRepos.length === 0) {
744 return c.redirect(
745 "/migrate?error=" +
746 encodeURIComponent(
747 `No repos found for "${githubOrg || "your account"}" with this token.`
748 )
749 );
750 }
751
752 // Create the in-memory session.
753 purgeStaleSessions();
754 const sessionId = randomId();
755 const session: MigrationSession = {
756 id: sessionId,
757 userId: user.id,
758 username: user.username,
759 githubOrg: githubOrg || user.username,
760 githubToken,
761 useOrgEndpoint,
762 repos: ghRepos.map((r) => ({
763 name: r.name,
764 status: "pending",
765 })),
766 createdAt: new Date(),
767 };
768 migrationSessions.set(sessionId, session);
769
770 // Kick off background cloning (fire and forget).
771 runMigration(session).catch((err) => {
772 console.error("[migrate] background worker crashed:", err);
773 });
774
775 return c.redirect(`/migrate/${sessionId}`);
776});
777
778// ─── GET /migrate/:sessionId/status — JSON ───────────────────
779
780migrateRoutes.get("/migrate/:sessionId/status", requireAuth, async (c) => {
781 const user = c.get("user")!;
782 const sessionId = c.req.param("sessionId");
783 const session = migrationSessions.get(sessionId);
784
785 if (!session) {
786 return c.json({ error: "Session not found" }, 404);
787 }
788 if (session.userId !== user.id) {
789 return c.json({ error: "Forbidden" }, 403);
790 }
791
792 const done = session.repos.filter((r) => r.status === "done").length;
793 const failed = session.repos.filter((r) => r.status === "failed").length;
794 const total = session.repos.length;
795 const complete = !!session.completedAt;
796
797 return c.json({
798 id: session.id,
799 githubOrg: session.githubOrg,
800 total,
801 done,
802 failed,
803 complete,
804 startedAt: session.startedAt?.toISOString(),
805 completedAt: session.completedAt?.toISOString(),
806 repos: session.repos.map((r) => ({
807 name: r.name,
808 status: r.status,
809 error: r.error,
810 repoId: r.repoId,
811 })),
812 });
813});
814
815// ─── GET /migrate/:sessionId — progress page ─────────────────
816
817migrateRoutes.get("/migrate/:sessionId", requireAuth, async (c) => {
818 const user = c.get("user")!;
819 const sessionId = c.req.param("sessionId");
820 const session = migrationSessions.get(sessionId);
821
822 if (!session) {
823 return c.html(
824 <Layout title="Migration not found" user={user}>
825 <div style="max-width:600px;margin:4rem auto;text-align:center;color:var(--text-muted)">
826 <h1 style="color:var(--text-strong);font-size:22px;font-family:var(--font-display)">
827 Session not found
828 </h1>
829 <p>This migration session has expired or doesn't exist.</p>
830 <a href="/migrate" class="btn btn-primary" style="margin-top:1rem">
831 Start a new migration
832 </a>
833 </div>
834 </Layout>,
835 404
836 );
837 }
838
839 if (session.userId !== user.id) {
840 return c.html(
841 <Layout title="Forbidden" user={user}>
842 <div style="max-width:600px;margin:4rem auto;text-align:center;color:var(--text-muted)">
843 <h1 style="color:var(--text-strong);font-size:22px;font-family:var(--font-display)">
844 Forbidden
845 </h1>
846 <p>This migration session belongs to another user.</p>
847 </div>
848 </Layout>,
849 403
850 );
851 }
852
853 const total = session.repos.length;
854 const doneCount = session.repos.filter((r) => r.status === "done").length;
855 const failedCount = session.repos.filter((r) => r.status === "failed").length;
856 const finishedCount = doneCount + failedCount;
857 const pct = total > 0 ? Math.round((finishedCount / total) * 100) : 0;
858 const isComplete = !!session.completedAt;
859
860 // JS polling — fetch /status every 3s and update the DOM without a full reload.
861 // Falls back gracefully to the meta-refresh if JS is disabled.
862 const pollScript = `
863(function () {
864 if (!window.fetch) return;
865 var sessionId = ${JSON.stringify(sessionId)};
866 var username = ${JSON.stringify(user.username)};
867 function statusCls(s) {
868 return 'mig-prog-status mig-prog-status-' + s;
869 }
870 function iconFor(s) {
871 if (s === 'done') return '\\u2713';
872 if (s === 'failed') return '\\u2717';
873 if (s === 'cloning') return '\\u21bb';
874 return '\\u23f3';
875 }
876 function poll() {
877 fetch('/migrate/' + sessionId + '/status')
878 .then(function (r) { return r.json(); })
879 .then(function (data) {
880 // Update progress bar.
881 var finished = data.done + data.failed;
882 var pct = data.total > 0 ? Math.round(finished / data.total * 100) : 0;
883 var bar = document.getElementById('mig-bar-fill');
884 var lbl = document.getElementById('mig-bar-label');
885 if (bar) bar.style.width = pct + '%';
886 if (lbl) lbl.textContent = finished + ' / ' + data.total + ' repos (' + pct + '%)';
887
888 // Update each repo row.
889 data.repos.forEach(function (repo) {
890 var row = document.getElementById('mig-repo-' + repo.name);
891 if (!row) return;
892 var icon = row.querySelector('.mig-prog-icon');
893 var st = row.querySelector('.mig-prog-status');
894 var nm = row.querySelector('.mig-prog-name');
895 var err = row.querySelector('.mig-prog-error');
896 if (icon) icon.textContent = iconFor(repo.status);
897 if (st) { st.className = statusCls(repo.status); st.textContent = repo.status; }
898 if (nm && repo.status === 'done' && repo.name) {
899 nm.innerHTML = '<a href="/' + username + '/' + repo.name + '">' + repo.name + '</a>';
900 }
901 if (err) { err.textContent = repo.error || ''; err.style.display = repo.error ? '' : 'none'; }
902 });
903
904 // Show complete banner.
905 if (data.complete) {
906 var banner = document.getElementById('mig-complete-banner');
907 if (banner) banner.style.display = '';
908 // Stop polling.
909 return;
910 }
911 setTimeout(poll, 3000);
912 })
913 .catch(function () { setTimeout(poll, 5000); });
914 }
915 setTimeout(poll, 3000);
916})();
917`;
918
919 return c.html(
920 <Layout title={`Migrating ${session.githubOrg}`} user={user}>
921 {/* Meta-refresh fallback for no-JS environments */}
922 {!isComplete && (
923 <meta http-equiv="refresh" content="3" />
924 )}
925 <style dangerouslySetInnerHTML={{ __html: migrateStyles }} />
926 <div class="mig-wiz-wrap">
927 {/* Header */}
928 <div class="mig-prog-header">
929 <div>
930 <h1 class="mig-prog-title">
931 Migrating from GitHub: @{session.githubOrg}
932 </h1>
933 <div class="mig-prog-count">
934 <strong>{total}</strong> {total === 1 ? "repo" : "repos"} found
935 </div>
936 </div>
937 </div>
938
939 {/* Complete banner */}
940 <div
941 id="mig-complete-banner"
942 class="mig-prog-complete"
943 style={isComplete ? "" : "display:none"}
944 >
945 <div class="mig-prog-complete-icon" aria-hidden="true">
946 &#10003;
947 </div>
948 <div class="mig-prog-complete-title">Migration Complete!</div>
949 <div class="mig-prog-complete-sub">
950 {doneCount} repo{doneCount !== 1 ? "s" : ""} imported
951 {failedCount > 0 ? `, ${failedCount} failed` : ""}.
952 </div>
953 <a href={`/${user.username}`} class="btn btn-primary">
954 View my repositories
955 </a>
956 </div>
957
958 {/* Progress bar */}
959 <div class="mig-prog-bar-wrap">
960 <div class="mig-prog-bar-label">
961 <span>Progress</span>
962 <span id="mig-bar-label">
963 {finishedCount} / {total} repos ({pct}%)
964 </span>
965 </div>
966 <div class="mig-prog-bar-track">
967 <div
968 id="mig-bar-fill"
969 class="mig-prog-bar-fill"
970 style={`width:${pct}%`}
971 />
972 </div>
973 </div>
974
975 {/* Repo list */}
976 <div class="mig-prog-list">
977 <div class="mig-prog-list-head">Repositories</div>
978 {session.repos.map((repo) => {
979 const icon =
980 repo.status === "done"
981 ? "✓"
982 : repo.status === "failed"
983 ? "✗"
984 : repo.status === "cloning"
985 ? "↻"
986 : "⏳";
987 const statusCls = `mig-prog-status mig-prog-status-${repo.status}`;
988 return (
989 <div class="mig-prog-item" id={`mig-repo-${repo.name}`}>
990 <span class="mig-prog-icon" aria-hidden="true">
991 {icon}
992 </span>
993 <div style="flex:1;min-width:0">
994 <div class="mig-prog-name">
995 {repo.status === "done" ? (
996 <a href={`/${user.username}/${repo.name}`}>
997 {repo.name}
998 </a>
999 ) : (
1000 repo.name
1001 )}
1002 </div>
1003 {repo.error && (
1004 <div class="mig-prog-error">{repo.error}</div>
1005 )}
1006 </div>
1007 <span class={statusCls}>{repo.status}</span>
1008 </div>
1009 );
1010 })}
1011 </div>
1012 </div>
1013 <script dangerouslySetInnerHTML={{ __html: pollScript }} />
1014 </Layout>
1015 );
1016});
1017
1018export default migrateRoutes;