Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit23d0abfunknown_key

feat(multi-repo-refactor): one English request → coordinated PRs across all affected repos

Claude committed on May 25, 2026Parent: 4bbacbe
7 files changed+2432723d0abf70a88b6f78c849a0fe9c30dd2a7128227
7 changed files+2432−7
Addeddrizzle/0063_multi_repo_refactors.sql+64−0View fileUnifiedSplit
1-- Multi-repo refactor agent — one English request fan-outs to coordinated
2-- PRs across every affected repo, then merges them in order once all PRs
3-- are green + approved.
4--
5-- The classic example: "rename `getUserById` to `findUser`". A user owns
6-- 8 repos that import the helper; the agent walks each of them, opens an
7-- AI-authored PR with the rename, and tags every PR with a single
8-- `multi-repo:refactor:<id>` label so the UI can group them.
9--
10-- Two tables:
11-- `multi_repo_refactors` — the parent record. One row per user-issued
12-- request. Lifecycle: planning → building →
13-- ready_for_review → merged | failed.
14-- `multi_repo_refactor_prs` — one row per affected repo. Holds the FK to
15-- the `pull_requests` row (nullable until the
16-- per-repo PR is actually opened) plus the
17-- per-repo status (pending|building|opened|
18-- failed) and an error message if the build
19-- step bailed for that repo.
20--
21-- Coordinated merge: when the user (or autopilot) triggers a merge on the
22-- parent refactor, the orchestrator walks the child rows in insertion order;
23-- if any child fails the rest stay open so the user can intervene. The
24-- merge driver lives in `src/lib/multi-repo-refactor.ts`.
25
26CREATE TABLE IF NOT EXISTS multi_repo_refactors (
27 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
28 owner_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
29 title text NOT NULL,
30 description text NOT NULL,
31 -- 'planning' | 'building' | 'ready_for_review' | 'merged' | 'failed'
32 status text NOT NULL DEFAULT 'planning',
33 created_at timestamp NOT NULL DEFAULT now(),
34 updated_at timestamp NOT NULL DEFAULT now()
35);
36
37CREATE INDEX IF NOT EXISTS multi_repo_refactors_owner
38 ON multi_repo_refactors (owner_user_id, created_at DESC);
39
40CREATE INDEX IF NOT EXISTS multi_repo_refactors_status
41 ON multi_repo_refactors (status, updated_at DESC);
42
43CREATE TABLE IF NOT EXISTS multi_repo_refactor_prs (
44 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
45 refactor_id uuid NOT NULL REFERENCES multi_repo_refactors(id) ON DELETE CASCADE,
46 repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
47 -- Nullable: stays NULL until the per-repo PR is actually opened.
48 pull_request_id uuid REFERENCES pull_requests(id) ON DELETE SET NULL,
49 -- 'pending' | 'building' | 'opened' | 'failed'
50 status text NOT NULL DEFAULT 'pending',
51 error_message text,
52 created_at timestamp NOT NULL DEFAULT now(),
53 updated_at timestamp NOT NULL DEFAULT now()
54);
55
56-- One row per (refactor, repo). A refactor never targets the same repo twice.
57CREATE UNIQUE INDEX IF NOT EXISTS multi_repo_refactor_prs_unique
58 ON multi_repo_refactor_prs (refactor_id, repository_id);
59
60CREATE INDEX IF NOT EXISTS multi_repo_refactor_prs_refactor
61 ON multi_repo_refactor_prs (refactor_id, status);
62
63CREATE INDEX IF NOT EXISTS multi_repo_refactor_prs_repo
64 ON multi_repo_refactor_prs (repository_id);
Addedsrc/__tests__/multi-repo-refactor.test.ts+503−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/multi-repo-refactor.ts.
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — no DB, no Claude. Always run.
7 * - rollupStatus rolls children into the parent status correctly.
8 * - refactorLabelName / refactorBranchName are deterministic.
9 * - deriveTitle caps + cleans first line.
10 * - buildPlanPrompt / buildEditPrompt embed the description so the
11 * round trip is auditable.
12 *
13 * 2. End-to-end planRefactor + executeRefactor — exercises the full
14 * pipeline with a fake Anthropic client + a real bare repo on disk.
15 * Gated on HAS_DB so the DB-less sandbox still gets signal from the
16 * pure-helper assertions.
17 *
18 * Skip rule:
19 * - Production code requires ANTHROPIC_API_KEY OR an injected client.
20 * Our DB tests inject a fake `client`, so they DO run even when the
21 * key is missing. The describe.skipIf below is purely "HAS_DB"-gated.
22 */
23
24import { describe, it, expect, beforeAll, afterAll } from "bun:test";
25import { join } from "path";
26import { rm, mkdir } from "fs/promises";
27import { eq } from "drizzle-orm";
28import {
29 __test,
30 buildEditPrompt,
31 buildPlanPrompt,
32 deriveTitle,
33 executeRefactor,
34 getRefactor,
35 MULTI_REPO_REFACTOR_LABEL_PREFIX,
36 MULTI_REPO_REFACTOR_MARKER,
37 planRefactor,
38 refactorBranchName,
39 refactorLabelName,
40 renderRefactorPrBody,
41 rollupStatus,
42} from "../lib/multi-repo-refactor";
43import {
44 createOrUpdateFileOnBranch,
45 initBareRepo,
46 refExists,
47} from "../git/repository";
48import { db } from "../db";
49import {
50 multiRepoRefactorPrs,
51 multiRepoRefactors,
52 pullRequests,
53 repositories,
54 users,
55} from "../db/schema";
56
57const HAS_DB = Boolean(process.env.DATABASE_URL);
58
59const TEST_REPOS = join(
60 import.meta.dir,
61 "../../.test-repos-multi-repo-refactor-" + Date.now()
62);
63
64beforeAll(async () => {
65 process.env.GIT_REPOS_PATH = TEST_REPOS;
66 await rm(TEST_REPOS, { recursive: true, force: true });
67 await mkdir(TEST_REPOS, { recursive: true });
68});
69
70afterAll(async () => {
71 await rm(TEST_REPOS, { recursive: true, force: true });
72});
73
74// ---------------------------------------------------------------------------
75// Pure helpers
76// ---------------------------------------------------------------------------
77
78describe("refactorLabelName", () => {
79 it("prefixes the refactor id with the group marker", () => {
80 expect(refactorLabelName("abc-123")).toBe(
81 `${MULTI_REPO_REFACTOR_LABEL_PREFIX}abc-123`
82 );
83 });
84});
85
86describe("refactorBranchName", () => {
87 it("derives an 8-char branch suffix from the refactor uuid", () => {
88 const branch = refactorBranchName("aaaabbbb-cccc-dddd-eeee-ffffffffffff");
89 expect(branch.startsWith("multi-repo-refactor/")).toBe(true);
90 expect(branch.length).toBeLessThan(50);
91 // Deterministic given the same id.
92 expect(branch).toBe(refactorBranchName("aaaabbbb-cccc-dddd-eeee-ffffffffffff"));
93 });
94});
95
96describe("deriveTitle", () => {
97 it("uses the first line of the description", () => {
98 expect(deriveTitle("rename foo to bar\n\nmore details")).toBe(
99 "rename foo to bar"
100 );
101 });
102 it("caps long descriptions at ~80 chars with an ellipsis", () => {
103 const long = "x".repeat(200);
104 const title = deriveTitle(long);
105 expect(title.endsWith("...")).toBe(true);
106 expect(title.length).toBeLessThanOrEqual(80);
107 });
108 it("falls back to a default for empty input", () => {
109 expect(deriveTitle("")).toBe("Multi-repo refactor");
110 });
111});
112
113describe("rollupStatus", () => {
114 it("returns failed when no children are present", () => {
115 expect(rollupStatus([])).toBe("failed");
116 });
117 it("stays building when any child is still in flight", () => {
118 expect(
119 rollupStatus([{ status: "opened" }, { status: "building" }])
120 ).toBe("building");
121 expect(rollupStatus([{ status: "pending" }])).toBe("building");
122 });
123 it("rolls up to ready_for_review when every child has terminated and at least one opened", () => {
124 expect(
125 rollupStatus([{ status: "opened" }, { status: "opened" }])
126 ).toBe("ready_for_review");
127 // Mixed success/failure → still ready_for_review so the user can merge
128 // what worked and retry the failed children.
129 expect(
130 rollupStatus([{ status: "opened" }, { status: "failed" }])
131 ).toBe("ready_for_review");
132 });
133 it("rolls up to failed when every child failed", () => {
134 expect(
135 rollupStatus([{ status: "failed" }, { status: "failed" }])
136 ).toBe("failed");
137 });
138});
139
140describe("buildPlanPrompt", () => {
141 it("embeds the description and repo list", () => {
142 const prompt = buildPlanPrompt({
143 description: "rename getUserById to findUser",
144 repos: [
145 { id: "id-1", owner: "alice", name: "service-a", description: null },
146 {
147 id: "id-2",
148 owner: "alice",
149 name: "service-b",
150 description: "consumer",
151 },
152 ],
153 });
154 expect(prompt).toContain("rename getUserById to findUser");
155 expect(prompt).toContain("id=id-1");
156 expect(prompt).toContain("alice/service-b — consumer");
157 expect(prompt).toContain('"predicted_changes_summary"');
158 });
159});
160
161describe("buildEditPrompt", () => {
162 it("embeds the description, predicted change, and any preloaded files", () => {
163 const prompt = buildEditPrompt({
164 description: "rename foo to bar",
165 predictedChanges: "rename foo() in src/foo.ts",
166 repoFiles: [{ path: "src/foo.ts", content: "export const foo = 1;" }],
167 });
168 expect(prompt).toContain("rename foo to bar");
169 expect(prompt).toContain("rename foo() in src/foo.ts");
170 expect(prompt).toContain("--- FILE: src/foo.ts ---");
171 expect(prompt).toContain("export const foo = 1;");
172 });
173});
174
175describe("renderRefactorPrBody", () => {
176 it("includes the marker, group label, description, and file list", () => {
177 const body = renderRefactorPrBody({
178 refactorId: "ref-1",
179 refactorTitle: "Rename helper",
180 description: "rename foo to bar",
181 predictedChanges: "edit src/foo.ts",
182 explanation: "did the thing",
183 patchPaths: ["src/foo.ts"],
184 });
185 expect(body).toContain(MULTI_REPO_REFACTOR_MARKER);
186 expect(body).toContain(refactorLabelName("ref-1"));
187 expect(body).toContain("rename foo to bar");
188 expect(body).toContain("src/foo.ts");
189 });
190});
191
192// ---------------------------------------------------------------------------
193// End-to-end — fake Claude client + real bare repo + real DB
194// ---------------------------------------------------------------------------
195
196/**
197 * Build a fake `ClaudeClient` whose `.messages.create` returns canned JSON
198 * envelopes. The plan response comes first, then one edit response per
199 * affected repo. We pop responses off the front so test sequences are
200 * deterministic.
201 */
202function fakeClientSequence(responses: string[]) {
203 const queue = [...responses];
204 return {
205 messages: {
206 create: async () => {
207 const text = queue.shift();
208 if (text === undefined) {
209 throw new Error("fakeClient: ran out of canned responses");
210 }
211 return {
212 content: [{ type: "text" as const, text }],
213 };
214 },
215 },
216 } as any;
217}
218
219describe.skipIf(!HAS_DB)(
220 "planRefactor + executeRefactor — end-to-end with fake Claude",
221 () => {
222 it(
223 "plans across two repos, opens one PR per repo with the group label, and rolls up to ready_for_review",
224 async () => {
225 // 1. Seed a user + two repos with one source file each.
226 const username =
227 "refac_" +
228 Date.now().toString(36) +
229 "_" +
230 Math.random().toString(36).slice(2, 6);
231 const [u] = await db
232 .insert(users)
233 .values({
234 username,
235 email: `${username}@example.com`,
236 passwordHash: "x",
237 })
238 .returning({ id: users.id });
239
240 const repoA = `svc_a_${Date.now()}`;
241 const repoB = `svc_b_${Date.now()}`;
242 const [ra] = await db
243 .insert(repositories)
244 .values({
245 ownerId: u.id,
246 name: repoA,
247 diskPath: `/tmp/${username}/${repoA}`,
248 defaultBranch: "main",
249 })
250 .returning({ id: repositories.id, name: repositories.name });
251 const [rb] = await db
252 .insert(repositories)
253 .values({
254 ownerId: u.id,
255 name: repoB,
256 diskPath: `/tmp/${username}/${repoB}`,
257 defaultBranch: "main",
258 })
259 .returning({ id: repositories.id, name: repositories.name });
260
261 // Seed bare repos on disk so the executor can write a branch.
262 await initBareRepo(username, repoA);
263 await initBareRepo(username, repoB);
264 for (const name of [repoA, repoB]) {
265 const seeded = await createOrUpdateFileOnBranch({
266 owner: username,
267 name,
268 branch: "main",
269 filePath: "src/user.ts",
270 bytes: new TextEncoder().encode(
271 "export function getUserById(id: string) { return id; }\n"
272 ),
273 message: "seed",
274 authorName: "Seeder",
275 authorEmail: "s@e.com",
276 });
277 if ("error" in seeded) throw new Error(`seed ${name} failed`);
278 }
279
280 // 2. Plan — canned response selects both repos.
281 const planJson = JSON.stringify({
282 title: "Rename getUserById to findUser",
283 affected: [
284 {
285 repository_id: ra.id,
286 predicted_changes_summary:
287 "rename getUserById -> findUser in src/user.ts",
288 },
289 {
290 repository_id: rb.id,
291 predicted_changes_summary:
292 "rename getUserById -> findUser in src/user.ts",
293 },
294 ],
295 });
296
297 const plan = await planRefactor({
298 userId: u.id,
299 description:
300 "rename `getUserById` to `findUser` across all my repos",
301 client: fakeClientSequence([planJson]),
302 });
303 expect(plan.ok).toBe(true);
304 if (!plan.ok) return;
305 expect(plan.plan.length).toBe(2);
306 expect(plan.refactor.status).toBe("planning");
307
308 // 3. Execute — provide one canned edit response per repo.
309 const editJson = JSON.stringify({
310 explanation: "Renamed the helper.",
311 patches: [
312 {
313 path: "src/user.ts",
314 new_content:
315 "export function findUser(id: string) { return id; }\n",
316 },
317 ],
318 });
319 const execRes = await executeRefactor({
320 refactorId: plan.refactor.id,
321 client: fakeClientSequence([editJson, editJson]),
322 });
323 expect(execRes.ok).toBe(true);
324 if (!execRes.ok) return;
325 expect(execRes.children.length).toBe(2);
326 for (const child of execRes.children) {
327 expect(child.status).toBe("opened");
328 expect(typeof child.pullRequestId).toBe("string");
329 expect(typeof child.prNumber).toBe("number");
330 expect(child.branch).toBe(
331 refactorBranchName(plan.refactor.id)
332 );
333 }
334 expect(execRes.refactor.status).toBe("ready_for_review");
335
336 // 4. Verify each PR has the marker label in its body and the right
337 // base/head branches.
338 for (const child of execRes.children) {
339 if (!child.pullRequestId) continue;
340 const [pr] = await db
341 .select()
342 .from(pullRequests)
343 .where(eq(pullRequests.id, child.pullRequestId))
344 .limit(1);
345 expect(pr).toBeTruthy();
346 if (!pr) continue;
347 expect(pr.title.startsWith("[refactor]")).toBe(true);
348 expect(pr.body || "").toContain(
349 refactorLabelName(plan.refactor.id)
350 );
351 expect(pr.body || "").toContain(MULTI_REPO_REFACTOR_MARKER);
352 expect(pr.headBranch).toBe(refactorBranchName(plan.refactor.id));
353 expect(pr.baseBranch).toBe("main");
354 }
355
356 // 5. Confirm the head branch actually exists on disk in each repo.
357 for (const name of [repoA, repoB]) {
358 const exists = await refExists(
359 username,
360 name,
361 `refs/heads/${refactorBranchName(plan.refactor.id)}`
362 );
363 expect(exists).toBe(true);
364 }
365
366 // 6. getRefactor returns the children with PR numbers attached.
367 const view = await getRefactor(plan.refactor.id, { userId: u.id });
368 expect(view).toBeTruthy();
369 if (!view) return;
370 expect(view.refactor.status).toBe("ready_for_review");
371 expect(view.children.length).toBe(2);
372 expect(view.children.every((c) => typeof c.prNumber === "number")).toBe(
373 true
374 );
375 },
376 30_000
377 );
378
379 it(
380 "marks the child as failed when Claude returns no patches, and rolls up to failed when every child fails",
381 async () => {
382 const username =
383 "refac_fail_" +
384 Date.now().toString(36) +
385 "_" +
386 Math.random().toString(36).slice(2, 6);
387 const [u] = await db
388 .insert(users)
389 .values({
390 username,
391 email: `${username}@example.com`,
392 passwordHash: "x",
393 })
394 .returning({ id: users.id });
395
396 const repoName = `nofix_${Date.now()}`;
397 const [r] = await db
398 .insert(repositories)
399 .values({
400 ownerId: u.id,
401 name: repoName,
402 diskPath: `/tmp/${username}/${repoName}`,
403 defaultBranch: "main",
404 })
405 .returning({ id: repositories.id });
406
407 await initBareRepo(username, repoName);
408 await createOrUpdateFileOnBranch({
409 owner: username,
410 name: repoName,
411 branch: "main",
412 filePath: "src/x.ts",
413 bytes: new TextEncoder().encode("export const x = 1;\n"),
414 message: "seed",
415 authorName: "Seeder",
416 authorEmail: "s@e.com",
417 });
418
419 const planJson = JSON.stringify({
420 title: "Empty refactor",
421 affected: [
422 {
423 repository_id: r.id,
424 predicted_changes_summary: "x",
425 },
426 ],
427 });
428 const editEmpty = JSON.stringify({
429 explanation: "nothing to do",
430 patches: [],
431 });
432
433 const plan = await planRefactor({
434 userId: u.id,
435 description: "do something",
436 client: fakeClientSequence([planJson]),
437 });
438 expect(plan.ok).toBe(true);
439 if (!plan.ok) return;
440
441 const execRes = await executeRefactor({
442 refactorId: plan.refactor.id,
443 client: fakeClientSequence([editEmpty]),
444 });
445 expect(execRes.ok).toBe(true);
446 if (!execRes.ok) return;
447 expect(execRes.children.length).toBe(1);
448 expect(execRes.children[0].status).toBe("failed");
449 expect(execRes.refactor.status).toBe("failed");
450
451 // No PRs should have been inserted for this repo.
452 const prs = await db
453 .select()
454 .from(pullRequests)
455 .where(eq(pullRequests.repositoryId, r.id));
456 expect(prs.length).toBe(0);
457 },
458 30_000
459 );
460 }
461);
462
463// ---------------------------------------------------------------------------
464// Guard tests (DB-less) — confirm we bail cleanly without an API key
465// ---------------------------------------------------------------------------
466
467describe("planRefactor — guards", () => {
468 it("returns ok:false when description is empty", async () => {
469 const out = await planRefactor({
470 userId: "00000000-0000-0000-0000-000000000000",
471 description: " ",
472 client: fakeClientSequence(["{}"]),
473 });
474 expect(out.ok).toBe(false);
475 if (!out.ok) expect(out.error).toContain("empty");
476 });
477
478 it("returns ok:false when no API key AND no client are provided", async () => {
479 const original = process.env.ANTHROPIC_API_KEY;
480 delete process.env.ANTHROPIC_API_KEY;
481 try {
482 const out = await planRefactor({
483 userId: "00000000-0000-0000-0000-000000000000",
484 description: "rename foo",
485 });
486 expect(out.ok).toBe(false);
487 if (!out.ok) expect(out.error).toContain("ANTHROPIC_API_KEY");
488 } finally {
489 if (original !== undefined) process.env.ANTHROPIC_API_KEY = original;
490 }
491 });
492});
493
494// Re-export the suite's internal helper coverage so it doesn't drift.
495describe("__test exports", () => {
496 it("exposes the same helpers tested above", () => {
497 expect(typeof __test.rollupStatus).toBe("function");
498 expect(typeof __test.refactorLabelName).toBe("function");
499 expect(typeof __test.refactorBranchName).toBe("function");
500 expect(typeof __test.deriveTitle).toBe("function");
501 expect(typeof __test.buildPlanPrompt).toBe("function");
502 });
503});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
6464import importSecretsRoutes from "./routes/import-secrets";
6565import migrationRoutes from "./routes/migrations";
6666import specsRoutes from "./routes/specs";
67import refactorRoutes from "./routes/refactors";
6768import webRoutes from "./routes/web";
6869import hookRoutes from "./routes/hooks";
6970import eventsRoutes from "./routes/events";
522523
523524// Spec-to-PR (experimental AI-generated draft PRs)
524525app.route("/", specsRoutes);
526app.route("/", refactorRoutes);
525527
526528// Explore page
527529app.route("/", exploreRoutes);
Modifiedsrc/db/schema.ts+60−7View fileUnifiedSplit
31593159export type NewPrLiveSession = typeof prLiveSessions.$inferInsert;
31603160
31613161// ---------------------------------------------------------------------------
3162// Migration 0062 — per-branch preview URLs.
31633162// ---------------------------------------------------------------------------
3164// One row per (repo, branch) pair. A push to a non-default branch
3165// upserts the row, replacing commit_sha + resetting status to 'building'.
3166// `preview_url` is computed at enqueue time so the row can be rendered
3167// immediately even while the build is still running.
3168
3163// Migration 0062 — per-branch preview URLs. See src/lib/branch-previews.ts.
3164// ---------------------------------------------------------------------------
31693165export const branchPreviews = pgTable(
31703166 "branch_previews",
31713167 {
31763172 branchName: text("branch_name").notNull(),
31773173 commitSha: text("commit_sha").notNull(),
31783174 previewUrl: text("preview_url").notNull(),
3179 // 'building' | 'ready' | 'failed' | 'expired'
31803175 status: text("status").default("building").notNull(),
31813176 buildStartedAt: timestamp("build_started_at", { withTimezone: true })
31823177 .defaultNow()
32003195export type BranchPreview = typeof branchPreviews.$inferSelect;
32013196export type NewBranchPreview = typeof branchPreviews.$inferInsert;
32023197
3198// ---------------------------------------------------------------------------
3199// 0063 — Multi-repo refactor agent. See src/lib/multi-repo-refactor.ts.
3200// ---------------------------------------------------------------------------
3201export const multiRepoRefactors = pgTable(
3202 "multi_repo_refactors",
3203 {
3204 id: uuid("id").primaryKey().defaultRandom(),
3205 ownerUserId: uuid("owner_user_id")
3206 .notNull()
3207 .references(() => users.id, { onDelete: "cascade" }),
3208 title: text("title").notNull(),
3209 description: text("description").notNull(),
3210 status: text("status").default("planning").notNull(),
3211 createdAt: timestamp("created_at").defaultNow().notNull(),
3212 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3213 },
3214 (table) => [
3215 index("multi_repo_refactors_owner").on(table.ownerUserId, table.createdAt),
3216 index("multi_repo_refactors_status").on(table.status, table.updatedAt),
3217 ]
3218);
3219
3220export const multiRepoRefactorPrs = pgTable(
3221 "multi_repo_refactor_prs",
3222 {
3223 id: uuid("id").primaryKey().defaultRandom(),
3224 refactorId: uuid("refactor_id")
3225 .notNull()
3226 .references(() => multiRepoRefactors.id, { onDelete: "cascade" }),
3227 repositoryId: uuid("repository_id")
3228 .notNull()
3229 .references(() => repositories.id, { onDelete: "cascade" }),
3230 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
3231 onDelete: "set null",
3232 }),
3233 status: text("status").default("pending").notNull(),
3234 errorMessage: text("error_message"),
3235 createdAt: timestamp("created_at").defaultNow().notNull(),
3236 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3237 },
3238 (table) => [
3239 uniqueIndex("multi_repo_refactor_prs_unique").on(
3240 table.refactorId,
3241 table.repositoryId
3242 ),
3243 index("multi_repo_refactor_prs_refactor").on(
3244 table.refactorId,
3245 table.status
3246 ),
3247 index("multi_repo_refactor_prs_repo").on(table.repositoryId),
3248 ]
3249);
3250
3251export type MultiRepoRefactor = typeof multiRepoRefactors.$inferSelect;
3252export type NewMultiRepoRefactor = typeof multiRepoRefactors.$inferInsert;
3253export type MultiRepoRefactorPr = typeof multiRepoRefactorPrs.$inferSelect;
3254export type NewMultiRepoRefactorPr = typeof multiRepoRefactorPrs.$inferInsert;
3255
Addedsrc/lib/multi-repo-refactor.ts+1157−0View fileUnifiedSplit
Large file (1,157 lines). Load full file
Addedsrc/routes/refactors.tsx+643−0View fileUnifiedSplit
1/**
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 = `
56 .refac-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
57
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;
72 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
73 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;
80 background: radial-gradient(circle, rgba(140,109,255,0.24), rgba(54,197,214,0.12) 45%, transparent 70%);
81 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;
102 background: linear-gradient(135deg, #8c6dff, #36c5d6);
103 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
104 }
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 {
115 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
116 -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; }
150 .refac-card-title a:hover { color: #8c6dff; }
151 .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 }
178 .refac-pill.is-planning { color: #36c5d6; border-color: rgba(54,197,214,0.35); background: rgba(54,197,214,0.08); }
179 .refac-pill.is-building { color: #a48bff; border-color: rgba(140,109,255,0.35); background: rgba(140,109,255,0.08); }
180 .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 {
252 background: linear-gradient(135deg, #8c6dff, #36c5d6);
253 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;
Modifiedsrc/views/layout.tsx+3−0View fileUnifiedSplit
258258 <a href="/import" class="nav-link">
259259 Import
260260 </a>
261 <a href="/refactors" class="nav-link">
262 Refactors
263 </a>
261264 <a href="/new" class="btn btn-sm btn-primary">
262265 + New
263266 </a>
264267