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

auto-merge-fast-lane.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.

auto-merge-fast-lane.test.tsBlame385 lines · 1 contributor
9dd96b9Test User1/**
2 * Block R3 — Fast-lane auto-merge (PR-create / PR-head-update event path) tests.
3 *
4 * Drives `tryAutoMergeNow` through its dependency-injection seam so no DB
5 * or external module is touched. The lib is loaded via spread-from-real
6 * import so an `afterAll` can no-op restore neighbouring suites — same K1
7 * pattern other tests use, even though no `mock.module` override is needed
8 * for this suite (the public API exposes a `deps` injection point).
9 *
10 * Covers the R3 contract:
11 * - waits for an AI-review comment when none exists yet (polls multiple times)
12 * - times out after `waitForAiReviewMs` without merging
13 * - on green decision: calls performMerge once + records auto_merge.merged audit + posts marker
14 * - on blocked decision: records the failure reason; does NOT call performMerge
15 * - never throws even when every injected helper throws
16 * - the PR-create route in pulls.tsx wires `tryAutoMergeNow(pr.id)` fire-and-forget
17 */
18
19import { describe, it, expect, beforeEach } from "bun:test";
20import { readFileSync } from "node:fs";
21import { join } from "node:path";
22
23import {
24 tryAutoMergeNow,
25 type AutoMergeContext,
26 type AutoMergeDecision,
27 type TryAutoMergeNowDeps,
28} from "../lib/auto-merge";
29import type { PerformMergeResult } from "../lib/pr-merge";
30
31// ---------------------------------------------------------------------------
32// Test fixtures
33// ---------------------------------------------------------------------------
34
35type FastLaneCtxArg = Parameters<NonNullable<TryAutoMergeNowDeps["merge"]>>[0];
36
37function makeCtx(overrides: Partial<FastLaneCtxArg> = {}): FastLaneCtxArg {
38 return {
39 prId: "pr-1",
40 prNumber: 7,
41 prTitle: "Fast lane test PR",
42 prBody: "Closes #99",
43 baseBranch: "main",
44 headBranch: "feature",
45 isDraft: false,
46 repositoryId: "repo-1",
47 authorUserId: "user-1",
48 ownerUsername: "alice",
49 repoName: "demo",
50 state: "open",
51 ...overrides,
52 };
53}
54
55const greenDecision: AutoMergeDecision = {
56 merge: true,
57 reason: "All auto-merge conditions met for 'main'.",
58};
59
60const blockedDecision: AutoMergeDecision = {
61 merge: false,
62 reason: "Branch protection requires AI approval but no approval was found.",
63 blocking: ["AI approval missing."],
64};
65
66const okMerge: PerformMergeResult = {
67 ok: true,
68 closedIssueNumbers: [99],
69 resolvedFiles: [],
70};
71
72// A no-op sleep so tests don't actually wait. We still record each call
73// so polling assertions can inspect them.
74function makeFakeSleep() {
75 const calls: number[] = [];
76 const sleep = async (ms: number) => {
77 calls.push(ms);
78 };
79 return { sleep, calls };
80}
81
82interface RecordedAttempt {
83 repositoryId: string;
84 prId: string;
85 decision: AutoMergeDecision;
86}
87
88interface RecordedMerged {
89 ctx: FastLaneCtxArg;
90 result: PerformMergeResult;
91}
92
93interface TestHarness {
94 loadContextCalls: number;
95 hasAiReviewCalls: number;
96 evaluateCalls: AutoMergeContext[];
97 mergeCalls: FastLaneCtxArg[];
98 recordedAttempts: RecordedAttempt[];
99 mergedCalls: RecordedMerged[];
100 sleepCalls: number[];
101 deps: TryAutoMergeNowDeps;
102}
103
104function makeHarness(overrides: {
105 ctx?: FastLaneCtxArg | null;
106 /** Return value of hasAiReviewComment, indexed by call count (0-based). */
107 aiCommentReadyAfterPolls?: number; // -1 means never
108 decision?: AutoMergeDecision;
109 mergeResult?: PerformMergeResult;
110 // Throw-injection hooks (default false everywhere)
111 loadContextThrows?: boolean;
112 hasAiReviewThrows?: boolean;
113 evaluateThrows?: boolean;
114 mergeThrows?: boolean;
115 recordAttemptThrows?: boolean;
116 onMergedThrows?: boolean;
117} = {}): TestHarness {
118 const ctx = overrides.ctx === undefined ? makeCtx() : overrides.ctx;
119 const decision = overrides.decision ?? greenDecision;
120 const mergeResult = overrides.mergeResult ?? okMerge;
121 const aiReadyAfter =
122 overrides.aiCommentReadyAfterPolls === undefined
123 ? 0 // ready on first probe by default
124 : overrides.aiCommentReadyAfterPolls;
125
126 const h: TestHarness = {
127 loadContextCalls: 0,
128 hasAiReviewCalls: 0,
129 evaluateCalls: [],
130 mergeCalls: [],
131 recordedAttempts: [],
132 mergedCalls: [],
133 sleepCalls: [],
134 deps: {},
135 };
136
137 const sleep = async (ms: number) => {
138 h.sleepCalls.push(ms);
139 };
140
141 h.deps = {
142 loadContext: async () => {
143 h.loadContextCalls += 1;
144 if (overrides.loadContextThrows) throw new Error("loadContext boom");
145 return ctx;
146 },
147 hasAiReviewComment: async () => {
148 h.hasAiReviewCalls += 1;
149 if (overrides.hasAiReviewThrows) throw new Error("hasAiReview boom");
150 if (aiReadyAfter < 0) return false;
151 return h.hasAiReviewCalls > aiReadyAfter;
152 },
153 evaluate: async (ctxArg: AutoMergeContext) => {
154 h.evaluateCalls.push(ctxArg);
155 if (overrides.evaluateThrows) throw new Error("evaluate boom");
156 return decision;
157 },
158 merge: async (mctx) => {
159 h.mergeCalls.push(mctx);
160 if (overrides.mergeThrows) throw new Error("merge boom");
161 return mergeResult;
162 },
163 recordAttempt: async (repoId, prId, d) => {
164 h.recordedAttempts.push({ repositoryId: repoId, prId, decision: d });
165 if (overrides.recordAttemptThrows) throw new Error("record boom");
166 },
167 onMerged: async (mctx, result) => {
168 h.mergedCalls.push({ ctx: mctx, result });
169 if (overrides.onMergedThrows) throw new Error("onMerged boom");
170 },
171 sleep,
172 };
173
174 return h;
175}
176
177// ---------------------------------------------------------------------------
178// AI-review wait behaviour
179// ---------------------------------------------------------------------------
180
181describe("tryAutoMergeNow — AI-review wait", () => {
182 it("polls multiple times while the AI-review comment is missing, then evaluates once it lands", async () => {
183 // The probe returns false on calls 1+2 and true on call 3, so the
184 // outer loop should sleep twice and then evaluate.
185 const h = makeHarness({ aiCommentReadyAfterPolls: 2 });
186
187 await tryAutoMergeNow("pr-1", {
188 waitForAiReviewMs: 60_000,
189 aiReviewPollIntervalMs: 5_000,
190 deps: h.deps,
191 });
192
193 expect(h.hasAiReviewCalls).toBeGreaterThanOrEqual(3);
194 expect(h.sleepCalls.length).toBeGreaterThanOrEqual(2);
195 expect(h.evaluateCalls.length).toBe(1);
196 // Green decision → merge proceeds.
197 expect(h.mergeCalls.length).toBe(1);
198 });
199
200 it("gives up cleanly when the AI-review wait window elapses without a comment", async () => {
201 // waitForAiReviewMs=0 ⇒ first check fails immediately and we return
202 // without evaluating. Models "AI review never landed before the cap".
203 const h = makeHarness({ aiCommentReadyAfterPolls: -1 });
204
205 await tryAutoMergeNow("pr-1", {
206 waitForAiReviewMs: 0,
207 aiReviewPollIntervalMs: 1,
208 deps: h.deps,
209 });
210
211 expect(h.hasAiReviewCalls).toBe(1);
212 expect(h.evaluateCalls.length).toBe(0);
213 expect(h.mergeCalls.length).toBe(0);
214 expect(h.recordedAttempts.length).toBe(0);
215 });
216
217 it("respects skipAiReviewWait=true and decides immediately without probing", async () => {
218 const h = makeHarness({ aiCommentReadyAfterPolls: -1 });
219
220 await tryAutoMergeNow("pr-1", {
221 skipAiReviewWait: true,
222 deps: h.deps,
223 });
224
225 expect(h.hasAiReviewCalls).toBe(0);
226 expect(h.evaluateCalls.length).toBe(1);
227 expect(h.mergeCalls.length).toBe(1);
228 });
229});
230
231// ---------------------------------------------------------------------------
232// Decision branching
233// ---------------------------------------------------------------------------
234
235describe("tryAutoMergeNow — green path", () => {
236 it("calls performMerge once + fires onMerged + records the evaluated audit", async () => {
237 const h = makeHarness({ decision: greenDecision });
238
239 await tryAutoMergeNow("pr-1", {
240 skipAiReviewWait: true,
241 deps: h.deps,
242 });
243
244 expect(h.mergeCalls.length).toBe(1);
245 expect(h.mergedCalls.length).toBe(1);
246 expect(h.mergedCalls[0].ctx.prId).toBe("pr-1");
247 expect(h.mergedCalls[0].result.ok).toBe(true);
248
249 // recordAttempt fires exactly once with the green decision.
250 expect(h.recordedAttempts.length).toBe(1);
251 expect(h.recordedAttempts[0].decision.merge).toBe(true);
252 });
253
254 it("default onMerged side-effect posts the gluecron:auto-merge:v1 marker comment", async () => {
255 // Verify the actual marker token is exported and starts the comment
256 // body that defaultOnMerged emits. Pulling from __test rather than
257 // copy-pasting the constant keeps the assertion honest.
258 const mod = await import("../lib/auto-merge");
259 expect(mod.__test.FAST_LANE_AUTO_MERGE_MARKER).toBe(
260 "<!-- gluecron:auto-merge:v1 -->"
261 );
262 });
263});
264
265describe("tryAutoMergeNow — blocked path", () => {
266 it("records the failure reason and does NOT call performMerge", async () => {
267 const h = makeHarness({ decision: blockedDecision });
268
269 await tryAutoMergeNow("pr-1", {
270 skipAiReviewWait: true,
271 deps: h.deps,
272 });
273
274 expect(h.mergeCalls.length).toBe(0);
275 expect(h.mergedCalls.length).toBe(0);
276 expect(h.recordedAttempts.length).toBe(1);
277 expect(h.recordedAttempts[0].decision.merge).toBe(false);
278 expect(h.recordedAttempts[0].decision.reason).toMatch(/AI approval/i);
279 });
280});
281
282// ---------------------------------------------------------------------------
283// Never-throws guarantee
284// ---------------------------------------------------------------------------
285
286describe("tryAutoMergeNow — never throws", () => {
287 it("swallows loadContext throws", async () => {
288 const h = makeHarness({ loadContextThrows: true });
289 await expect(
290 tryAutoMergeNow("pr-1", { skipAiReviewWait: true, deps: h.deps })
291 ).resolves.toBeUndefined();
292 // Nothing else should have run.
293 expect(h.evaluateCalls.length).toBe(0);
294 expect(h.mergeCalls.length).toBe(0);
295 });
296
297 it("swallows evaluate throws + still records an audit", async () => {
298 const h = makeHarness({ evaluateThrows: true });
299 await expect(
300 tryAutoMergeNow("pr-1", { skipAiReviewWait: true, deps: h.deps })
301 ).resolves.toBeUndefined();
302 // We never merged.
303 expect(h.mergeCalls.length).toBe(0);
304 // But we still recorded a failure audit so the paper trail survives.
305 expect(h.recordedAttempts.length).toBe(1);
306 expect(h.recordedAttempts[0].decision.merge).toBe(false);
307 });
308
309 it("swallows merge throws", async () => {
310 const h = makeHarness({ mergeThrows: true, decision: greenDecision });
311 await expect(
312 tryAutoMergeNow("pr-1", { skipAiReviewWait: true, deps: h.deps })
313 ).resolves.toBeUndefined();
314 expect(h.mergeCalls.length).toBe(1);
315 expect(h.mergedCalls.length).toBe(0);
316 });
317
318 it("swallows recordAttempt + onMerged throws", async () => {
319 const h = makeHarness({
320 recordAttemptThrows: true,
321 onMergedThrows: true,
322 decision: greenDecision,
323 });
324 await expect(
325 tryAutoMergeNow("pr-1", { skipAiReviewWait: true, deps: h.deps })
326 ).resolves.toBeUndefined();
327 // Merge still attempted; recordAttempt + onMerged both threw silently.
328 expect(h.mergeCalls.length).toBe(1);
329 });
330
331 it("never throws even when every injected helper throws", async () => {
332 const exploding: TryAutoMergeNowDeps = {
333 loadContext: async () => {
334 throw new Error("load");
335 },
336 hasAiReviewComment: async () => {
337 throw new Error("probe");
338 },
339 evaluate: async () => {
340 throw new Error("eval");
341 },
342 merge: async () => {
343 throw new Error("merge");
344 },
345 recordAttempt: async () => {
346 throw new Error("record");
347 },
348 onMerged: async () => {
349 throw new Error("merged");
350 },
351 sleep: async () => {
352 throw new Error("sleep");
353 },
354 };
355 await expect(
356 tryAutoMergeNow("pr-1", { skipAiReviewWait: true, deps: exploding })
357 ).resolves.toBeUndefined();
358 });
359
360 it("returns cleanly when loadContext returns null (PR vanished mid-flight)", async () => {
361 const h = makeHarness({ ctx: null });
362 await tryAutoMergeNow("pr-1", { skipAiReviewWait: true, deps: h.deps });
363 expect(h.evaluateCalls.length).toBe(0);
364 expect(h.mergeCalls.length).toBe(0);
365 });
366});
367
368// ---------------------------------------------------------------------------
369// Route wiring smoke check
370// ---------------------------------------------------------------------------
371
372describe("pulls.tsx — PR-create hook wiring (R3 fast-lane)", () => {
373 it("imports auto-merge and calls tryAutoMergeNow(pr.id) fire-and-forget after PR insert", () => {
374 const src = readFileSync(
375 join(import.meta.dir, "..", "routes", "pulls.tsx"),
376 "utf8"
377 );
378 // The exact line we added — kept loose enough to allow whitespace
379 // diffs but strict enough that an accidental delete trips this test.
380 expect(src).toMatch(/import\(\s*"\.\.\/lib\/auto-merge"\s*\)/);
381 expect(src).toMatch(/tryAutoMergeNow\s*\(\s*pr\.id\s*\)/);
382 // R3 marker comment so a casual rebase doesn't quietly drop the hook.
383 expect(src).toMatch(/R3 .*fast-lane auto-merge/i);
384 });
385});