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.
| 23d0abf | 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 | ||
| 24 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; | |
| 25 | import { join } from "path"; | |
| 26 | import { rm, mkdir } from "fs/promises"; | |
| 27 | import { eq } from "drizzle-orm"; | |
| 28 | import { | |
| 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"; | |
| 43 | import { | |
| 44 | createOrUpdateFileOnBranch, | |
| 45 | initBareRepo, | |
| 46 | refExists, | |
| 47 | } from "../git/repository"; | |
| 48 | import { db } from "../db"; | |
| 49 | import { | |
| 50 | multiRepoRefactorPrs, | |
| 51 | multiRepoRefactors, | |
| 52 | pullRequests, | |
| 53 | repositories, | |
| 54 | users, | |
| 55 | } from "../db/schema"; | |
| 56 | ||
| 57 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 58 | ||
| 59 | const TEST_REPOS = join( | |
| 60 | import.meta.dir, | |
| 61 | "../../.test-repos-multi-repo-refactor-" + Date.now() | |
| 62 | ); | |
| 63 | ||
| 64 | beforeAll(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 | ||
| 70 | afterAll(async () => { | |
| 71 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | // --------------------------------------------------------------------------- | |
| 75 | // Pure helpers | |
| 76 | // --------------------------------------------------------------------------- | |
| 77 | ||
| 78 | describe("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 | ||
| 86 | describe("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 | ||
| 96 | describe("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 | ||
| 113 | describe("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 | ||
| 140 | describe("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 | ||
| 161 | describe("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 | ||
| 175 | describe("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 | */ | |
| 202 | function 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 | ||
| 219 | describe.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 | ||
| 467 | describe("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. | |
| 495 | describe("__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 | }); |