Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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

migration-assistant.test.tsBlame638 lines · 1 contributor
6493d91Claude1/**
2 * Tests for src/lib/migration-assistant.ts.
3 *
4 * Coverage:
5 * 1. Pure helpers — no DB, no Claude. Always run.
6 * - dependencyHints embeds the dep in each import idiom.
7 * - migrationBranchName respects the override and otherwise slugs
8 * the dependency + version cleanly.
9 * - buildMigrationPrompt + renderMigrationPrBody carry the metadata
10 * a reviewer needs.
11 * - detectMajorBump catches major bumps and ignores minor/patch.
12 *
13 * 2. End-to-end with an injected fake Claude client + a real bare repo
14 * on disk. The DB-touching steps (PR insert, audit, dedupe lookup)
15 * are gated on DATABASE_URL via `it.skipIf(!HAS_DB)` — without it
16 * we still verify the git side effects.
17 *
18 * 3. Watcher — DB-gated. Asserts the dedupe row blocks a repeat
19 * proposal within the throttle window.
20 */
21
22import { describe, it, expect, beforeAll, afterAll } from "bun:test";
23import { join } from "path";
24import { rm, mkdir } from "fs/promises";
25import {
26 __test,
27 buildMigrationPrompt,
28 dependencyHints,
29 detectMajorBump,
30 findManifest,
31 findUsages,
32 migrationBranchName,
33 MIGRATION_AUDIT_ACTION,
34 MIGRATION_LABEL,
35 MIGRATION_MARKER,
36 proposeMajorMigration,
37 recentlyProposed,
38 renderMigrationPrBody,
39 runMigrationWatcherTaskOnce,
40 SUPPORTED_MANIFESTS,
41} from "../lib/migration-assistant";
42import {
43 initBareRepo,
44 createOrUpdateFileOnBranch,
45 refExists,
46 getBlob,
47} from "../git/repository";
48import { db } from "../db";
49import { and, eq } from "drizzle-orm";
50import {
51 auditLog,
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-migration-" + Date.now()
62);
63
64beforeAll(async () => {
65 process.env.GIT_REPOS_PATH = TEST_REPOS;
66 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
67 await rm(TEST_REPOS, { recursive: true, force: true });
68 await mkdir(TEST_REPOS, { recursive: true });
69});
70
71afterAll(async () => {
72 await rm(TEST_REPOS, { recursive: true, force: true });
73});
74
75// ---------------------------------------------------------------------------
76// Pure helpers
77// ---------------------------------------------------------------------------
78
79describe("SUPPORTED_MANIFESTS", () => {
80 it("covers node, python, rust, and go", () => {
81 expect(SUPPORTED_MANIFESTS).toContain("package.json");
82 expect(SUPPORTED_MANIFESTS).toContain("pyproject.toml");
83 expect(SUPPORTED_MANIFESTS).toContain("Cargo.toml");
84 expect(SUPPORTED_MANIFESTS).toContain("go.mod");
85 });
86});
87
88describe("dependencyHints", () => {
89 it("returns common import idioms for an npm-style name", () => {
90 const hs = dependencyHints("hono");
91 expect(hs.length).toBeGreaterThan(0);
92 expect(hs).toContain(`"hono"`);
93 expect(hs).toContain(`'hono'`);
94 expect(hs).toContain(` from "hono"`);
95 expect(hs).toContain(`require("hono")`);
96 });
97
98 it("rewrites hyphens to underscores for Rust use clauses", () => {
99 const hs = dependencyHints("my-crate");
100 expect(hs.some((h) => h.includes("my_crate::"))).toBe(true);
101 });
102
103 it("returns empty when the name is blank", () => {
104 expect(dependencyHints("")).toEqual([]);
105 expect(dependencyHints(" ")).toEqual([]);
106 });
107});
108
109describe("migrationBranchName", () => {
110 it("honours an override", () => {
111 expect(migrationBranchName("hono", "4.0.0", "custom/branch")).toBe(
112 "custom/branch"
113 );
114 });
115
116 it("uses ai-migration/<slug>-<timestamp> by default", () => {
117 const name = migrationBranchName("@hono/zod-validator", "2.0.0");
118 expect(name.startsWith("ai-migration/")).toBe(true);
119 // No double slashes, no leading/trailing dashes in the slug portion.
120 expect(name).not.toMatch(/--/);
121 const ts = name.split("-").pop()!;
122 expect(/^\d+$/.test(ts)).toBe(true);
123 });
124});
125
126describe("detectMajorBump", () => {
127 it("returns from/to when major increases", () => {
128 expect(detectMajorBump("^3.2.1", "4.0.0")).toEqual({
129 from: "^3.2.1",
130 to: "4.0.0",
131 });
132 });
133
134 it("returns null when major matches", () => {
135 expect(detectMajorBump("^4.0.0", "4.5.2")).toBeNull();
136 });
137
138 it("returns null when latest is older", () => {
139 expect(detectMajorBump("^5.0.0", "4.0.0")).toBeNull();
140 });
141
142 it("returns null for non-semver inputs", () => {
143 expect(detectMajorBump("workspace:*", "4.0.0")).toBeNull();
144 expect(detectMajorBump("^3.0.0", "next")).toBeNull();
145 });
146});
147
148describe("buildMigrationPrompt", () => {
149 it("embeds the dep, version range, and file contents", () => {
150 const out = buildMigrationPrompt({
151 dependency: "hono",
152 fromVersion: "^3.0.0",
153 toVersion: "4.0.0",
154 manifestPath: "package.json",
155 changelog: "Removed Context.req.json()",
156 files: [
157 { path: "src/app.ts", content: "import { Hono } from 'hono'" },
158 ],
159 });
160 expect(out).toContain("hono");
161 expect(out).toContain("^3.0.0");
162 expect(out).toContain("4.0.0");
163 expect(out).toContain("package.json");
164 expect(out).toContain("src/app.ts");
165 expect(out).toContain("Removed Context.req.json()");
166 expect(out).toContain(`"patches"`);
167 expect(out).toContain(`"test_updates"`);
168 expect(out).toContain(`"explanation"`);
169 });
170
171 it("notes the missing changelog when none is provided", () => {
172 const out = buildMigrationPrompt({
173 dependency: "x",
174 fromVersion: "1",
175 toVersion: "2",
176 manifestPath: null,
177 files: [],
178 });
179 expect(out).toContain("(none provided)");
180 });
181});
182
183describe("renderMigrationPrBody", () => {
184 it("includes marker, label, and a split between source + test changes", () => {
185 const body = renderMigrationPrBody({
186 dependency: "hono",
187 fromVersion: "^3.0.0",
188 toVersion: "4.0.0",
189 explanation: "Replaced Context.req.json() with Context.req.json().",
190 patchPaths: ["src/app.ts"],
191 testPaths: ["test/app.test.ts"],
192 changelog: "Removed the old API.",
193 });
194 expect(body).toContain(MIGRATION_MARKER);
195 expect(body).toContain(MIGRATION_LABEL);
196 expect(body).toContain("hono");
197 expect(body).toContain("^3.0.0");
198 expect(body).toContain("4.0.0");
199 expect(body).toContain("src/app.ts");
200 expect(body).toContain("test/app.test.ts");
201 expect(body).toContain("Replaced Context.req.json");
202 expect(body).toContain("### Source changes");
203 expect(body).toContain("### Test changes");
204 });
205
206 it("falls back gracefully when explanation or changelog is missing", () => {
207 const body = renderMigrationPrBody({
208 dependency: "x",
209 fromVersion: "1.0.0",
210 toVersion: "2.0.0",
211 explanation: "",
212 patchPaths: [],
213 testPaths: [],
214 });
215 expect(body).toContain("(no explanation provided)");
216 expect(body).toContain("(none)");
217 expect(body).toContain("(no changelog provided)");
218 });
219});
220
221describe("__test internals", () => {
222 it("matchSemver parses 3-segment versions", () => {
223 const v = __test.matchSemver("^1.2.3");
224 expect(v).toEqual({ major: 1, minor: 2, patch: 3 });
225 });
226
227 it("matchSemver returns null on garbage", () => {
228 expect(__test.matchSemver("workspace:*")).toBeNull();
229 expect(__test.matchSemver("")).toBeNull();
230 });
231});
232
233// ---------------------------------------------------------------------------
234// Bare-repo fixtures + fake Claude
235// ---------------------------------------------------------------------------
236
237const OWNER = "mig-test-" + Date.now().toString(36);
238const REPO = "subject";
239
240const PACKAGE_JSON =
241 JSON.stringify(
242 {
243 name: "subject",
244 dependencies: { hono: "^3.0.0" },
245 },
246 null,
247 2
248 ) + "\n";
249
250const APP_TS = `import { Hono } from "hono";\n\nconst app = new Hono();\napp.get('/', (c) => c.text('hello'));\nexport default app;\n`;
251
252async function seedRepo(): Promise<{ baseSha: string }> {
253 await initBareRepo(OWNER, REPO);
254 const first = await createOrUpdateFileOnBranch({
255 owner: OWNER,
256 name: REPO,
257 branch: "main",
258 filePath: "package.json",
259 bytes: new TextEncoder().encode(PACKAGE_JSON),
260 message: "seed manifest",
261 authorName: "Seeder",
262 authorEmail: "seed@example.com",
263 });
264 if ("error" in first) throw new Error(`seed manifest: ${first.error}`);
265 const second = await createOrUpdateFileOnBranch({
266 owner: OWNER,
267 name: REPO,
268 branch: "main",
269 filePath: "src/app.ts",
270 bytes: new TextEncoder().encode(APP_TS),
271 message: "seed app",
272 authorName: "Seeder",
273 authorEmail: "seed@example.com",
274 });
275 if ("error" in second) throw new Error(`seed app: ${second.error}`);
276 return { baseSha: second.commitSha };
277}
278
279function fakeClient(responseText: string) {
280 return {
281 messages: {
282 create: async () => ({
283 content: [{ type: "text" as const, text: responseText }],
284 }),
285 },
286 } as any;
287}
288
289// ---------------------------------------------------------------------------
290// Sandbox repo — git side effects (no DB required)
291// ---------------------------------------------------------------------------
292
293describe("findManifest + findUsages (no DB)", () => {
294 it("locates package.json + the call-site that imports the dep", async () => {
295 const { baseSha } = await seedRepo();
296 const m = await findManifest(OWNER, REPO, baseSha);
297 expect(m).not.toBeNull();
298 expect(m!.path).toBe("package.json");
299 expect(m!.content).toContain(`"hono"`);
300
301 const usages = await findUsages(OWNER, REPO, baseSha, "hono");
302 expect(usages).toContain("src/app.ts");
303 // manifest is filtered out of usages so the model gets a clean split.
304 expect(usages).not.toContain("package.json");
305 });
306});
307
308// ---------------------------------------------------------------------------
309// End-to-end with injected Claude
310// ---------------------------------------------------------------------------
311
312describe("proposeMajorMigration", () => {
313 it("returns null when dependency or toVersion is missing", async () => {
314 const out = await proposeMajorMigration({
315 repositoryId: "00000000-0000-0000-0000-000000000000",
316 dependency: "",
317 fromVersion: "1",
318 toVersion: "2",
319 baseSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
320 client: fakeClient("{}"),
321 });
322 expect(out).toBeNull();
323 });
324
325 it.skipIf(!HAS_DB)(
326 "creates branch + commit + PR when Claude returns a real patch",
327 async () => {
328 const username = `mig_${Date.now()}_${Math.random()
329 .toString(36)
330 .slice(2, 6)}`;
331 const [u] = await db
332 .insert(users)
333 .values({
334 username,
335 email: `${username}@example.com`,
336 passwordHash: "x",
337 })
338 .returning({ id: users.id });
339
340 const repoName = `subject_${Date.now()}`;
341 const [r] = await db
342 .insert(repositories)
343 .values({
344 ownerId: u.id,
345 name: repoName,
346 diskPath: `/tmp/${username}/${repoName}`,
347 defaultBranch: "main",
348 })
349 .returning({ id: repositories.id });
350
351 await initBareRepo(username, repoName);
352 const seedManifest = await createOrUpdateFileOnBranch({
353 owner: username,
354 name: repoName,
355 branch: "main",
356 filePath: "package.json",
357 bytes: new TextEncoder().encode(PACKAGE_JSON),
358 message: "seed manifest",
359 authorName: "Seeder",
360 authorEmail: "s@e.com",
361 });
362 if ("error" in seedManifest) throw new Error("seed manifest");
363 const seedApp = await createOrUpdateFileOnBranch({
364 owner: username,
365 name: repoName,
366 branch: "main",
367 filePath: "src/app.ts",
368 bytes: new TextEncoder().encode(APP_TS),
369 message: "seed app",
370 authorName: "Seeder",
371 authorEmail: "s@e.com",
372 });
373 if ("error" in seedApp) throw new Error("seed app");
374
375 const canned = JSON.stringify({
376 explanation: "Bumped hono and updated the import statement.",
377 patches: [
378 {
379 path: "package.json",
380 new_content: PACKAGE_JSON.replace("^3.0.0", "^4.0.0"),
381 },
382 {
383 path: "src/app.ts",
384 new_content: APP_TS.replace("hono", "hono"),
385 },
386 ],
387 test_updates: [
388 {
389 path: "test/app.test.ts",
390 new_content:
391 "import app from '../src/app';\nimport { describe, it, expect } from 'bun:test';\ndescribe('app', () => { it('starts', () => { expect(app).toBeTruthy(); }); });\n",
392 },
393 ],
394 });
395
396 const branchOverride = `ai-migration/test-${Date.now()}`;
397 const out = await proposeMajorMigration({
398 repositoryId: r.id,
399 dependency: "hono",
400 fromVersion: "^3.0.0",
401 toVersion: "4.0.0",
402 baseSha: seedApp.commitSha,
403 client: fakeClient(canned),
404 branchOverride,
405 });
406
407 expect(out).not.toBeNull();
408 expect(out!.branch).toBe(branchOverride);
409 expect(typeof out!.prNumber).toBe("number");
410
411 // Branch exists.
412 expect(
413 await refExists(username, repoName, `refs/heads/${branchOverride}`)
414 ).toBe(true);
415
416 // Manifest on the new branch has the bump applied.
417 const newManifest = await getBlob(
418 username,
419 repoName,
420 branchOverride,
421 "package.json"
422 );
423 expect(newManifest).not.toBeNull();
424 expect(newManifest!.content).toContain("^4.0.0");
425
426 // Test file was created.
427 const newTest = await getBlob(
428 username,
429 repoName,
430 branchOverride,
431 "test/app.test.ts"
432 );
433 expect(newTest).not.toBeNull();
434 expect(newTest!.content).toContain("describe('app'");
435
436 // PR row exists with the right base/head + body markers.
437 const [pr] = await db
438 .select({
439 number: pullRequests.number,
440 headBranch: pullRequests.headBranch,
441 baseBranch: pullRequests.baseBranch,
442 title: pullRequests.title,
443 body: pullRequests.body,
444 })
445 .from(pullRequests)
446 .where(eq(pullRequests.repositoryId, r.id))
447 .limit(1);
448 expect(pr).toBeTruthy();
449 expect(pr!.headBranch).toBe(branchOverride);
450 expect(pr!.baseBranch).toBe("main");
451 expect(pr!.title).toContain("[migration]");
452 expect(pr!.title).toContain("hono");
453 expect(pr!.body).toContain(MIGRATION_MARKER);
454 expect(pr!.body).toContain(MIGRATION_LABEL);
455
456 // Audit row for the proposal exists with the right metadata.
457 const audits = await db
458 .select({ metadata: auditLog.metadata })
459 .from(auditLog)
460 .where(
461 and(
462 eq(auditLog.repositoryId, r.id),
463 eq(auditLog.action, MIGRATION_AUDIT_ACTION)
464 )
465 )
466 .limit(5);
467 expect(audits.length).toBeGreaterThan(0);
468 const md = JSON.parse(audits[0].metadata as string) as {
469 dependency: string;
470 toVersion: string;
471 };
472 expect(md.dependency).toBe("hono");
473 expect(md.toVersion).toBe("4.0.0");
474 },
475 25000
476 );
477
478 it.skipIf(!HAS_DB)(
479 "dedupes proposals for the same {dep, version} within 7 days",
480 async () => {
481 const username = `mig_dup_${Date.now()}_${Math.random()
482 .toString(36)
483 .slice(2, 6)}`;
484 const [u] = await db
485 .insert(users)
486 .values({
487 username,
488 email: `${username}@example.com`,
489 passwordHash: "x",
490 })
491 .returning({ id: users.id });
492 const repoName = `dup_${Date.now()}`;
493 const [r] = await db
494 .insert(repositories)
495 .values({
496 ownerId: u.id,
497 name: repoName,
498 diskPath: `/tmp/${username}/${repoName}`,
499 defaultBranch: "main",
500 })
501 .returning({ id: repositories.id });
502
503 await initBareRepo(username, repoName);
504 const seedManifest = await createOrUpdateFileOnBranch({
505 owner: username,
506 name: repoName,
507 branch: "main",
508 filePath: "package.json",
509 bytes: new TextEncoder().encode(PACKAGE_JSON),
510 message: "seed",
511 authorName: "S",
512 authorEmail: "s@e.com",
513 });
514 if ("error" in seedManifest) throw new Error("seed");
515 const seedApp = await createOrUpdateFileOnBranch({
516 owner: username,
517 name: repoName,
518 branch: "main",
519 filePath: "src/app.ts",
520 bytes: new TextEncoder().encode(APP_TS),
521 message: "seed app",
522 authorName: "S",
523 authorEmail: "s@e.com",
524 });
525 if ("error" in seedApp) throw new Error("seed app");
526
527 const canned = JSON.stringify({
528 explanation: "stub",
529 patches: [
530 {
531 path: "package.json",
532 new_content: PACKAGE_JSON.replace("^3.0.0", "^4.0.0"),
533 },
534 ],
535 test_updates: [],
536 });
537
538 // First proposal lands.
539 const first = await proposeMajorMigration({
540 repositoryId: r.id,
541 dependency: "hono",
542 fromVersion: "^3.0.0",
543 toVersion: "4.0.0",
544 baseSha: seedApp.commitSha,
545 client: fakeClient(canned),
546 branchOverride: `ai-migration/dup-1-${Date.now()}`,
547 });
548 expect(first).not.toBeNull();
549
550 // Sanity: recentlyProposed should now report a hit.
551 expect(await recentlyProposed(r.id, "hono", "4.0.0")).toBe(true);
552
553 // Second proposal for the same dep+version is squashed by the
554 // throttle (skipThrottle defaults to false).
555 const second = await proposeMajorMigration({
556 repositoryId: r.id,
557 dependency: "hono",
558 fromVersion: "^3.0.0",
559 toVersion: "4.0.0",
560 baseSha: seedApp.commitSha,
561 client: fakeClient(canned),
562 branchOverride: `ai-migration/dup-2-${Date.now()}`,
563 });
564 expect(second).toBeNull();
565
566 // Only the first PR landed.
567 const prs = await db
568 .select({ id: pullRequests.id })
569 .from(pullRequests)
570 .where(eq(pullRequests.repositoryId, r.id));
571 expect(prs.length).toBe(1);
572
573 // skipThrottle:true lets the override land. (We don't actually
574 // assert a third PR because the manifest is already on 4.0.0;
575 // instead we check the function ran past the dedupe gate by
576 // observing recentlyProposed remains true.)
577 expect(await recentlyProposed(r.id, "hono", "4.0.0")).toBe(true);
578 },
579 30000
580 );
581});
582
583// ---------------------------------------------------------------------------
584// Watcher
585// ---------------------------------------------------------------------------
586
587describe("runMigrationWatcherTaskOnce", () => {
588 it("returns zero-counters when no AI key and no propose injection", async () => {
589 const saved = process.env.ANTHROPIC_API_KEY;
590 delete process.env.ANTHROPIC_API_KEY;
591 try {
592 const out = await runMigrationWatcherTaskOnce();
593 expect(out.proposed).toBe(0);
594 expect(out.considered).toBe(0);
595 } finally {
596 if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved;
597 }
598 });
599
600 it.skipIf(!HAS_DB)(
601 "skips repos when migration_watch is disabled",
602 async () => {
603 const username = `mig_w_${Date.now()}_${Math.random()
604 .toString(36)
605 .slice(2, 6)}`;
606 const [u] = await db
607 .insert(users)
608 .values({
609 username,
610 email: `${username}@example.com`,
611 passwordHash: "x",
612 })
613 .returning({ id: users.id });
614 await db
615 .insert(repositories)
616 .values({
617 ownerId: u.id,
618 name: `w_${Date.now()}`,
619 diskPath: `/tmp/${username}/w`,
620 defaultBranch: "main",
621 })
622 .returning({ id: repositories.id });
623
624 let proposeCalls = 0;
625 const out = await runMigrationWatcherTaskOnce({
626 propose: async () => {
627 proposeCalls++;
628 return null;
629 },
630 isEnabled: async () => false,
631 fetchLatest: async () => "9.9.9",
632 maxReposPerTick: 50,
633 });
634 expect(proposeCalls).toBe(0);
635 expect(out.skippedNotEnabled).toBeGreaterThan(0);
636 }
637 );
638});