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

enable-auto-merge.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.

enable-auto-merge.test.tsBlame552 lines · 1 contributor
f764c07Claude1/**
2 * Block N1 — Tests for the auto-merge bootstrap + readiness scripts.
3 *
4 * The script exports a pure orchestrator (`runEnableAutoMerge`) that
5 * takes a DB-shaped dependency and an `audit` callback. We feed it a
6 * hand-rolled fake DB that records inserts / updates / selects so every
7 * branch (insert vs update vs no-op) is observable without going near
8 * Neon.
9 *
10 * No `mock.module()` here on purpose — the orchestrator was designed
11 * with explicit DI so we don't have to poison the module graph for
12 * downstream test files.
13 */
14
15import { describe, expect, test, beforeEach, afterAll } from "bun:test";
16import {
17 runEnableAutoMerge,
18 renderDiff,
19 resolveRepo,
20 type DbLike,
21 type EnableAutoMergeArgs,
22} from "../../scripts/enable-auto-merge";
23import {
24 checkAnthropicKey,
25 checkAutopilotEnabled,
26 checkAutoMergeSweepRegistered,
27 checkMigration0040,
28} from "../../scripts/check-auto-merge-readiness";
29import type { BranchProtection } from "../db/schema";
30
31// ---------------------------------------------------------------------------
32// Fake DB — narrowly scoped to the script's actual queries.
33// ---------------------------------------------------------------------------
34
35interface FakeState {
36 users: Array<{ id: string; username: string }>;
37 repositories: Array<{
38 id: string;
39 ownerId: string;
40 name: string;
41 defaultBranch: string;
42 }>;
43 branchProtection: BranchProtection[];
44 inserts: Array<{ table: string; values: any }>;
45 updates: Array<{ table: string; set: any }>;
46}
47
48function freshState(): FakeState {
49 return {
50 users: [],
51 repositories: [],
52 branchProtection: [],
53 inserts: [],
54 updates: [],
55 };
56}
57
58/**
59 * Inspect a Drizzle pgTable proxy to identify it by a unique-shape
60 * column. We mirror the K1 approach: peek at well-known columns rather
61 * than importing the schema-internal Symbol.
62 */
63function tableName(t: any): string {
64 if (!t || typeof t !== "object") return "?";
65 if ("isPrivate" in t && "defaultBranch" in t) return "repositories";
66 if ("username" in t && "passwordHash" in t) return "users";
67 if ("pattern" in t && "enableAutoMerge" in t) return "branch_protection";
68 if ("action" in t && "userId" in t && "targetType" in t) return "audit_log";
69 return "?";
70}
71
72/**
73 * Build a where-predicate from a Drizzle SQL expression. We can't
74 * introspect Drizzle's AST cheaply, so the fake instead records the
75 * "select context" (which table is being queried) and the test sets
76 * `_filter` when needed. For this script's queries we only need one
77 * filter per call so we infer it from the most recent select.
78 */
79function makeDb(state: FakeState): DbLike {
80 let _selectTable: string = "?";
81 let _selectCols: any = null;
82 let _selectFilter: any = null;
83
84 // Drizzle's `eq(col, val)` returns a plain object — we don't need to
85 // unwrap it. Instead we capture the *operand* values via wrapped
86 // helpers that the script itself uses through drizzle-orm.
87 // For our purposes the filter just needs to be applied in `.limit()`
88 // or `.where().limit()`, so we approximate: each table only has one
89 // representative row that the test wants matched. We match by the
90 // first call argument shape.
91
92 const selectChain: any = {
93 from: (t: any) => {
94 _selectTable = tableName(t);
95 return selectChain;
96 },
97 where: (cond: any) => {
98 _selectFilter = cond;
99 return selectChain;
100 },
101 limit: async () => {
102 return resolveSelect(_selectTable, _selectFilter, _selectCols, state);
103 },
104 };
105
106 return {
107 select: (cols?: any) => {
108 _selectCols = cols;
109 _selectTable = "?";
110 _selectFilter = null;
111 return selectChain;
112 },
113 insert: (t: any) => {
114 const name = tableName(t);
115 return {
116 values: (vals: any) => {
117 state.inserts.push({ table: name, values: vals });
118 if (name === "branch_protection") {
119 // Synthesize a BranchProtection row.
120 const row: BranchProtection = {
121 id: `bp-${state.branchProtection.length + 1}`,
122 repositoryId: vals.repositoryId,
123 pattern: vals.pattern,
124 requirePullRequest: vals.requirePullRequest ?? true,
125 requireGreenGates: vals.requireGreenGates ?? true,
126 requireAiApproval: vals.requireAiApproval ?? true,
127 requireHumanReview: vals.requireHumanReview ?? false,
128 requiredApprovals: vals.requiredApprovals ?? 0,
129 allowForcePush: vals.allowForcePush ?? false,
130 allowDeletion: vals.allowDeletion ?? false,
131 dismissStaleReviews: vals.dismissStaleReviews ?? false,
132 enableAutoMerge: vals.enableAutoMerge ?? false,
133 createdAt: new Date(),
134 updatedAt: new Date(),
135 };
136 state.branchProtection.push(row);
137 return {
138 returning: async () => [row],
139 };
140 }
141 return {
142 returning: async () => [vals],
143 };
144 },
145 };
146 },
147 update: (t: any) => {
148 const name = tableName(t);
149 return {
150 set: (vals: any) => {
151 state.updates.push({ table: name, set: vals });
152 return {
153 where: async () => {
154 if (name === "branch_protection") {
155 // Apply set values to the single bp row (script only
156 // updates by id, and tests only have one bp row at a time).
157 for (const r of state.branchProtection) {
158 Object.assign(r, vals);
159 }
160 }
161 return undefined;
162 },
163 };
164 },
165 };
166 },
167 };
168}
169
170function resolveSelect(
171 table: string,
172 _filter: any,
173 _cols: any,
174 state: FakeState
175): any[] {
176 // Approximation: return the (single) configured row for the table.
177 // resolveRepo issues two selects in sequence — first against `users`,
178 // then `repositories` — and the script only seeds one of each in
179 // these tests, so returning the first row is sufficient.
180 if (table === "users") {
181 return state.users.length > 0 ? [state.users[0]] : [];
182 }
183 if (table === "repositories") {
184 return state.repositories.length > 0
185 ? [{
186 id: state.repositories[0]!.id,
187 ownerId: state.repositories[0]!.ownerId,
188 name: state.repositories[0]!.name,
189 defaultBranch: state.repositories[0]!.defaultBranch,
190 }]
191 : [];
192 }
193 if (table === "branch_protection") {
194 return state.branchProtection.length > 0
195 ? [state.branchProtection[0]]
196 : [];
197 }
198 return [];
199}
200
201// ---------------------------------------------------------------------------
202// Audit fake
203// ---------------------------------------------------------------------------
204
205type AuditCall = {
206 userId?: string | null;
207 repositoryId?: string | null;
208 action: string;
209 targetType?: string;
210 targetId?: string;
211 metadata?: Record<string, unknown>;
212};
213
214function makeAudit(sink: AuditCall[]) {
215 return async (opts: AuditCall) => {
216 sink.push(opts);
217 };
218}
219
220// ---------------------------------------------------------------------------
221// Fixtures
222// ---------------------------------------------------------------------------
223
224let state: FakeState;
225let audits: AuditCall[];
226let db: DbLike;
227
228function seedRepo(opts?: { withProtection?: Partial<BranchProtection> }) {
229 state.users.push({ id: "user-1", username: "ccantynz" });
230 state.repositories.push({
231 id: "repo-1",
232 ownerId: "user-1",
233 name: "Gluecron.com",
234 defaultBranch: "main",
235 });
236 if (opts?.withProtection) {
237 state.branchProtection.push({
238 id: "bp-existing",
239 repositoryId: "repo-1",
240 pattern: "main",
241 requirePullRequest: true,
242 requireGreenGates: true,
243 requireAiApproval: true,
244 requireHumanReview: false,
245 requiredApprovals: 0,
246 allowForcePush: false,
247 allowDeletion: false,
248 dismissStaleReviews: false,
249 enableAutoMerge: false,
250 createdAt: new Date("2026-01-01"),
251 updatedAt: new Date("2026-01-01"),
252 ...opts.withProtection,
253 } as BranchProtection);
254 }
255}
256
257beforeEach(() => {
258 state = freshState();
259 audits = [];
260 db = makeDb(state);
261});
262
263afterAll(() => {
264 // No module-level mocks installed — nothing to undo. Reset state for
265 // hygiene anyway.
266 state = freshState();
267 audits = [];
268});
269
270// ---------------------------------------------------------------------------
271// resolveRepo
272// ---------------------------------------------------------------------------
273
274describe("resolveRepo", () => {
275 test("returns the repo when owner + name match", async () => {
276 seedRepo();
277 const r = await resolveRepo(db, "ccantynz/Gluecron.com");
278 expect(r).not.toBeNull();
279 expect(r?.id).toBe("repo-1");
280 });
281
282 test("returns null when the owner doesn't exist", async () => {
283 // No seeded users
284 const r = await resolveRepo(db, "ghost/repo");
285 expect(r).toBeNull();
286 });
287
288 test("rejects malformed owner/name", async () => {
289 seedRepo();
290 expect(await resolveRepo(db, "no-slash")).toBeNull();
291 expect(await resolveRepo(db, "/")).toBeNull();
292 expect(await resolveRepo(db, "owner/")).toBeNull();
293 });
294});
295
296// ---------------------------------------------------------------------------
297// runEnableAutoMerge — INSERT path
298// ---------------------------------------------------------------------------
299
300describe("runEnableAutoMerge — fresh insert", () => {
301 test("inserts a new branch_protection row with the safety defaults", async () => {
302 seedRepo(); // no existing protection
303 const args: EnableAutoMergeArgs = {
304 ownerSlash: "ccantynz/Gluecron.com",
305 pattern: "main",
306 };
307 const result = await runEnableAutoMerge(db, args, makeAudit(audits));
308
309 expect(result.action).toBe("inserted");
310 expect(result.before).toBeNull();
311 expect(result.after.enableAutoMerge).toBe(true);
312
313 // Safety defaults present on insert.
314 const ins = state.inserts.find((i) => i.table === "branch_protection");
315 expect(ins).toBeTruthy();
316 expect(ins!.values.requireGreenGates).toBe(true);
317 expect(ins!.values.requireAiApproval).toBe(true);
318 expect(ins!.values.requireHumanReview).toBe(false);
319 expect(ins!.values.requiredApprovals).toBe(0);
320 expect(ins!.values.enableAutoMerge).toBe(true);
321 expect(ins!.values.dismissStaleReviews).toBe(false);
322 expect(ins!.values.allowForcePush).toBe(false);
323 expect(ins!.values.allowDeletion).toBe(false);
324
325 // Audit row written.
326 expect(audits.length).toBe(1);
327 expect(audits[0]!.action).toBe("auto_merge.enabled_on_main");
328 expect(audits[0]!.repositoryId).toBe("repo-1");
329 expect(audits[0]!.targetType).toBe("branch_protection");
330 });
331
332 test("throws a clear error when the repo isn't found", async () => {
333 // No seed — empty DB.
334 let caught: Error | null = null;
335 try {
336 await runEnableAutoMerge(
337 db,
338 { ownerSlash: "ghost/nope", pattern: "main" },
339 makeAudit(audits)
340 );
341 } catch (err) {
342 caught = err as Error;
343 }
344 expect(caught).not.toBeNull();
345 expect(caught?.message).toMatch(/Repository not found/);
346 // No audit entry written for a failed resolve.
347 expect(audits.length).toBe(0);
348 });
349});
350
351// ---------------------------------------------------------------------------
352// runEnableAutoMerge — UPDATE path
353// ---------------------------------------------------------------------------
354
355describe("runEnableAutoMerge — existing row", () => {
356 test("flips enableAutoMerge=true on an existing row, preserves other fields", async () => {
357 seedRepo({
358 withProtection: {
359 enableAutoMerge: false,
360 requireGreenGates: true,
361 requireAiApproval: true,
362 requireHumanReview: true, // not what the defaults would set
363 requiredApprovals: 2,
364 },
365 });
366 const result = await runEnableAutoMerge(
367 db,
368 { ownerSlash: "ccantynz/Gluecron.com", pattern: "main" },
369 makeAudit(audits)
370 );
371
372 expect(result.action).toBe("updated");
373 expect(result.before).not.toBeNull();
374 expect(result.before!.enableAutoMerge).toBe(false);
375 expect(result.after.enableAutoMerge).toBe(true);
376
377 // Other fields preserved on the after row.
378 expect(result.after.requireHumanReview).toBe(true);
379 expect(result.after.requiredApprovals).toBe(2);
380
381 // Only enableAutoMerge + updatedAt should land in the SET payload.
382 const upd = state.updates.find((u) => u.table === "branch_protection");
383 expect(upd).toBeTruthy();
384 expect(upd!.set.enableAutoMerge).toBe(true);
385 expect("requireHumanReview" in upd!.set).toBe(false);
386 expect("requiredApprovals" in upd!.set).toBe(false);
387
388 expect(audits.length).toBe(1);
389 expect(audits[0]!.action).toBe("auto_merge.enabled_on_main");
390 });
391
392 test("--off flips the bit to false and writes the disable audit action", async () => {
393 seedRepo({ withProtection: { enableAutoMerge: true } });
394 const result = await runEnableAutoMerge(
395 db,
396 { ownerSlash: "ccantynz/Gluecron.com", pattern: "main", off: true },
397 makeAudit(audits)
398 );
399
400 expect(result.action).toBe("updated");
401 expect(result.after.enableAutoMerge).toBe(false);
402 expect(audits[0]!.action).toBe("auto_merge.disabled_on_main");
403 });
404
405 test("idempotent: second run is a no-op with no second audit entry", async () => {
406 seedRepo({ withProtection: { enableAutoMerge: true } });
407 const result = await runEnableAutoMerge(
408 db,
409 { ownerSlash: "ccantynz/Gluecron.com", pattern: "main" },
410 makeAudit(audits)
411 );
412 expect(result.action).toBe("noop");
413 expect(result.auditWritten).toBe(false);
414 expect(audits.length).toBe(0);
415 // And no UPDATE was issued.
416 expect(state.updates.length).toBe(0);
417 });
418});
419
420// ---------------------------------------------------------------------------
421// renderDiff
422// ---------------------------------------------------------------------------
423
424describe("renderDiff", () => {
425 test("INSERT case renders every tracked field as additions", () => {
426 const after: BranchProtection = {
427 id: "bp-1",
428 repositoryId: "repo-1",
429 pattern: "main",
430 requirePullRequest: true,
431 requireGreenGates: true,
432 requireAiApproval: true,
433 requireHumanReview: false,
434 requiredApprovals: 0,
435 allowForcePush: false,
436 allowDeletion: false,
437 dismissStaleReviews: false,
438 enableAutoMerge: true,
439 createdAt: new Date(),
440 updatedAt: new Date(),
441 } as BranchProtection;
442 const out = renderDiff(null, after);
443 expect(out).toContain("no previous");
444 expect(out).toContain("enableAutoMerge = true");
445 expect(out).toContain("requireAiApproval = true");
446 });
447
448 test("UPDATE case renders only the changed fields", () => {
449 const before: BranchProtection = {
450 id: "bp-1",
451 repositoryId: "repo-1",
452 pattern: "main",
453 requirePullRequest: true,
454 requireGreenGates: true,
455 requireAiApproval: true,
456 requireHumanReview: false,
457 requiredApprovals: 0,
458 allowForcePush: false,
459 allowDeletion: false,
460 dismissStaleReviews: false,
461 enableAutoMerge: false,
462 createdAt: new Date(),
463 updatedAt: new Date(),
464 } as BranchProtection;
465 const after = { ...before, enableAutoMerge: true } as BranchProtection;
466 const out = renderDiff(before, after);
467 expect(out).toContain("enableAutoMerge");
468 expect(out).toContain("false");
469 expect(out).toContain("true");
470 // No other field should appear.
471 expect(out).not.toContain("requireAiApproval:");
472 expect(out).not.toContain("requireGreenGates:");
473 });
474});
475
476// ---------------------------------------------------------------------------
477// Readiness checks
478// ---------------------------------------------------------------------------
479
480describe("readiness check helpers", () => {
481 test("checkAnthropicKey fails when env var is missing", () => {
482 const r = checkAnthropicKey({} as NodeJS.ProcessEnv);
483 expect(r.status).toBe("fail");
484 expect(r.reason).toMatch(/ANTHROPIC_API_KEY/);
485 });
486
487 test("checkAnthropicKey passes when env var is present", () => {
488 const r = checkAnthropicKey({
489 ANTHROPIC_API_KEY: "sk-ant-1234567890",
490 } as unknown as NodeJS.ProcessEnv);
491 expect(r.status).toBe("pass");
492 });
493
494 test("checkAutopilotEnabled fails when AUTOPILOT_DISABLED=1", () => {
495 const r = checkAutopilotEnabled({
496 AUTOPILOT_DISABLED: "1",
497 } as unknown as NodeJS.ProcessEnv);
498 expect(r.status).toBe("fail");
499 });
500
501 test("checkAutopilotEnabled passes when AUTOPILOT_DISABLED is unset", () => {
502 const r = checkAutopilotEnabled({} as NodeJS.ProcessEnv);
503 expect(r.status).toBe("pass");
504 });
505
506 test("checkAutoMergeSweepRegistered passes when the task is present", () => {
507 const r = checkAutoMergeSweepRegistered([
508 { name: "mirror-sync" },
509 { name: "auto-merge-sweep" },
510 { name: "weekly-digest" },
511 ]);
512 expect(r.status).toBe("pass");
513 });
514
515 test("checkAutoMergeSweepRegistered fails when missing", () => {
516 const r = checkAutoMergeSweepRegistered([{ name: "mirror-sync" }]);
517 expect(r.status).toBe("fail");
518 expect(r.reason).toMatch(/auto-merge-sweep/);
519 });
520
521 test("checkMigration0040 fails when column is missing", async () => {
522 const r = await checkMigration0040(async () => ({ exists: false }));
523 expect(r.status).toBe("fail");
524 expect(r.reason).toMatch(/enable_auto_merge/);
525 });
526
527 test("checkMigration0040 fails on runner error", async () => {
528 const r = await checkMigration0040(async () => ({
529 exists: false,
530 error: "connection refused",
531 }));
532 expect(r.status).toBe("fail");
533 expect(r.reason).toMatch(/connection refused/);
534 });
535
536 test("checkMigration0040 passes when column exists", async () => {
537 const r = await checkMigration0040(async () => ({ exists: true }));
538 expect(r.status).toBe("pass");
539 });
540});
541
542// ---------------------------------------------------------------------------
543// Confirm the real defaultTasks() registers auto-merge-sweep.
544// ---------------------------------------------------------------------------
545
546describe("real defaultTasks() registration", () => {
547 test("registers an 'auto-merge-sweep' task (guards against accidental removal)", async () => {
548 const { defaultTasks } = await import("../lib/autopilot");
549 const tasks = defaultTasks();
550 expect(tasks.some((t) => t.name === "auto-merge-sweep")).toBe(true);
551 });
552});