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

multi-repo-refactor.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

multi-repo-refactor.test.tsBlame503 lines · 1 contributor
23d0abfClaude1/**
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});