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

codebase-migrator.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.

codebase-migrator.tsxBlame1128 lines · 1 contributor
b665cacClaude1/**
2 * AI Codebase Migration — one-click language/framework translation with Claude.
3 *
4 * GET /:owner/:repo/migrate — migration form (owner only)
5 * POST /:owner/:repo/migrate/start — start a job (requireAuth, owner only)
6 * GET /:owner/:repo/migrate/:jobId — job progress page (auto-refreshes)
7 * GET /:owner/:repo/migrate/:jobId/status — JSON status endpoint (polling)
8 */
9
10import { Hono } from "hono";
11import { eq, and } from "drizzle-orm";
12import { db } from "../db";
13import { users, repositories } from "../db/schema";
14import { Layout } from "../views/layout";
15import { RepoHeader, RepoNav } from "../views/components";
16import { softAuth, requireAuth } from "../middleware/auth";
17import type { AuthEnv } from "../middleware/auth";
18import {
19 startMigration,
20 getJob,
21 isRepoMigrating,
22 type MigrationJob,
23 type MigrationTarget,
24} from "../lib/codebase-migrator";
25
26// ---------------------------------------------------------------------------
27// Helpers
28// ---------------------------------------------------------------------------
29
30interface ResolvedRepo {
31 ownerId: string;
32 repoId: string;
33 defaultBranch: string;
34}
35
36async function resolveRepo(
37 ownerName: string,
38 repoName: string
39): Promise<ResolvedRepo | null> {
40 try {
41 const [ownerRow] = await db
42 .select()
43 .from(users)
44 .where(eq(users.username, ownerName))
45 .limit(1);
46 if (!ownerRow) return null;
47 const [repoRow] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, ownerRow.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!repoRow) return null;
58 return {
59 ownerId: ownerRow.id,
60 repoId: repoRow.id,
61 defaultBranch: repoRow.defaultBranch || "main",
62 };
63 } catch {
64 return null;
65 }
66}
67
68function isOwner(resolved: ResolvedRepo, userId: string | undefined): boolean {
69 return !!userId && resolved.ownerId === userId;
70}
71
72function statusLabel(status: MigrationJob["status"]): string {
73 const labels: Record<MigrationJob["status"], string> = {
74 queued: "Queued",
75 analyzing: "Analyzing",
76 translating: "Translating",
77 committing: "Committing",
78 "opening-pr": "Opening PR",
79 done: "Done",
80 failed: "Failed",
81 };
82 return labels[status] ?? status;
83}
84
85function targetDescription(target: MigrationTarget): string {
86 if (target.type === "language") return `${target.from} → ${target.to}`;
87 if (target.type === "framework") return `${target.from} → ${target.to}`;
88 return target.description;
89}
90
91// ---------------------------------------------------------------------------
92// Styles
93// ---------------------------------------------------------------------------
94
95const migrateStyles = `
96 .mg-wrap {
97 max-width: 860px;
98 margin: 0 auto;
99 padding: var(--space-5) var(--space-4) var(--space-8);
100 }
101
102 /* Header */
103 .mg-head { margin-bottom: var(--space-5); }
104 .mg-eyebrow {
105 display: inline-flex;
106 align-items: center;
107 gap: 8px;
108 text-transform: uppercase;
109 font-family: var(--font-mono);
110 font-size: 11px;
111 letter-spacing: 0.16em;
112 color: var(--text-muted);
113 font-weight: 600;
114 margin-bottom: 10px;
115 }
116 .mg-eyebrow-dot {
117 width: 8px; height: 8px;
118 border-radius: 9999px;
119 background: linear-gradient(135deg, #f59e0b, #ef4444);
120 box-shadow: 0 0 0 3px rgba(245,158,11,0.18);
121 }
122 .mg-title {
123 font-family: var(--font-display);
124 font-size: clamp(24px, 3.2vw, 36px);
125 font-weight: 800;
126 letter-spacing: -0.025em;
127 line-height: 1.08;
128 margin: 0 0 8px;
129 color: var(--text-strong);
130 }
131 .mg-title-grad {
132 background-image: linear-gradient(135deg, #f59e0b 0%, #ef4444 60%, #8b5cf6 100%);
133 -webkit-background-clip: text;
134 background-clip: text;
135 -webkit-text-fill-color: transparent;
136 color: transparent;
137 }
138 .mg-sub {
139 font-size: 15px;
140 color: var(--text-muted);
141 margin: 0;
142 line-height: 1.5;
143 max-width: 600px;
144 }
145
146 /* Warning banner */
147 .mg-warning {
148 display: flex;
149 align-items: flex-start;
150 gap: 10px;
151 padding: 12px 14px;
152 background: rgba(245,158,11,0.08);
153 border: 1px solid rgba(245,158,11,0.28);
154 border-radius: 10px;
155 margin-bottom: var(--space-5);
156 font-size: 13px;
157 color: var(--text);
158 line-height: 1.5;
159 }
160 .mg-warning-icon {
161 font-size: 16px;
162 flex-shrink: 0;
163 margin-top: 1px;
164 }
165 .mg-warning strong { color: var(--text-strong); }
166
167 /* Error banner */
168 .mg-error {
169 display: flex;
170 align-items: flex-start;
171 gap: 10px;
172 padding: 12px 14px;
173 background: rgba(239,68,68,0.08);
174 border: 1px solid rgba(239,68,68,0.28);
175 border-radius: 10px;
176 margin-bottom: var(--space-4);
177 font-size: 13px;
178 color: var(--text);
179 }
180
181 /* Section card */
182 .mg-section {
183 background: rgba(255,255,255,0.018);
184 border: 1px solid var(--border);
185 border-radius: 12px;
186 margin-bottom: var(--space-4);
187 overflow: hidden;
188 }
189 .mg-section-head {
190 padding: 16px 20px 14px;
191 border-bottom: 1px solid var(--border);
192 }
193 .mg-section-title {
194 margin: 0 0 4px;
195 font-family: var(--font-display);
196 font-size: 15px;
197 font-weight: 700;
198 color: var(--text-strong);
199 letter-spacing: -0.005em;
200 }
201 .mg-section-sub {
202 margin: 0;
203 font-size: 12.5px;
204 color: var(--text-muted);
205 line-height: 1.45;
206 }
207 .mg-section-body { padding: 18px 20px; }
208
209 /* Radio types */
210 .mg-type-grid {
211 display: grid;
212 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
213 gap: 10px;
214 margin-bottom: 20px;
215 }
216 .mg-type-card {
217 position: relative;
218 cursor: pointer;
219 }
220 .mg-type-card input[type="radio"] {
221 position: absolute;
222 opacity: 0;
223 width: 0; height: 0;
224 }
225 .mg-type-label {
226 display: block;
227 padding: 14px 16px;
228 border: 1.5px solid var(--border-strong);
229 border-radius: 10px;
230 cursor: pointer;
231 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
232 user-select: none;
233 }
234 .mg-type-card input[type="radio"]:checked + .mg-type-label {
235 border-color: rgba(245,158,11,0.65);
236 background: rgba(245,158,11,0.06);
237 box-shadow: 0 0 0 3px rgba(245,158,11,0.14);
238 }
239 .mg-type-emoji { font-size: 20px; margin-bottom: 6px; display: block; }
240 .mg-type-name {
241 font-size: 13.5px;
242 font-weight: 700;
243 color: var(--text-strong);
244 display: block;
245 margin-bottom: 3px;
246 }
247 .mg-type-desc { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
248
249 /* Form fields */
250 .mg-field { margin-bottom: 16px; }
251 .mg-field:last-child { margin-bottom: 0; }
252 .mg-field-label {
253 display: block;
254 font-size: 12px;
255 font-weight: 600;
256 text-transform: uppercase;
257 letter-spacing: 0.06em;
258 color: var(--text-muted);
259 margin-bottom: 6px;
260 }
261 .mg-field-row {
262 display: grid;
263 grid-template-columns: 1fr auto 1fr;
264 gap: 10px;
265 align-items: center;
266 }
267 .mg-arrow { color: var(--text-muted); font-size: 18px; text-align: center; }
268 .mg-input, .mg-select, .mg-textarea {
269 width: 100%;
270 box-sizing: border-box;
271 padding: 9px 11px;
272 font: inherit;
273 font-size: 13.5px;
274 color: var(--text);
275 background: rgba(255,255,255,0.03);
276 border: 1px solid var(--border-strong);
277 border-radius: 8px;
278 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
279 }
280 .mg-input:focus, .mg-select:focus, .mg-textarea:focus {
281 outline: none;
282 border-color: rgba(245,158,11,0.55);
283 background: rgba(255,255,255,0.05);
284 box-shadow: 0 0 0 3px rgba(245,158,11,0.18);
285 }
286 .mg-textarea { resize: vertical; min-height: 90px; font-size: 13px; }
287 .mg-select {
288 appearance: none;
289 padding-right: 28px;
290 background-image:
291 linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
292 linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
293 background-position: right 10px top 52%, right 6px top 52%;
294 background-size: 5px 5px, 5px 5px;
295 background-repeat: no-repeat;
296 }
297
298 /* Collapsible type panels */
299 .mg-type-panel { display: none; }
300 .mg-type-panel.is-active { display: block; }
301
302 /* Submit */
303 .mg-actions {
304 display: flex;
305 align-items: center;
306 gap: 12px;
307 margin-top: var(--space-4);
308 }
309 .mg-btn {
310 display: inline-flex;
311 align-items: center;
312 justify-content: center;
313 gap: 8px;
314 padding: 10px 22px;
315 border-radius: 10px;
316 font-size: 14px;
317 font-weight: 600;
318 text-decoration: none;
319 border: 1px solid transparent;
320 cursor: pointer;
321 font: inherit;
322 line-height: 1;
323 white-space: nowrap;
324 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, opacity 120ms ease;
325 }
326 .mg-btn-primary {
327 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
328 color: #fff;
329 box-shadow: 0 6px 18px -6px rgba(245,158,11,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
330 }
331 .mg-btn-primary:hover {
332 transform: translateY(-1px);
333 box-shadow: 0 10px 24px -8px rgba(245,158,11,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
334 color: #fff;
335 text-decoration: none;
336 }
337 .mg-btn-primary:disabled {
338 cursor: not-allowed;
339 opacity: 0.6;
340 transform: none;
341 box-shadow: none;
342 }
343 .mg-btn-secondary {
344 background: rgba(255,255,255,0.06);
345 color: var(--text);
346 border-color: var(--border-strong);
347 }
348 .mg-btn-secondary:hover {
349 background: rgba(255,255,255,0.09);
350 text-decoration: none;
351 color: var(--text);
352 }
353 .mg-hint { font-size: 12px; color: var(--text-muted); }
354
355 /* ─── Progress page ─────────────────────────────────────────── */
356 .mgp-wrap {
357 max-width: 600px;
358 margin: 0 auto;
359 padding: var(--space-8) var(--space-4);
360 }
361 .mgp-head { text-align: center; margin-bottom: var(--space-6); }
362 .mgp-eyebrow {
363 display: inline-flex;
364 align-items: center;
365 gap: 8px;
366 text-transform: uppercase;
367 font-family: var(--font-mono);
368 font-size: 11px;
369 letter-spacing: 0.16em;
370 color: var(--text-muted);
371 font-weight: 600;
372 margin-bottom: 10px;
373 }
374 .mgp-eyebrow-dot {
375 width: 6px; height: 6px;
376 border-radius: 9999px;
377 background: linear-gradient(135deg, #f59e0b, #ef4444);
378 animation: mgp-pulse 1.5s ease-in-out infinite;
379 }
380 .mgp-eyebrow-dot.is-done { animation: none; background: #22c55e; }
381 .mgp-eyebrow-dot.is-failed { animation: none; background: #ef4444; }
382 @keyframes mgp-pulse {
383 0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,0.50); }
384 50% { box-shadow: 0 0 0 6px rgba(245,158,11,0); }
385 }
386 .mgp-title {
387 font-family: var(--font-display);
388 font-size: clamp(20px, 2.6vw, 28px);
389 font-weight: 800;
390 letter-spacing: -0.022em;
391 line-height: 1.1;
392 margin: 0 0 6px;
393 color: var(--text-strong);
394 }
395 .mgp-sub { font-size: 14px; color: var(--text-muted); margin: 0; }
396
397 /* Phase pills */
398 .mgp-phases {
399 display: flex;
400 flex-wrap: wrap;
401 gap: 6px;
402 justify-content: center;
403 margin-bottom: var(--space-5);
404 }
405 .mgp-phase {
406 display: inline-flex;
407 align-items: center;
408 gap: 5px;
409 padding: 4px 11px;
410 border-radius: 9999px;
411 font-size: 12px;
412 font-weight: 600;
413 background: rgba(255,255,255,0.05);
414 border: 1px solid var(--border);
415 color: var(--text-muted);
416 transition: all 120ms ease;
417 }
418 .mgp-phase.is-active {
419 background: rgba(245,158,11,0.12);
420 border-color: rgba(245,158,11,0.40);
421 color: #fbbf24;
422 }
423 .mgp-phase.is-done-phase {
424 background: rgba(34,197,94,0.10);
425 border-color: rgba(34,197,94,0.30);
426 color: #4ade80;
427 }
428 .mgp-phase.is-failed-phase {
429 background: rgba(239,68,68,0.10);
430 border-color: rgba(239,68,68,0.28);
431 color: #f87171;
432 }
433
434 /* Progress bar */
435 .mgp-bar-wrap {
436 background: rgba(255,255,255,0.06);
437 border-radius: 9999px;
438 height: 6px;
439 overflow: hidden;
440 margin-bottom: var(--space-3);
441 }
442 .mgp-bar-fill {
443 height: 100%;
444 border-radius: 9999px;
445 background: linear-gradient(90deg, #f59e0b, #ef4444);
446 transition: width 600ms ease;
447 }
448 .mgp-bar-fill.is-done { background: linear-gradient(90deg, #22c55e, #4ade80); }
449 .mgp-bar-fill.is-failed { background: #ef4444; }
450
451 /* Status card */
452 .mgp-card {
453 background: rgba(255,255,255,0.018);
454 border: 1px solid var(--border);
455 border-radius: 12px;
456 padding: 20px;
457 margin-bottom: var(--space-4);
458 }
459 .mgp-current-file {
460 font-family: var(--font-mono);
461 font-size: 12.5px;
462 color: var(--text-muted);
463 text-overflow: ellipsis;
464 overflow: hidden;
465 white-space: nowrap;
466 margin-top: 10px;
467 }
468 .mgp-file-count {
469 font-size: 13px;
470 color: var(--text-muted);
471 margin-top: 6px;
472 }
473
474 /* Done / fail states */
475 .mgp-done-box {
476 display: flex;
477 flex-direction: column;
478 align-items: center;
479 gap: 14px;
480 padding: 24px;
481 background: rgba(34,197,94,0.07);
482 border: 1px solid rgba(34,197,94,0.25);
483 border-radius: 12px;
484 text-align: center;
485 }
486 .mgp-done-icon { font-size: 36px; }
487 .mgp-done-title {
488 font-family: var(--font-display);
489 font-size: 18px;
490 font-weight: 700;
491 color: var(--text-strong);
492 margin: 0;
493 }
494 .mgp-done-sub { font-size: 13px; color: var(--text-muted); margin: 0; }
495 .mgp-fail-box {
496 display: flex;
497 flex-direction: column;
498 align-items: center;
499 gap: 12px;
500 padding: 24px;
501 background: rgba(239,68,68,0.07);
502 border: 1px solid rgba(239,68,68,0.25);
503 border-radius: 12px;
504 text-align: center;
505 }
506 .mgp-fail-icon { font-size: 32px; }
507 .mgp-fail-title {
508 font-family: var(--font-display);
509 font-size: 17px;
510 font-weight: 700;
511 color: var(--text-strong);
512 margin: 0;
513 }
514 .mgp-fail-msg {
515 font-size: 13px;
516 color: var(--text-muted);
517 font-family: var(--font-mono);
518 background: rgba(255,255,255,0.04);
519 padding: 8px 12px;
520 border-radius: 7px;
521 word-break: break-word;
522 }
523`;
524
525// ---------------------------------------------------------------------------
526// JS for the migration form (radio-driven panel toggling)
527// ---------------------------------------------------------------------------
528
529const migrateFormJs = `
530(function() {
531 var radios = document.querySelectorAll('input[name="migrationType"]');
532 var panels = document.querySelectorAll('.mg-type-panel');
533
534 function showPanel(val) {
535 panels.forEach(function(p) {
536 if (p.dataset.type === val) {
537 p.classList.add('is-active');
538 } else {
539 p.classList.remove('is-active');
540 }
541 });
542 }
543
544 radios.forEach(function(r) {
545 r.addEventListener('change', function() {
546 showPanel(this.value);
547 });
548 if (r.checked) showPanel(r.value);
549 });
550
551 // Disable submit button on form submission
552 var form = document.getElementById('migrate-form');
553 if (form) {
554 form.addEventListener('submit', function() {
555 var btn = form.querySelector('button[type="submit"]');
556 if (btn) {
557 btn.disabled = true;
558 btn.textContent = 'Starting migration…';
559 }
560 });
561 }
562})();
563`;
564
565// ---------------------------------------------------------------------------
566// Phase computation helper
567// ---------------------------------------------------------------------------
568
569const PHASES: MigrationJob["status"][] = [
570 "analyzing",
571 "translating",
572 "committing",
573 "opening-pr",
574 "done",
575];
576
577function phaseClass(
578 phase: MigrationJob["status"],
579 current: MigrationJob["status"]
580): string {
581 if (current === "failed") {
582 return phase === current ? "is-failed-phase" : "";
583 }
584 const phaseIdx = PHASES.indexOf(phase);
585 const currentIdx = PHASES.indexOf(current);
586 if (currentIdx < 0) return "";
587 if (phaseIdx < currentIdx) return "is-done-phase";
588 if (phaseIdx === currentIdx) return "is-active";
589 return "";
590}
591
592// ---------------------------------------------------------------------------
593// Route components
594// ---------------------------------------------------------------------------
595
596function MigrationFormPage({
597 owner,
598 repo,
599 user,
600 error,
601}: {
602 owner: string;
603 repo: string;
604 user: { username: string };
605 error?: string;
606}) {
607 const languages = [
608 "TypeScript",
609 "JavaScript",
610 "Python",
611 "Go",
612 "Rust",
613 "Java",
614 "Ruby",
615 "PHP",
616 "C#",
617 "Swift",
618 "Kotlin",
619 ];
620
621 return (
622 <div class="mg-wrap">
623 <header class="mg-head">
624 <div class="mg-eyebrow">
625 <span class="mg-eyebrow-dot" aria-hidden="true" />
626 Repository &middot; AI Migration
627 </div>
628 <h1 class="mg-title">
629 <span class="mg-title-grad">AI Codebase Migration</span>
630 </h1>
631 <p class="mg-sub">
632 Say what you want migrated. Claude translates your code, creates a
633 new branch, and opens a draft PR — without touching your existing
634 code.
635 </p>
636 </header>
637
638 <div class="mg-warning">
639 <span class="mg-warning-icon">&#9888;</span>
640 <span>
641 <strong>This does NOT modify your existing code.</strong> A new branch
642 is created with the translated files and a pull request is opened for
643 your review. Inspect the diff and test thoroughly before merging.
644 </span>
645 </div>
646
647 {error && (
648 <div class="mg-error">
649 <span>&#10005; {error}</span>
650 </div>
651 )}
652
653 <section class="mg-section">
654 <div class="mg-section-head">
655 <h2 class="mg-section-title">Migration type</h2>
656 <p class="mg-section-sub">
657 Choose what kind of migration you want to perform.
658 </p>
659 </div>
660 <div class="mg-section-body">
661 <form
662 method="post"
663 action={`/${owner}/${repo}/migrate/start`}
664 id="migrate-form"
665 >
666 <div class="mg-type-grid">
667 <label class="mg-type-card">
668 <input
669 type="radio"
670 name="migrationType"
671 value="language"
672 defaultChecked
673 />
674 <span class="mg-type-label">
675 <span class="mg-type-emoji">&#127758;</span>
676 <span class="mg-type-name">Language</span>
677 <span class="mg-type-desc">
678 Translate source files to a different programming language
679 </span>
680 </span>
681 </label>
682 <label class="mg-type-card">
683 <input type="radio" name="migrationType" value="framework" />
684 <span class="mg-type-label">
685 <span class="mg-type-emoji">&#128295;</span>
686 <span class="mg-type-name">Framework</span>
687 <span class="mg-type-desc">
688 Switch from one framework or library to another
689 </span>
690 </span>
691 </label>
692 <label class="mg-type-card">
693 <input type="radio" name="migrationType" value="custom" />
694 <span class="mg-type-label">
695 <span class="mg-type-emoji">&#10024;</span>
696 <span class="mg-type-name">Custom</span>
697 <span class="mg-type-desc">
698 Free-form instruction for any kind of codebase transformation
699 </span>
700 </span>
701 </label>
702 </div>
703
704 {/* Language panel */}
705 <div class="mg-type-panel is-active" data-type="language">
706 <div class="mg-field">
707 <span class="mg-field-label">Language migration</span>
708 <div class="mg-field-row">
709 <select name="langFrom" class="mg-select">
710 {languages.map((l) => (
711 <option value={l}>{l}</option>
712 ))}
713 </select>
714 <span class="mg-arrow">&#8594;</span>
715 <select name="langTo" class="mg-select">
716 {languages.map((l, i) => (
717 <option value={l} selected={i === 1}>
718 {l}
719 </option>
720 ))}
721 </select>
722 </div>
723 </div>
724 </div>
725
726 {/* Framework panel */}
727 <div class="mg-type-panel" data-type="framework">
728 <div class="mg-field">
729 <span class="mg-field-label">Framework migration</span>
730 <div class="mg-field-row">
731 <input
732 type="text"
733 name="frameworkFrom"
734 class="mg-input"
735 placeholder="Express"
736 />
737 <span class="mg-arrow">&#8594;</span>
738 <input
739 type="text"
740 name="frameworkTo"
741 class="mg-input"
742 placeholder="Hono"
743 />
744 </div>
745 </div>
746 </div>
747
748 {/* Custom panel */}
749 <div class="mg-type-panel" data-type="custom">
750 <div class="mg-field">
751 <label class="mg-field-label" for="customDesc">
752 Describe the transformation
753 </label>
754 <textarea
755 name="customDesc"
756 id="customDesc"
757 class="mg-textarea"
758 placeholder="Convert all class components to React functional hooks, update deprecated APIs, and modernise the build config"
759 rows={4}
760 />
761 </div>
762 </div>
763
764 <div class="mg-actions">
765 <button type="submit" class="mg-btn mg-btn-primary">
766 &#9889; Start Migration
767 </button>
768 <a
769 href={`/${owner}/${repo}`}
770 class="mg-btn mg-btn-secondary"
771 >
772 Cancel
773 </a>
774 <span class="mg-hint">
775 Takes 2–10 minutes depending on repo size.
776 </span>
777 </div>
778 </form>
779 </div>
780 </section>
781
782 <script dangerouslySetInnerHTML={{ __html: migrateFormJs }} />
783 <style dangerouslySetInnerHTML={{ __html: migrateStyles }} />
784 </div>
785 );
786}
787
788function ProgressPage({
789 owner,
790 repo,
791 job,
792}: {
793 owner: string;
794 repo: string;
795 job: MigrationJob;
796}) {
797 const isDone = job.status === "done";
798 const isFailed = job.status === "failed";
799 const isRunning = !isDone && !isFailed;
800
801 const dotClass = isDone
802 ? "mgp-eyebrow-dot is-done"
803 : isFailed
804 ? "mgp-eyebrow-dot is-failed"
805 : "mgp-eyebrow-dot";
806
807 const barClass =
808 "mgp-bar-fill" +
809 (isDone ? " is-done" : isFailed ? " is-failed" : "");
810
811 const phases: { key: MigrationJob["status"]; label: string }[] = [
812 { key: "analyzing", label: "Analyzing" },
813 { key: "translating", label: "Translating" },
814 { key: "committing", label: "Committing" },
815 { key: "opening-pr", label: "Opening PR" },
816 { key: "done", label: "Done" },
817 ];
818
819 return (
820 <div class="mgp-wrap">
821 {/* Auto-refresh while running */}
822 {isRunning && (
823 <meta http-equiv="refresh" content="3" />
824 )}
825
826 <header class="mgp-head">
827 <div class="mgp-eyebrow">
828 <span class={dotClass} aria-hidden="true" />
829 {isDone
830 ? "Migration complete"
831 : isFailed
832 ? "Migration failed"
833 : "Migration in progress"}
834 </div>
835 <h1 class="mgp-title">{targetDescription(job.target)}</h1>
836 <p class="mgp-sub">
837 {owner}/{repo} &middot; Branch:{" "}
838 <code style="font-family: var(--font-mono); font-size: 12px;">
839 {job.branchName}
840 </code>
841 </p>
842 </header>
843
844 {/* Phase pills */}
845 <div class="mgp-phases">
846 {phases.map(({ key, label }) => (
847 <span class={`mgp-phase ${phaseClass(key, job.status)}`}>
848 {key === "done" && isDone && "✓ "}
849 {label}
850 </span>
851 ))}
852 </div>
853
854 {/* Progress bar */}
855 <div class="mgp-bar-wrap">
856 <div
857 class={barClass}
858 style={`width: ${job.progress}%`}
859 role="progressbar"
860 aria-valuenow={job.progress}
861 aria-valuemin={0}
862 aria-valuemax={100}
863 />
864 </div>
865
866 {/* Status card */}
867 {isRunning && (
868 <div class="mgp-card">
869 <p style="margin: 0; font-size: 14px; color: var(--text-strong); font-weight: 600;">
870 {statusLabel(job.status)}…
871 </p>
872 {job.currentFile && (
873 <p class="mgp-current-file">Translating {job.currentFile}</p>
874 )}
875 {job.filesTotal > 0 && (
876 <p class="mgp-file-count">
877 {job.filesTranslated} / {job.filesTotal} files
878 </p>
879 )}
880 </div>
881 )}
882
883 {/* Done */}
884 {isDone && job.prNumber && (
885 <div class="mgp-done-box">
886 <span class="mgp-done-icon">&#127881;</span>
887 <h2 class="mgp-done-title">Migration complete!</h2>
888 <p class="mgp-done-sub">
889 {job.filesTranslated} file
890 {job.filesTranslated !== 1 ? "s" : ""} translated and committed to{" "}
891 <code
892 style="font-family: var(--font-mono); font-size: 12px;"
893 >
894 {job.branchName}
895 </code>
896 </p>
897 <a
898 href={`/${owner}/${repo}/pulls/${job.prNumber}`}
899 class="mg-btn mg-btn-primary"
900 >
901 View Pull Request #{job.prNumber}
902 </a>
903 </div>
904 )}
905
906 {/* Failed */}
907 {isFailed && (
908 <div class="mgp-fail-box">
909 <span class="mgp-fail-icon">&#10060;</span>
910 <h2 class="mgp-fail-title">Migration failed</h2>
911 {job.error && <p class="mgp-fail-msg">{job.error}</p>}
912 <a
913 href={`/${owner}/${repo}/migrate`}
914 class="mg-btn mg-btn-secondary"
915 >
916 Try again
917 </a>
918 </div>
919 )}
920
921 <style dangerouslySetInnerHTML={{ __html: migrateStyles }} />
922 </div>
923 );
924}
925
926// ---------------------------------------------------------------------------
927// Routes
928// ---------------------------------------------------------------------------
929
930const codebaseMigratorRoutes = new Hono<AuthEnv>();
931
932/** GET /:owner/:repo/migrate — migration form */
933codebaseMigratorRoutes.get(
934 "/:owner/:repo/migrate",
935 softAuth,
936 requireAuth,
937 async (c) => {
938 const { owner, repo } = c.req.param();
939 const user = c.get("user")!;
940
941 const resolved = await resolveRepo(owner, repo);
942 if (!resolved) return c.notFound();
943
944 if (!isOwner(resolved, user.id)) {
945 return c.html(
946 <Layout title="Forbidden" user={user}>
947 <RepoHeader owner={owner} repo={repo} />
948 <RepoNav owner={owner} repo={repo} active="migrate" />
949 <div style="max-width:600px;margin:3rem auto;padding:0 1rem;text-align:center">
950 <h2 style="margin-bottom:8px">Owner access required</h2>
951 <p style="color:var(--text-muted)">
952 Only the repository owner can start a codebase migration.
953 </p>
954 </div>
955 </Layout>,
956 403
957 );
958 }
959
960 return c.html(
961 <Layout title={`AI Migration — ${owner}/${repo}`} user={user}>
962 <RepoHeader owner={owner} repo={repo} />
963 <RepoNav owner={owner} repo={repo} active="migrate" />
964 <MigrationFormPage owner={owner} repo={repo} user={user} />
965 </Layout>
966 );
967 }
968);
969
970/** POST /:owner/:repo/migrate/start — start a migration job */
971codebaseMigratorRoutes.post(
972 "/:owner/:repo/migrate/start",
973 softAuth,
974 requireAuth,
975 async (c) => {
976 const { owner, repo } = c.req.param();
977 const user = c.get("user")!;
978
979 const resolved = await resolveRepo(owner, repo);
980 if (!resolved) return c.notFound();
981
982 if (!isOwner(resolved, user.id)) {
983 return c.html(
984 <Layout title="Forbidden" user={user}>
985 <RepoHeader owner={owner} repo={repo} />
986 <RepoNav owner={owner} repo={repo} active="migrate" />
987 <div style="max-width:600px;margin:3rem auto;padding:0 1rem;text-align:center">
988 <h2>Owner access required</h2>
989 </div>
990 </Layout>,
991 403
992 );
993 }
994
995 const body = await c.req.parseBody();
996 const migrationType = String(body.migrationType || "language").trim();
997
998 let target: MigrationTarget;
999 let validationError: string | null = null;
1000
1001 if (migrationType === "language") {
1002 const from = String(body.langFrom || "").trim();
1003 const to = String(body.langTo || "").trim();
1004 if (!from || !to) {
1005 validationError = "Both source and target languages are required.";
1006 } else if (from === to) {
1007 validationError = "Source and target languages must be different.";
1008 } else {
1009 target = { type: "language", from, to };
1010 }
1011 } else if (migrationType === "framework") {
1012 const from = String(body.frameworkFrom || "").trim();
1013 const to = String(body.frameworkTo || "").trim();
1014 if (!from || !to) {
1015 validationError =
1016 "Both source and target framework names are required.";
1017 } else if (from.toLowerCase() === to.toLowerCase()) {
1018 validationError = "Source and target frameworks must be different.";
1019 } else {
1020 target = { type: "framework", from, to };
1021 }
1022 } else if (migrationType === "custom") {
1023 const description = String(body.customDesc || "").trim();
1024 if (!description) {
1025 validationError = "A description of the transformation is required.";
1026 } else if (description.length < 10) {
1027 validationError = "Please provide a more detailed description (min 10 characters).";
1028 } else {
1029 target = { type: "custom", description };
1030 }
1031 } else {
1032 validationError = "Invalid migration type.";
1033 }
1034
1035 if (validationError) {
1036 return c.html(
1037 <Layout title={`AI Migration — ${owner}/${repo}`} user={user}>
1038 <RepoHeader owner={owner} repo={repo} />
1039 <RepoNav owner={owner} repo={repo} active="migrate" />
1040 <MigrationFormPage
1041 owner={owner}
1042 repo={repo}
1043 user={user}
1044 error={validationError}
1045 />
1046 </Layout>,
1047 400
1048 );
1049 }
1050
1051 const result = await startMigration({
1052 owner,
1053 repo,
1054 repoId: resolved.repoId,
1055 userId: user.id,
1056 target: target!,
1057 });
1058
1059 if (!result.ok) {
1060 return c.html(
1061 <Layout title={`AI Migration — ${owner}/${repo}`} user={user}>
1062 <RepoHeader owner={owner} repo={repo} />
1063 <RepoNav owner={owner} repo={repo} active="migrate" />
1064 <MigrationFormPage
1065 owner={owner}
1066 repo={repo}
1067 user={user}
1068 error={result.error}
1069 />
1070 </Layout>,
1071 429
1072 );
1073 }
1074
1075 return c.redirect(`/${owner}/${repo}/migrate/${result.job.id}`);
1076 }
1077);
1078
1079/** GET /:owner/:repo/migrate/:jobId — progress page */
1080codebaseMigratorRoutes.get(
1081 "/:owner/:repo/migrate/:jobId",
1082 softAuth,
1083 async (c) => {
1084 const { owner, repo, jobId } = c.req.param();
1085 const user = c.get("user") ?? null;
1086
1087 const job = getJob(jobId);
1088 if (!job || job.owner !== owner || job.repo !== repo) {
1089 return c.html(
1090 <Layout title="Not Found" user={user}>
1091 <div style="max-width:600px;margin:3rem auto;padding:0 1rem;text-align:center">
1092 <h2>Migration job not found</h2>
1093 <p style="color:var(--text-muted)">
1094 This job may have expired or never existed.
1095 </p>
1096 <a href={`/${owner}/${repo}/migrate`} style="color:var(--accent)">
1097 Start a new migration
1098 </a>
1099 </div>
1100 </Layout>,
1101 404
1102 );
1103 }
1104
1105 return c.html(
1106 <Layout title={`Migration — ${owner}/${repo}`} user={user}>
1107 <RepoHeader owner={owner} repo={repo} />
1108 <RepoNav owner={owner} repo={repo} active="migrate" />
1109 <ProgressPage owner={owner} repo={repo} job={job} />
1110 </Layout>
1111 );
1112 }
1113);
1114
1115/** GET /:owner/:repo/migrate/:jobId/status — JSON status */
1116codebaseMigratorRoutes.get(
1117 "/:owner/:repo/migrate/:jobId/status",
1118 async (c) => {
1119 const { owner, repo, jobId } = c.req.param();
1120 const job = getJob(jobId);
1121 if (!job || job.owner !== owner || job.repo !== repo) {
1122 return c.json({ error: "Not found" }, 404);
1123 }
1124 return c.json(job);
1125 }
1126);
1127
1128export default codebaseMigratorRoutes;