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

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

refactors.tsxBlame643 lines · 1 contributor
23d0abfClaude1/**
2 * Multi-repo refactor agent — UI + API.
3 *
4 * One file owns both surfaces because they share the same `planRefactor` /
5 * `executeRefactor` / `getRefactor` helpers from `src/lib/multi-repo-refactor.ts`
6 * and we don't want to fragment the auth / styling story across two route
7 * files. The mount in `src/app.tsx` is a single `app.route("/", refactorRoutes)`.
8 *
9 * UI surface (`/refactors`):
10 * GET /refactors — list a user's refactors with status pills.
11 * POST /refactors — accepts the textarea + repo-multi-select form,
12 * kicks off planning, redirects to /refactors/:id.
13 * GET /refactors/:id — per-refactor detail page: PR table, status
14 * pills, links into each repo's /pulls page.
15 * POST /refactors/:id/execute — kicks off the per-repo PR fan-out.
16 *
17 * API surface (`/api/v2/refactors`):
18 * POST /api/v2/refactors — create refactor + plan
19 * POST /api/v2/refactors/:id/execute — kick off per-repo PRs
20 * GET /api/v2/refactors/:id — current state
21 *
22 * Hard rules respected:
23 * - Shared layout + nav untouched (we only added the `/refactors` nav link
24 * in layout.tsx).
25 * - All CSS scoped under `.refac-*`.
26 * - Reuses helpers from `ai-patch-generator` and `spec-to-pr` via the
27 * shared `ai-client.ts` + `git/repository.ts` modules.
28 */
29
30import { Hono } from "hono";
31import { eq } from "drizzle-orm";
32import { db } from "../db";
33import { repositories } from "../db/schema";
34import { Layout } from "../views/layout";
35import { softAuth, requireAuth } from "../middleware/auth";
36import type { AuthEnv } from "../middleware/auth";
37import {
38 executeRefactor,
39 getRefactor,
40 listRefactorsForUser,
41 planRefactor,
42} from "../lib/multi-repo-refactor";
43
44const refactors = new Hono<AuthEnv>();
45
46// All surfaces require an authenticated user.
47refactors.use("/refactors", softAuth, requireAuth);
48refactors.use("/refactors/*", softAuth, requireAuth);
49refactors.use("/api/v2/refactors", softAuth, requireAuth);
50refactors.use("/api/v2/refactors/*", softAuth, requireAuth);
51
52// ---------------------------------------------------------------------------
53// Scoped CSS — every class is `.refac-*`.
54// ---------------------------------------------------------------------------
55const refacStyles = `
eed4684Claude56 .refac-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
23d0abfClaude57
58 .refac-hero {
59 position: relative;
60 margin-bottom: var(--space-5);
61 padding: var(--space-5) var(--space-6);
62 background: var(--bg-elevated);
63 border: 1px solid var(--border);
64 border-radius: 16px;
65 overflow: hidden;
66 }
67 .refac-hero::before {
68 content: '';
69 position: absolute;
70 top: 0; left: 0; right: 0;
71 height: 2px;
6fd5915Claude72 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
23d0abfClaude73 opacity: 0.78;
74 pointer-events: none;
75 }
76 .refac-hero-orb {
77 position: absolute;
78 inset: -30% -15% auto auto;
79 width: 460px; height: 460px;
6fd5915Claude80 background: radial-gradient(circle, rgba(91,110,232,0.24), rgba(95,143,160,0.12) 45%, transparent 70%);
23d0abfClaude81 filter: blur(80px);
82 opacity: 0.78;
83 pointer-events: none;
84 z-index: 0;
85 }
86 .refac-hero-inner { position: relative; z-index: 1; max-width: 760px; }
87 .refac-eyebrow {
88 display: inline-flex;
89 align-items: center;
90 gap: 8px;
91 text-transform: uppercase;
92 font-family: var(--font-mono);
93 font-size: 11px;
94 letter-spacing: 0.18em;
95 color: var(--text-muted);
96 font-weight: 600;
97 margin-bottom: 14px;
98 }
99 .refac-eyebrow-dot {
100 width: 8px; height: 8px;
101 border-radius: 9999px;
6fd5915Claude102 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
103 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
23d0abfClaude104 }
105 .refac-title {
106 font-family: var(--font-display);
107 font-size: clamp(28px, 4vw, 40px);
108 font-weight: 800;
109 letter-spacing: -0.028em;
110 line-height: 1.05;
111 margin: 0 0 var(--space-2);
112 color: var(--text-strong);
113 }
114 .refac-title-grad {
6fd5915Claude115 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
23d0abfClaude116 -webkit-background-clip: text;
117 background-clip: text;
118 -webkit-text-fill-color: transparent;
119 color: transparent;
120 }
121 .refac-sub {
122 font-size: 15px;
123 color: var(--text-muted);
124 margin: 0;
125 line-height: 1.55;
126 }
127
128 .refac-card {
129 background: var(--bg-elevated);
130 border: 1px solid var(--border);
131 border-radius: 14px;
132 padding: var(--space-4) var(--space-5);
133 margin-bottom: var(--space-3);
134 }
135 .refac-card-head {
136 display: flex;
137 justify-content: space-between;
138 align-items: baseline;
139 gap: var(--space-3);
140 margin-bottom: var(--space-2);
141 }
142 .refac-card-title {
143 font-family: var(--font-display);
144 font-size: 18px;
145 font-weight: 700;
146 color: var(--text-strong);
147 margin: 0;
148 }
149 .refac-card-title a { color: inherit; text-decoration: none; }
6fd5915Claude150 .refac-card-title a:hover { color: #5b6ee8; }
23d0abfClaude151 .refac-card-meta {
152 font-size: 12px;
153 color: var(--text-muted);
154 font-variant-numeric: tabular-nums;
155 }
156 .refac-card-desc {
157 color: var(--text);
158 font-size: 14px;
159 line-height: 1.55;
160 margin: 0;
161 }
162
163 .refac-pill {
164 display: inline-flex;
165 align-items: center;
166 gap: 6px;
167 padding: 3px 9px;
168 border-radius: 9999px;
169 font-size: 11px;
170 font-weight: 600;
171 font-family: var(--font-mono);
172 text-transform: uppercase;
173 letter-spacing: 0.06em;
174 border: 1px solid var(--border);
175 background: var(--bg);
176 color: var(--text-muted);
177 }
6fd5915Claude178 .refac-pill.is-planning { color: #5f8fa0; border-color: rgba(95,143,160,0.35); background: rgba(95,143,160,0.08); }
179 .refac-pill.is-building { color: #5b6ee8; border-color: rgba(91,110,232,0.35); background: rgba(91,110,232,0.08); }
23d0abfClaude180 .refac-pill.is-ready_for_review { color: #4ade80; border-color: rgba(74,222,128,0.35); background: rgba(74,222,128,0.08); }
181 .refac-pill.is-merged { color: #4ade80; border-color: rgba(74,222,128,0.45); background: rgba(74,222,128,0.12); }
182 .refac-pill.is-failed { color: #fca5a5; border-color: rgba(252,165,165,0.35); background: rgba(252,165,165,0.08); }
183 .refac-pill.is-pending { color: var(--text-muted); }
184 .refac-pill.is-opened { color: #4ade80; border-color: rgba(74,222,128,0.35); background: rgba(74,222,128,0.08); }
185
186 /* New-refactor form */
187 .refac-form {
188 background: var(--bg-elevated);
189 border: 1px solid var(--border);
190 border-radius: 14px;
191 padding: var(--space-5);
192 margin-bottom: var(--space-5);
193 }
194 .refac-form label {
195 display: block;
196 font-size: 12px;
197 font-weight: 600;
198 text-transform: uppercase;
199 letter-spacing: 0.1em;
200 color: var(--text-muted);
201 margin-bottom: 6px;
202 }
203 .refac-form textarea {
204 width: 100%;
205 min-height: 90px;
206 padding: 12px 14px;
207 background: var(--bg);
208 color: var(--text);
209 border: 1px solid var(--border);
210 border-radius: 10px;
211 font-family: var(--font-mono);
212 font-size: 13px;
213 line-height: 1.5;
214 resize: vertical;
215 }
216 .refac-form-row { margin-bottom: var(--space-3); }
217 .refac-repos-list {
218 max-height: 240px;
219 overflow: auto;
220 border: 1px solid var(--border);
221 border-radius: 10px;
222 padding: 8px 12px;
223 background: var(--bg);
224 }
225 .refac-repos-list label {
226 display: flex;
227 align-items: center;
228 gap: 8px;
229 margin-bottom: 4px;
230 font-size: 13px;
231 font-weight: 500;
232 text-transform: none;
233 letter-spacing: 0;
234 color: var(--text);
235 }
236 .refac-actions { display: flex; gap: 10px; align-items: center; }
237 .refac-btn {
238 display: inline-flex;
239 align-items: center;
240 gap: 6px;
241 padding: 8px 16px;
242 border-radius: 8px;
243 font-weight: 600;
244 font-size: 13px;
245 border: 1px solid var(--border);
246 background: var(--bg);
247 color: var(--text);
248 cursor: pointer;
249 text-decoration: none;
250 }
251 .refac-btn-primary {
6fd5915Claude252 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
23d0abfClaude253 border-color: transparent;
254 color: #fff;
255 }
256 .refac-btn-primary:hover { filter: brightness(1.05); }
257
258 /* Per-refactor detail table */
259 .refac-pr-table {
260 width: 100%;
261 border-collapse: collapse;
262 background: var(--bg-elevated);
263 border: 1px solid var(--border);
264 border-radius: 14px;
265 overflow: hidden;
266 }
267 .refac-pr-table th, .refac-pr-table td {
268 text-align: left;
269 padding: 10px 14px;
270 border-bottom: 1px solid var(--border);
271 font-size: 13px;
272 }
273 .refac-pr-table th {
274 background: var(--bg);
275 color: var(--text-muted);
276 font-size: 11px;
277 text-transform: uppercase;
278 letter-spacing: 0.08em;
279 }
280 .refac-pr-table tr:last-child td { border-bottom: none; }
281 .refac-pr-error {
282 margin-top: 4px;
283 font-size: 11px;
284 color: #fca5a5;
285 font-family: var(--font-mono);
286 }
287
288 .refac-empty {
289 text-align: center;
290 padding: var(--space-5);
291 background: var(--bg-elevated);
292 border: 1px dashed var(--border);
293 border-radius: 14px;
294 color: var(--text-muted);
295 }
296`;
297
298function statusLabel(status: string): string {
299 // Render the underlying status string verbatim, but capitalise the
300 // first letter so the UI looks tidy. Keep snake_case readable.
301 if (!status) return "—";
302 return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
303}
304
305function Pill({ status }: { status: string }) {
306 return (
307 <span class={`refac-pill is-${status}`}>
308 {statusLabel(status)}
309 </span>
310 );
311}
312
313// ---------------------------------------------------------------------------
314// UI: GET /refactors — list + new-refactor form
315// ---------------------------------------------------------------------------
316refactors.get("/refactors", async (c) => {
317 const user = c.get("user")!;
318 const list = await listRefactorsForUser(user.id);
319
320 // Load every repo the user owns to populate the multi-select.
321 let userRepos: Array<{ id: string; name: string }> = [];
322 try {
323 userRepos = await db
324 .select({ id: repositories.id, name: repositories.name })
325 .from(repositories)
326 .where(eq(repositories.ownerId, user.id))
327 .orderBy(repositories.name);
328 } catch {
329 userRepos = [];
330 }
331
332 return c.html(
333 <Layout title="Refactor across repos" user={user}>
334 <div class="refac-wrap">
335 <section class="refac-hero">
336 <div class="refac-hero-orb" aria-hidden="true" />
337 <div class="refac-hero-inner">
338 <div class="refac-eyebrow">
339 <span class="refac-eyebrow-dot" aria-hidden="true" />
340 Multi-repo refactor agent
341 </div>
342 <h1 class="refac-title">
343 <span class="refac-title-grad">Refactor across repos</span>
344 </h1>
345 <p class="refac-sub">
346 One English request. Coordinated PRs across every affected
347 repo. Click into a refactor to see the per-repo PR status and
348 merge them as a single logical change.
349 </p>
350 </div>
351 </section>
352
353 <form class="refac-form" method="post" action="/refactors">
354 <div class="refac-form-row">
355 <label for="description">Describe the refactor</label>
356 <textarea
357 id="description"
358 name="description"
359 placeholder="e.g. rename `getUserById` to `findUser` across all my repos"
360 required
361 />
362 </div>
363 <div class="refac-form-row">
364 <label>Repos to include</label>
365 <div class="refac-repos-list">
366 {userRepos.length === 0 ? (
367 <span style="color: var(--text-muted); font-size: 13px;">
368 You don't own any repositories yet.
369 </span>
370 ) : (
371 userRepos.map((r) => (
372 <label>
373 <input
374 type="checkbox"
375 name="repositoryIds"
376 value={r.id}
377 checked
378 />
379 {r.name}
380 </label>
381 ))
382 )}
383 </div>
384 </div>
385 <div class="refac-actions">
386 <button type="submit" class="refac-btn refac-btn-primary">
387 Plan refactor
388 </button>
389 </div>
390 </form>
391
392 <h2 style="font-family: var(--font-display); font-size: 20px; margin: var(--space-4) 0 var(--space-3);">
393 Your refactors
394 </h2>
395
396 {list.length === 0 ? (
397 <div class="refac-empty">
398 No refactors yet — kick one off above.
399 </div>
400 ) : (
401 list.map((r) => (
402 <article class="refac-card">
403 <div class="refac-card-head">
404 <h3 class="refac-card-title">
405 <a href={`/refactors/${r.id}`}>{r.title}</a>
406 </h3>
407 <Pill status={r.status} />
408 </div>
409 <p class="refac-card-desc">
410 {r.description.length > 240
411 ? r.description.slice(0, 237) + "..."
412 : r.description}
413 </p>
414 <div class="refac-card-meta">
415 {new Date(r.createdAt).toLocaleString()}
416 </div>
417 </article>
418 ))
419 )}
420 </div>
421 <style dangerouslySetInnerHTML={{ __html: refacStyles }} />
422 </Layout>
423 );
424});
425
426// ---------------------------------------------------------------------------
427// UI: POST /refactors — plan handler
428// ---------------------------------------------------------------------------
429refactors.post("/refactors", async (c) => {
430 const user = c.get("user")!;
431 const body = await c.req.parseBody();
432 const description = String(body.description || "").trim();
433 const repoIdsRaw = body.repositoryIds;
434 const repositoryIds = Array.isArray(repoIdsRaw)
435 ? repoIdsRaw.map(String)
436 : repoIdsRaw
437 ? [String(repoIdsRaw)]
438 : undefined;
439
440 if (!description) {
441 return c.redirect("/refactors");
442 }
443
444 const res = await planRefactor({
445 userId: user.id,
446 description,
447 repositoryIds,
448 });
449 if (!res.ok) {
450 return c.html(
451 <Layout title="Refactor failed" user={user}>
452 <div class="refac-wrap">
453 <section class="refac-hero">
454 <div class="refac-hero-orb" aria-hidden="true" />
455 <div class="refac-hero-inner">
456 <h1 class="refac-title">Could not plan refactor</h1>
457 <p class="refac-sub">{res.error}</p>
458 <p>
459 <a href="/refactors" class="refac-btn">Back to refactors</a>
460 </p>
461 </div>
462 </section>
463 </div>
464 <style dangerouslySetInnerHTML={{ __html: refacStyles }} />
465 </Layout>,
466 400
467 );
468 }
469 return c.redirect(`/refactors/${res.refactor.id}`);
470});
471
472// ---------------------------------------------------------------------------
473// UI: GET /refactors/:id — detail page
474// ---------------------------------------------------------------------------
475refactors.get("/refactors/:id", async (c) => {
476 const user = c.get("user")!;
477 const id = c.req.param("id");
478 const data = await getRefactor(id, { userId: user.id });
479 if (!data) return c.notFound();
480
481 return c.html(
482 <Layout title={data.refactor.title} user={user}>
483 <div class="refac-wrap">
484 <section class="refac-hero">
485 <div class="refac-hero-orb" aria-hidden="true" />
486 <div class="refac-hero-inner">
487 <div class="refac-eyebrow">
488 <span class="refac-eyebrow-dot" aria-hidden="true" />
489 Multi-repo refactor · <Pill status={data.refactor.status} />
490 </div>
491 <h1 class="refac-title">
492 <span class="refac-title-grad">{data.refactor.title}</span>
493 </h1>
494 <p class="refac-sub">{data.refactor.description}</p>
495 </div>
496 </section>
497
498 {data.refactor.status === "planning" && (
499 <form method="post" action={`/refactors/${data.refactor.id}/execute`}>
500 <div class="refac-actions" style="margin-bottom: var(--space-4);">
501 <button type="submit" class="refac-btn refac-btn-primary">
502 Execute — open PRs in every repo
503 </button>
504 </div>
505 </form>
506 )}
507
508 <table class="refac-pr-table">
509 <thead>
510 <tr>
511 <th>Repository</th>
512 <th>Status</th>
513 <th>PR</th>
514 <th>Updated</th>
515 </tr>
516 </thead>
517 <tbody>
518 {data.children.map((c) => (
519 <tr>
520 <td>
521 {c.repoOwner && c.repoName ? (
522 <a href={`/${c.repoOwner}/${c.repoName}`}>
523 {c.repoOwner}/{c.repoName}
524 </a>
525 ) : (
526 <span style="color: var(--text-muted);">(repo deleted)</span>
527 )}
528 </td>
529 <td>
530 <Pill status={c.status} />
531 {c.errorMessage && (
532 <div class="refac-pr-error">{c.errorMessage}</div>
533 )}
534 </td>
535 <td>
536 {c.prNumber != null && c.repoOwner && c.repoName ? (
537 <a
538 href={`/${c.repoOwner}/${c.repoName}/pull/${c.prNumber}`}
539 >
540 #{c.prNumber}
541 </a>
542 ) : (
543 <span style="color: var(--text-muted);">—</span>
544 )}
545 </td>
546 <td style="color: var(--text-muted); font-size: 12px;">
547 {new Date(c.updatedAt).toLocaleString()}
548 </td>
549 </tr>
550 ))}
551 {data.children.length === 0 && (
552 <tr>
553 <td colspan={4} style="text-align: center; padding: var(--space-4); color: var(--text-muted);">
554 No repos in this refactor.
555 </td>
556 </tr>
557 )}
558 </tbody>
559 </table>
560 </div>
561 <style dangerouslySetInnerHTML={{ __html: refacStyles }} />
562 </Layout>
563 );
564});
565
566// ---------------------------------------------------------------------------
567// UI: POST /refactors/:id/execute — execute handler
568// ---------------------------------------------------------------------------
569refactors.post("/refactors/:id/execute", async (c) => {
570 const user = c.get("user")!;
571 const id = c.req.param("id");
572 // Defence-in-depth: only the owner can execute.
573 const owned = await getRefactor(id, { userId: user.id });
574 if (!owned) return c.notFound();
575
576 await executeRefactor({ refactorId: id });
577 return c.redirect(`/refactors/${id}`);
578});
579
580// ---------------------------------------------------------------------------
581// API: POST /api/v2/refactors — create + plan
582// ---------------------------------------------------------------------------
583refactors.post("/api/v2/refactors", async (c) => {
584 const user = c.get("user")!;
585 let body: { description?: unknown; repositoryIds?: unknown } = {};
586 try {
587 body = (await c.req.json()) as typeof body;
588 } catch {
589 return c.json({ error: "invalid JSON body" }, 400);
590 }
591 const description =
592 typeof body.description === "string" ? body.description.trim() : "";
593 const repositoryIds = Array.isArray(body.repositoryIds)
594 ? body.repositoryIds.filter((x): x is string => typeof x === "string")
595 : undefined;
596 if (!description) {
597 return c.json({ error: "description required" }, 400);
598 }
599
600 const res = await planRefactor({
601 userId: user.id,
602 description,
603 repositoryIds,
604 });
605 if (!res.ok) return c.json({ error: res.error }, 400);
606 return c.json(
607 {
608 refactor: res.refactor,
609 plan: res.plan,
610 },
611 201
612 );
613});
614
615// ---------------------------------------------------------------------------
616// API: POST /api/v2/refactors/:id/execute
617// ---------------------------------------------------------------------------
618refactors.post("/api/v2/refactors/:id/execute", async (c) => {
619 const user = c.get("user")!;
620 const id = c.req.param("id");
621 const owned = await getRefactor(id, { userId: user.id });
622 if (!owned) return c.json({ error: "not found" }, 404);
623
624 const res = await executeRefactor({ refactorId: id });
625 if (!res.ok) return c.json({ error: res.error }, 400);
626 return c.json({
627 refactor: res.refactor,
628 children: res.children,
629 });
630});
631
632// ---------------------------------------------------------------------------
633// API: GET /api/v2/refactors/:id
634// ---------------------------------------------------------------------------
635refactors.get("/api/v2/refactors/:id", async (c) => {
636 const user = c.get("user")!;
637 const id = c.req.param("id");
638 const data = await getRefactor(id, { userId: user.id });
639 if (!data) return c.json({ error: "not found" }, 404);
640 return c.json(data);
641});
642
643export default refactors;