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.
| 9dd96b9 | 1 | /** |
| 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 | ||
| 19 | import { describe, it, expect, beforeEach } from "bun:test"; | |
| 20 | import { readFileSync } from "node:fs"; | |
| 21 | import { join } from "node:path"; | |
| 22 | ||
| 23 | import { | |
| 24 | tryAutoMergeNow, | |
| 25 | type AutoMergeContext, | |
| 26 | type AutoMergeDecision, | |
| 27 | type TryAutoMergeNowDeps, | |
| 28 | } from "../lib/auto-merge"; | |
| 29 | import type { PerformMergeResult } from "../lib/pr-merge"; | |
| 30 | ||
| 31 | // --------------------------------------------------------------------------- | |
| 32 | // Test fixtures | |
| 33 | // --------------------------------------------------------------------------- | |
| 34 | ||
| 35 | type FastLaneCtxArg = Parameters<NonNullable<TryAutoMergeNowDeps["merge"]>>[0]; | |
| 36 | ||
| 37 | function 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 | ||
| 55 | const greenDecision: AutoMergeDecision = { | |
| 56 | merge: true, | |
| 57 | reason: "All auto-merge conditions met for 'main'.", | |
| 58 | }; | |
| 59 | ||
| 60 | const blockedDecision: AutoMergeDecision = { | |
| 61 | merge: false, | |
| 62 | reason: "Branch protection requires AI approval but no approval was found.", | |
| 63 | blocking: ["AI approval missing."], | |
| 64 | }; | |
| 65 | ||
| 66 | const 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. | |
| 74 | function makeFakeSleep() { | |
| 75 | const calls: number[] = []; | |
| 76 | const sleep = async (ms: number) => { | |
| 77 | calls.push(ms); | |
| 78 | }; | |
| 79 | return { sleep, calls }; | |
| 80 | } | |
| 81 | ||
| 82 | interface RecordedAttempt { | |
| 83 | repositoryId: string; | |
| 84 | prId: string; | |
| 85 | decision: AutoMergeDecision; | |
| 86 | } | |
| 87 | ||
| 88 | interface RecordedMerged { | |
| 89 | ctx: FastLaneCtxArg; | |
| 90 | result: PerformMergeResult; | |
| 91 | } | |
| 92 | ||
| 93 | interface 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 | ||
| 104 | function 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 | ||
| 181 | describe("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 | ||
| 235 | describe("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 | ||
| 265 | describe("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 | ||
| 286 | describe("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 | ||
| 372 | describe("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 | }); |