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.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.test.tsBlame240 lines · 1 contributor
4626e61Claude1/**
2 * Block K2 — AI-gated auto-merge evaluator tests.
3 *
4 * Drives the pure decision helper directly (no DB) so every branch is
5 * deterministic. The DB-backed `evaluateAutoMerge` wrapper is exercised
6 * indirectly via the same decision logic — its own integration is
7 * trivial glue.
8 */
9
10import { describe, expect, test } from "bun:test";
11import {
12 __test,
13 type AutoMergeDecision,
14} from "../lib/auto-merge";
15import type { BranchProtection } from "../db/schema";
16
17const { decideAutoMerge, aiCommentLooksApproved } = __test;
18
19function rule(overrides: Partial<BranchProtection> = {}): BranchProtection {
20 return {
21 id: "id-rule",
22 repositoryId: "repo-1",
23 pattern: "main",
24 requirePullRequest: true,
25 requireGreenGates: false,
26 requireAiApproval: false,
27 requireHumanReview: false,
28 requiredApprovals: 0,
29 allowForcePush: false,
30 allowDeletion: false,
31 dismissStaleReviews: true,
32 enableAutoMerge: true,
33 createdAt: new Date(),
34 updatedAt: new Date(),
35 ...overrides,
36 } as BranchProtection;
37}
38
39function happyArgs() {
40 return {
41 rule: rule(),
42 isDraft: false,
43 aiApproved: true,
44 humanApprovalCount: 0,
45 hasFailedGates: false,
46 passingCheckNames: [] as string[],
47 requiredCheckNames: [] as string[],
48 };
49}
50
51function assertInvariant(d: AutoMergeDecision) {
52 // The blocking list is non-empty iff merge=false.
53 if (d.merge) {
54 expect(d.blocking === undefined || d.blocking.length === 0).toBe(true);
55 } else {
56 expect(d.blocking && d.blocking.length > 0).toBe(true);
57 }
58}
59
60describe("decideAutoMerge", () => {
61 test("happy path: rule on, no draft, AI approved, gates green → merge", () => {
62 const d = decideAutoMerge({
63 ...happyArgs(),
64 rule: rule({ requireAiApproval: true }),
65 aiApproved: true,
66 });
67 expect(d.merge).toBe(true);
68 assertInvariant(d);
69 });
70
71 test("default-deny when no branch_protection rule matches", () => {
72 const d = decideAutoMerge({ ...happyArgs(), rule: null });
73 expect(d.merge).toBe(false);
74 expect(d.blocking?.[0]).toMatch(/default-deny/i);
75 assertInvariant(d);
76 });
77
78 test("default-deny when enableAutoMerge=false on the matching rule", () => {
79 const d = decideAutoMerge({
80 ...happyArgs(),
81 rule: rule({ enableAutoMerge: false }),
82 });
83 expect(d.merge).toBe(false);
84 expect(d.blocking?.some((r) => /auto-merge enabled/i.test(r))).toBe(true);
85 assertInvariant(d);
86 });
87
88 test("blocks when PR is draft", () => {
89 const d = decideAutoMerge({ ...happyArgs(), isDraft: true });
90 expect(d.merge).toBe(false);
91 expect(d.blocking?.some((r) => /draft/i.test(r))).toBe(true);
92 assertInvariant(d);
93 });
94
95 test("blocks when AI approval required but missing", () => {
96 const d = decideAutoMerge({
97 ...happyArgs(),
98 rule: rule({ requireAiApproval: true }),
99 aiApproved: false,
100 });
101 expect(d.merge).toBe(false);
102 expect(d.blocking?.some((r) => /AI approval/i.test(r))).toBe(true);
103 assertInvariant(d);
104 });
105
106 test("blocks on failing hard gate (requireGreenGates)", () => {
107 const d = decideAutoMerge({
108 ...happyArgs(),
109 rule: rule({ requireGreenGates: true }),
110 hasFailedGates: true,
111 });
112 expect(d.merge).toBe(false);
113 expect(d.blocking?.some((r) => /green gates/i.test(r))).toBe(true);
114 assertInvariant(d);
115 });
116
117 test("blocks on missing required check", () => {
118 const d = decideAutoMerge({
119 ...happyArgs(),
120 requiredCheckNames: ["lint", "test"],
121 passingCheckNames: ["lint"],
122 hasFailedGates: true, // K3 caller would set this consistently
123 });
124 expect(d.merge).toBe(false);
125 expect(d.blocking?.some((r) => /test/i.test(r))).toBe(true);
126 assertInvariant(d);
127 });
128
129 test("allows when all required checks pass", () => {
130 const d = decideAutoMerge({
131 ...happyArgs(),
132 requiredCheckNames: ["lint", "test"],
133 passingCheckNames: ["lint", "test"],
134 hasFailedGates: false,
135 });
136 expect(d.merge).toBe(true);
137 assertInvariant(d);
138 });
139
140 test("blocks when human review required and not present", () => {
141 const d = decideAutoMerge({
142 ...happyArgs(),
143 rule: rule({ requireHumanReview: true }),
144 humanApprovalCount: 0,
145 });
146 expect(d.merge).toBe(false);
147 expect(d.blocking?.some((r) => /human review/i.test(r))).toBe(true);
148 assertInvariant(d);
149 });
150
151 test("size cap blocks when over limit", () => {
152 const d = decideAutoMerge({
153 ...happyArgs(),
154 diffStats: { files: 50, lines: 5000 },
155 caps: { maxChangedFiles: 10, maxChangedLines: 1000 },
156 });
157 expect(d.merge).toBe(false);
158 expect(d.blocking?.length).toBeGreaterThanOrEqual(2);
159 assertInvariant(d);
160 });
161
162 test("size cap does not block when under limit", () => {
163 const d = decideAutoMerge({
164 ...happyArgs(),
165 diffStats: { files: 3, lines: 80 },
166 caps: { maxChangedFiles: 10, maxChangedLines: 1000 },
167 });
168 expect(d.merge).toBe(true);
169 assertInvariant(d);
170 });
171
172 test("size cap skipped when no caps provided even with big diff", () => {
173 const d = decideAutoMerge({
174 ...happyArgs(),
175 diffStats: { files: 9999, lines: 9999 },
176 });
177 expect(d.merge).toBe(true);
178 assertInvariant(d);
179 });
180
181 test("accumulates multiple blocking reasons", () => {
182 const d = decideAutoMerge({
183 ...happyArgs(),
184 rule: rule({
185 requireAiApproval: true,
186 requireGreenGates: true,
187 requireHumanReview: true,
188 }),
189 isDraft: true,
190 aiApproved: false,
191 humanApprovalCount: 0,
192 hasFailedGates: true,
193 });
194 expect(d.merge).toBe(false);
195 expect(d.blocking?.length).toBeGreaterThanOrEqual(4);
196 assertInvariant(d);
197 });
198
199 test("invariant: blocking list non-empty iff merge=false (random shapes)", () => {
200 const shapes = [
201 happyArgs(),
202 { ...happyArgs(), rule: null },
203 { ...happyArgs(), isDraft: true },
204 { ...happyArgs(), rule: rule({ enableAutoMerge: false }) },
205 ];
206 for (const s of shapes) {
207 assertInvariant(decideAutoMerge(s));
208 }
209 });
210});
211
212describe("aiCommentLooksApproved", () => {
213 test("approves a clean AI summary", () => {
214 const body =
215 "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\n**AI review:** no blocking issues found.\n\nLooks good.";
216 expect(aiCommentLooksApproved(body)).toBe(true);
217 });
218
219 test("rejects when API was unavailable", () => {
220 const body =
221 "<!-- gluecron-ai-review:summary -->\n## AI review unavailable\n\nThe AI review attempt failed: timeout.";
222 expect(aiCommentLooksApproved(body)).toBe(false);
223 });
224
225 test("rejects on severity: blocking marker (case-insensitive)", () => {
226 const body =
227 "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\nFindings:\n- Severity: BLOCKING — auth bypass at line 42.";
228 expect(aiCommentLooksApproved(body)).toBe(false);
229 });
230
231 test("rejects when AI flagged items for human attention", () => {
232 const body =
233 "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\n**AI review:** flagged 3 item(s) for human attention.";
234 expect(aiCommentLooksApproved(body)).toBe(false);
235 });
236
237 test("rejects empty body", () => {
238 expect(aiCommentLooksApproved("")).toBe(false);
239 });
240});