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

migration-assistant.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.

migration-assistant.tsxBlame719 lines · 1 contributor
45f3b73Claude1/**
2 * Major-version migration assistant UI.
3 *
4 * GET /:owner/:repo/migrations/propose — picker form
5 * POST /:owner/:repo/migrations/propose — drives proposeMajorMigration
6 *
7 * Owner-only. Renders the result inline (Claude's explanation + diff
8 * preview + "Open PR" deep-link). Lives behind its own scoped `.migprop-*`
9 * class system — does NOT touch layout/components/ui.
10 */
11
12import { Hono } from "hono";
13import { and, eq } from "drizzle-orm";
14import { db } from "../db";
15import { repositories, users, pullRequests } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { IssueNav } from "./issues";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import {
22 proposeMajorMigration,
23 findManifest,
24 type ProposeMigrationResult,
25} from "../lib/migration-assistant";
26import { parseManifest } from "../lib/dep-updater";
27import { resolveRef } from "../git/repository";
28
29const migrationAssistant = new Hono<AuthEnv>();
30
31migrationAssistant.use("*", softAuth);
32
33/* ──────────────────────────────────────────────────────────────────────
34 * Scoped CSS — `.migprop-*`. Gradient hairline + orb hero, polished
35 * form, result panel. Never overrides layout primitives.
36 * ────────────────────────────────────────────────────────────────── */
37const migpropStyles = `
eed4684Claude38 .migprop-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
45f3b73Claude39
40 .migprop-hero {
41 position: relative;
42 margin-bottom: var(--space-5);
43 padding: var(--space-5) var(--space-6);
44 background: var(--bg-elevated);
45 border: 1px solid var(--border);
46 border-radius: 16px;
47 overflow: hidden;
48 }
49 .migprop-hero::before {
50 content: '';
51 position: absolute;
52 top: 0; left: 0; right: 0;
53 height: 2px;
54 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
55 opacity: 0.7;
56 pointer-events: none;
57 }
58 .migprop-hero-orb {
59 position: absolute;
60 inset: -20% -10% auto auto;
61 width: 380px; height: 380px;
62 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
63 filter: blur(80px);
64 opacity: 0.7;
65 pointer-events: none;
66 z-index: 0;
67 }
68 .migprop-hero-inner { position: relative; z-index: 1; }
69 .migprop-eyebrow {
70 display: inline-flex;
71 align-items: center;
72 gap: 8px;
73 text-transform: uppercase;
74 font-family: var(--font-mono);
75 font-size: 11px;
76 letter-spacing: 0.16em;
77 color: var(--text-muted);
78 font-weight: 600;
79 margin-bottom: 10px;
80 }
81 .migprop-eyebrow-dot {
82 width: 8px; height: 8px;
83 border-radius: 9999px;
84 background: linear-gradient(135deg, #8c6dff, #36c5d6);
85 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
86 }
87 .migprop-title {
88 font-family: var(--font-display);
89 font-size: clamp(28px, 4vw, 40px);
90 font-weight: 800;
91 letter-spacing: -0.028em;
92 line-height: 1.05;
93 margin: 0 0 var(--space-2);
94 color: var(--text-strong);
95 }
96 .migprop-title-grad {
97 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
98 -webkit-background-clip: text;
99 background-clip: text;
100 -webkit-text-fill-color: transparent;
101 color: transparent;
102 }
103 .migprop-sub {
104 font-size: 15px;
105 color: var(--text-muted);
106 margin: 0;
107 line-height: 1.5;
108 max-width: 640px;
109 }
110 .migprop-sub code {
111 font-family: var(--font-mono);
112 font-size: 13px;
113 background: var(--bg-tertiary);
114 padding: 1px 5px;
115 border-radius: 4px;
116 }
117
118 /* ── Form card ── */
119 .migprop-card {
120 margin-bottom: var(--space-5);
121 background: var(--bg-elevated);
122 border: 1px solid var(--border);
123 border-radius: 14px;
124 overflow: hidden;
125 position: relative;
126 }
127 .migprop-card::before {
128 content: '';
129 position: absolute;
130 top: 0; left: 0; right: 0;
131 height: 2px;
132 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
133 opacity: 0.45;
134 pointer-events: none;
135 }
136 .migprop-card-body { padding: var(--space-5) var(--space-5) var(--space-4); }
137 .migprop-card-title {
138 margin: 0 0 var(--space-2);
139 font-family: var(--font-display);
140 font-size: 16px;
141 font-weight: 700;
142 color: var(--text-strong);
143 letter-spacing: -0.018em;
144 }
145 .migprop-card-sub {
146 margin: 0 0 var(--space-4);
147 font-size: 13px;
148 color: var(--text-muted);
149 line-height: 1.45;
150 }
151
152 .migprop-fields {
153 display: grid;
154 grid-template-columns: 2fr 1fr 1fr;
155 gap: var(--space-3);
156 align-items: end;
157 }
158 @media (max-width: 720px) {
159 .migprop-fields { grid-template-columns: 1fr; }
160 }
161 .migprop-field {
162 display: flex;
163 flex-direction: column;
164 gap: 6px;
165 }
166 .migprop-label {
167 font-size: 12px;
168 text-transform: uppercase;
169 font-family: var(--font-mono);
170 color: var(--text-muted);
171 letter-spacing: 0.06em;
172 font-weight: 600;
173 }
174 .migprop-input {
175 font-family: var(--font-mono);
176 font-size: 13px;
177 padding: 9px 11px;
178 background: var(--bg-tertiary);
179 border: 1px solid var(--border);
180 border-radius: 8px;
181 color: var(--text-strong);
182 line-height: 1.3;
183 outline: none;
184 transition: border-color 100ms ease, background 100ms ease;
185 }
186 .migprop-input:focus {
187 border-color: rgba(140,109,255,0.55);
188 background: var(--bg-secondary);
189 }
190
191 .migprop-actions {
192 display: flex;
193 align-items: center;
194 gap: 12px;
195 margin-top: var(--space-4);
196 flex-wrap: wrap;
197 }
198 .migprop-cta {
199 display: inline-flex;
200 align-items: center;
201 gap: 7px;
202 padding: 10px 18px;
203 font-size: 13.5px;
204 font-weight: 600;
205 border-radius: 10px;
206 border: 1px solid transparent;
207 cursor: pointer;
208 font: inherit;
209 line-height: 1;
210 white-space: nowrap;
211 text-decoration: none;
212 color: #ffffff;
213 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
214 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
215 transition: transform 120ms ease, box-shadow 120ms ease;
216 }
217 .migprop-cta:hover {
218 transform: translateY(-1px);
219 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
220 color: #ffffff;
221 text-decoration: none;
222 }
223 .migprop-hint {
224 font-size: 12px;
225 color: var(--text-muted);
226 font-family: var(--font-mono);
227 }
228
229 /* ── Result panel ── */
230 .migprop-result {
231 margin-top: var(--space-5);
232 padding: var(--space-5);
233 border: 1px solid var(--border);
234 border-radius: 14px;
235 background: var(--bg-elevated);
236 position: relative;
237 overflow: hidden;
238 }
239 .migprop-result::before {
240 content: '';
241 position: absolute;
242 top: 0; left: 0; right: 0;
243 height: 2px;
244 background: linear-gradient(90deg, transparent 0%, #6ee7b7 30%, #36c5d6 70%, transparent 100%);
245 opacity: 0.55;
246 }
247 .migprop-result-title {
248 font-family: var(--font-display);
249 font-size: 18px;
250 font-weight: 700;
251 color: var(--text-strong);
252 margin: 10px 0 6px;
253 }
254 .migprop-result-sub {
255 margin: 0 0 var(--space-4);
256 font-size: 13px;
257 color: var(--text-muted);
258 }
259 .migprop-pill {
260 display: inline-flex;
261 align-items: center;
262 gap: 6px;
263 padding: 3px 9px;
264 border-radius: 9999px;
265 font-size: 11px;
266 font-weight: 600;
267 letter-spacing: 0.04em;
268 text-transform: uppercase;
269 background: rgba(110,231,183,0.14);
270 color: #6ee7b7;
271 box-shadow: inset 0 0 0 1px rgba(110,231,183,0.32);
272 }
273 .migprop-pill.is-error {
274 background: rgba(248,113,113,0.14);
275 color: #fca5a5;
276 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
277 }
278 .migprop-explanation {
279 margin: 0 0 var(--space-4);
280 padding: var(--space-3);
281 background: rgba(255,255,255,0.025);
282 border: 1px solid var(--border);
283 border-radius: 8px;
284 font-size: 13.5px;
285 line-height: 1.55;
286 color: var(--text);
287 white-space: pre-wrap;
288 }
289 .migprop-meta {
290 font-family: var(--font-mono);
291 font-size: 12.5px;
292 color: var(--text-muted);
293 display: flex;
294 gap: 14px;
295 flex-wrap: wrap;
296 margin-bottom: var(--space-3);
297 }
298 .migprop-meta code {
299 font-family: var(--font-mono);
300 background: var(--bg-tertiary);
301 padding: 2px 7px;
302 border-radius: 5px;
303 border: 1px solid var(--border);
304 }
305
306 .migprop-notice {
307 max-width: 540px;
308 margin: var(--space-12) auto;
309 padding: var(--space-6);
310 text-align: center;
311 background: var(--bg-elevated);
312 border: 1px solid var(--border);
313 border-radius: 16px;
314 }
315 .migprop-notice h2 {
316 font-family: var(--font-display);
317 font-size: 22px;
318 margin: 0 0 8px;
319 color: var(--text-strong);
320 }
321 .migprop-notice p { color: var(--text-muted); margin: 0; font-size: 14px; }
322`;
323
324/**
325 * Resolve repo row + enforce owner-only access. Mirrors the helper in
326 * dep-updater.tsx so the two routes have identical permission semantics.
327 */
328async function resolveOwnerRepo(
329 c: any,
330 ownerName: string,
331 repoName: string
332): Promise<
333 | { kind: "ok"; repo: typeof repositories.$inferSelect }
334 | { kind: "response"; res: Response }
335> {
336 const user = c.get("user");
337 if (!user) {
338 return {
339 kind: "response",
340 res: c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`),
341 };
342 }
343 try {
344 const [owner] = await db
345 .select()
346 .from(users)
347 .where(eq(users.username, ownerName))
348 .limit(1);
349 if (!owner) return { kind: "response", res: c.notFound() };
350 if (owner.id !== user.id) {
351 return {
352 kind: "response",
353 res: c.html(
354 <Layout title="Unauthorized" user={user}>
355 <div class="migprop-wrap">
356 <div class="migprop-notice">
357 <h2>Unauthorized</h2>
358 <p>Only the repository owner can request a migration plan.</p>
359 </div>
360 </div>
361 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
362 </Layout>,
363 403
364 ),
365 };
366 }
367 const [repo] = await db
368 .select()
369 .from(repositories)
370 .where(
371 and(
372 eq(repositories.ownerId, owner.id),
373 eq(repositories.name, repoName)
374 )
375 )
376 .limit(1);
377 if (!repo) return { kind: "response", res: c.notFound() };
378 return { kind: "ok", repo };
379 } catch {
380 return {
381 kind: "response",
382 res: c.html(
383 <Layout title="Error" user={user}>
384 <div class="migprop-wrap">
385 <div class="migprop-notice">
386 <h2>Service unavailable</h2>
387 <p>The migration assistant is temporarily offline.</p>
388 </div>
389 </div>
390 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
391 </Layout>,
392 503
393 ),
394 };
395 }
396}
397
398/**
399 * Pull the manifest off the default branch + return a flat list of
400 * declared deps for the autocomplete datalist. Failure is non-fatal —
401 * the form still renders, just with an empty datalist.
402 */
403async function listDeclaredDeps(
404 owner: string,
405 name: string,
406 branch: string
407): Promise<Array<{ name: string; range: string; kind: "dep" | "dev" }>> {
408 try {
409 const baseSha = await resolveRef(owner, name, branch);
410 if (!baseSha) return [];
411 const manifest = await findManifest(owner, name, baseSha);
412 if (!manifest || manifest.path !== "package.json") return [];
413 const parsed = parseManifest(manifest.content);
414 const all: Array<{ name: string; range: string; kind: "dep" | "dev" }> = [];
415 for (const [n, r] of Object.entries(parsed.dependencies || {})) {
416 all.push({ name: n, range: r, kind: "dep" });
417 }
418 for (const [n, r] of Object.entries(parsed.devDependencies || {})) {
419 all.push({ name: n, range: r, kind: "dev" });
420 }
421 return all;
422 } catch {
423 return [];
424 }
425}
426
427function HeroBlock({
428 ownerName,
429 repoName,
430 dep,
431}: {
432 ownerName: string;
433 repoName: string;
434 dep?: string;
435}) {
436 return (
437 <section class="migprop-hero">
438 <div class="migprop-hero-orb" aria-hidden="true" />
439 <div class="migprop-hero-inner">
440 <div class="migprop-eyebrow">
441 <span class="migprop-eyebrow-dot" aria-hidden="true" />
442 {ownerName}/{repoName} · Migration assistant
443 </div>
444 <h1 class="migprop-title">
445 <span class="migprop-title-grad">
446 {dep ? `Migrate ${dep}` : "Migrate a dependency"}
447 </span>
448 </h1>
449 <p class="migprop-sub">
450 Tell us which package to upgrade and to which major. Claude reads
451 your manifest + call-sites, drafts the upgrade as a PR, and
452 updates tests along with the source.
453 </p>
454 </div>
455 </section>
456 );
457}
458
459// ─────────────────────────────────────────────────────────────────────────
460// GET — form
461// ─────────────────────────────────────────────────────────────────────────
462
463migrationAssistant.get(
464 "/:owner/:repo/migrations/propose",
465 requireAuth,
466 async (c) => {
467 const { owner: ownerName, repo: repoName } = c.req.param();
468 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
469 if (resolved.kind === "response") return resolved.res;
470 const { repo } = resolved;
471 const user = c.get("user")!;
472
473 const branch = repo.defaultBranch || "main";
474 const declared = await listDeclaredDeps(ownerName, repoName, branch);
475 const prefill = c.req.query("dep") || "";
476
477 return c.html(
478 <Layout title={`Migrate dep — ${ownerName}/${repoName}`} user={user}>
479 <RepoHeader owner={ownerName} repo={repoName} />
480 <IssueNav owner={ownerName} repo={repoName} active="code" />
481 <div class="migprop-wrap">
482 <HeroBlock ownerName={ownerName} repoName={repoName} dep={prefill} />
483 <section class="migprop-card">
484 <div class="migprop-card-body">
485 <h2 class="migprop-card-title">Get a migration plan</h2>
486 <p class="migprop-card-sub">
487 We'll detect uses in your tree, ask Claude for a patch set,
488 and (if it returns one) open a PR labeled{" "}
489 <code>ai:major-migration</code>. Server must have an{" "}
490 <code>ANTHROPIC_API_KEY</code> configured.
491 </p>
492 <form
493 method="post"
494 action={`/${ownerName}/${repoName}/migrations/propose`}
495 >
496 <div class="migprop-fields">
497 <div class="migprop-field">
498 <label class="migprop-label" for="migprop-dep">
499 Dependency
500 </label>
501 <input
502 id="migprop-dep"
503 class="migprop-input"
504 type="text"
505 name="dependency"
506 list="migprop-deplist"
507 placeholder="hono"
508 value={prefill}
509 required
510 autocomplete="off"
511 />
512 <datalist id="migprop-deplist">
513 {declared.map((d) => (
514 <option value={d.name}>
515 {d.range} ({d.kind})
516 </option>
517 ))}
518 </datalist>
519 </div>
520 <div class="migprop-field">
521 <label class="migprop-label" for="migprop-from">
522 From
523 </label>
524 <input
525 id="migprop-from"
526 class="migprop-input"
527 type="text"
528 name="fromVersion"
529 placeholder="^3.0.0"
530 required
531 />
532 </div>
533 <div class="migprop-field">
534 <label class="migprop-label" for="migprop-to">
535 To
536 </label>
537 <input
538 id="migprop-to"
539 class="migprop-input"
540 type="text"
541 name="toVersion"
542 placeholder="4.0.0"
543 required
544 />
545 </div>
546 </div>
547 <div class="migprop-actions">
548 <button type="submit" class="migprop-cta">
549 Get migration plan
550 </button>
551 <span class="migprop-hint">
552 Opens a PR on success. Bypasses the 7-day throttle.
553 </span>
554 </div>
555 </form>
556 </div>
557 </section>
558 </div>
559 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
560 </Layout>
561 );
562 }
563);
564
565// ─────────────────────────────────────────────────────────────────────────
566// POST — run the assistant + render the result inline
567// ─────────────────────────────────────────────────────────────────────────
568
569migrationAssistant.post(
570 "/:owner/:repo/migrations/propose",
571 requireAuth,
572 async (c) => {
573 const { owner: ownerName, repo: repoName } = c.req.param();
574 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
575 if (resolved.kind === "response") return resolved.res;
576 const { repo } = resolved;
577 const user = c.get("user")!;
578
579 const form = await c.req.parseBody();
580 const dependency = String(form.dependency ?? "").trim();
581 const fromVersion = String(form.fromVersion ?? "").trim();
582 const toVersion = String(form.toVersion ?? "").trim();
583
584 const renderError = (msg: string) =>
585 c.html(
586 <Layout title={`Migrate dep — ${ownerName}/${repoName}`} user={user}>
587 <RepoHeader owner={ownerName} repo={repoName} />
588 <IssueNav owner={ownerName} repo={repoName} active="code" />
589 <div class="migprop-wrap">
590 <HeroBlock
591 ownerName={ownerName}
592 repoName={repoName}
593 dep={dependency}
594 />
595 <section class="migprop-result">
596 <span class="migprop-pill is-error">Failed</span>
597 <h2 class="migprop-result-title">
598 Could not generate a migration plan
599 </h2>
600 <p class="migprop-result-sub">{msg}</p>
601 <a
602 class="migprop-cta"
603 href={`/${ownerName}/${repoName}/migrations/propose`}
604 >
605 Try again
606 </a>
607 </section>
608 </div>
609 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
610 </Layout>
611 );
612
613 if (!dependency || !fromVersion || !toVersion) {
614 return renderError("All three fields are required.");
615 }
616
617 const branch = repo.defaultBranch || "main";
618 const baseSha = await resolveRef(ownerName, repoName, branch);
619 if (!baseSha) {
620 return renderError(
621 `Could not resolve the default branch (\`${branch}\`).`
622 );
623 }
624
625 let result: ProposeMigrationResult | null = null;
626 try {
627 result = await proposeMajorMigration({
628 repositoryId: repo.id,
629 dependency,
630 fromVersion,
631 toVersion,
632 baseSha,
633 // UI users explicitly want the migration; bypass the watcher's
634 // 7-day cool-down.
635 skipThrottle: true,
636 });
637 } catch (err) {
638 console.error("[migrations] propose threw:", err);
639 return renderError(
640 "The migration assistant encountered an unexpected error."
641 );
642 }
643
644 if (!result) {
645 return renderError(
646 "Claude did not return a usable patch. This usually means the model couldn't find safe, mechanical changes — try narrowing the scope or set ANTHROPIC_API_KEY if the server is missing it."
647 );
648 }
649
650 // Look up the PR body so we can preview the explanation inline.
651 let explanation = "";
652 try {
653 const [pr] = await db
654 .select({ body: pullRequests.body })
655 .from(pullRequests)
656 .where(
657 and(
658 eq(pullRequests.repositoryId, repo.id),
659 eq(pullRequests.number, result.prNumber)
660 )
661 )
662 .limit(1);
663 if (pr?.body) {
664 // Extract the "### Summary" block. Best-effort — fall back to the
665 // entire body when the marker isn't found.
666 const match = pr.body.match(/### Summary\n([\s\S]*?)\n\n###/);
667 explanation = match ? match[1].trim() : pr.body;
668 }
669 } catch {
670 // ignore — we can still render the success page.
671 }
672
673 return c.html(
674 <Layout title={`Migrate dep — ${ownerName}/${repoName}`} user={user}>
675 <RepoHeader owner={ownerName} repo={repoName} />
676 <IssueNav owner={ownerName} repo={repoName} active="code" />
677 <div class="migprop-wrap">
678 <HeroBlock
679 ownerName={ownerName}
680 repoName={repoName}
681 dep={dependency}
682 />
683 <section class="migprop-result">
684 <span class="migprop-pill">PR opened</span>
685 <h2 class="migprop-result-title">Migration plan ready</h2>
686 <p class="migprop-result-sub">
687 Claude proposed a patch set for <code>{dependency}</code>{" "}
688 {fromVersion} → {toVersion}. Review the diff and merge if the
689 call-sites look right.
690 </p>
691 <div class="migprop-meta">
692 <span>
693 Branch: <code>{result.branch}</code>
694 </span>
695 <span>
696 Base: <code>{branch}</code>
697 </span>
698 <span>
699 PR: <code>#{result.prNumber}</code>
700 </span>
701 </div>
702 {explanation ? (
703 <div class="migprop-explanation">{explanation}</div>
704 ) : null}
705 <a
706 class="migprop-cta"
707 href={`/${ownerName}/${repoName}/pulls/${result.prNumber}`}
708 >
709 Open PR #{result.prNumber}
710 </a>
711 </section>
712 </div>
713 <style dangerouslySetInnerHTML={{ __html: migpropStyles }} />
714 </Layout>
715 );
716 }
717);
718
719export default migrationAssistant;