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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
auto-merge.test.ts7.1 KB · 240 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
/**
 * Block K2 — AI-gated auto-merge evaluator tests.
 *
 * Drives the pure decision helper directly (no DB) so every branch is
 * deterministic. The DB-backed `evaluateAutoMerge` wrapper is exercised
 * indirectly via the same decision logic — its own integration is
 * trivial glue.
 */

import { describe, expect, test } from "bun:test";
import {
  __test,
  type AutoMergeDecision,
} from "../lib/auto-merge";
import type { BranchProtection } from "../db/schema";

const { decideAutoMerge, aiCommentLooksApproved } = __test;

function rule(overrides: Partial<BranchProtection> = {}): BranchProtection {
  return {
    id: "id-rule",
    repositoryId: "repo-1",
    pattern: "main",
    requirePullRequest: true,
    requireGreenGates: false,
    requireAiApproval: false,
    requireHumanReview: false,
    requiredApprovals: 0,
    allowForcePush: false,
    allowDeletion: false,
    dismissStaleReviews: true,
    enableAutoMerge: true,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  } as BranchProtection;
}

function happyArgs() {
  return {
    rule: rule(),
    isDraft: false,
    aiApproved: true,
    humanApprovalCount: 0,
    hasFailedGates: false,
    passingCheckNames: [] as string[],
    requiredCheckNames: [] as string[],
  };
}

function assertInvariant(d: AutoMergeDecision) {
  // The blocking list is non-empty iff merge=false.
  if (d.merge) {
    expect(d.blocking === undefined || d.blocking.length === 0).toBe(true);
  } else {
    expect(d.blocking && d.blocking.length > 0).toBe(true);
  }
}

describe("decideAutoMerge", () => {
  test("happy path: rule on, no draft, AI approved, gates green → merge", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      rule: rule({ requireAiApproval: true }),
      aiApproved: true,
    });
    expect(d.merge).toBe(true);
    assertInvariant(d);
  });

  test("default-deny when no branch_protection rule matches", () => {
    const d = decideAutoMerge({ ...happyArgs(), rule: null });
    expect(d.merge).toBe(false);
    expect(d.blocking?.[0]).toMatch(/default-deny/i);
    assertInvariant(d);
  });

  test("default-deny when enableAutoMerge=false on the matching rule", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      rule: rule({ enableAutoMerge: false }),
    });
    expect(d.merge).toBe(false);
    expect(d.blocking?.some((r) => /auto-merge enabled/i.test(r))).toBe(true);
    assertInvariant(d);
  });

  test("blocks when PR is draft", () => {
    const d = decideAutoMerge({ ...happyArgs(), isDraft: true });
    expect(d.merge).toBe(false);
    expect(d.blocking?.some((r) => /draft/i.test(r))).toBe(true);
    assertInvariant(d);
  });

  test("blocks when AI approval required but missing", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      rule: rule({ requireAiApproval: true }),
      aiApproved: false,
    });
    expect(d.merge).toBe(false);
    expect(d.blocking?.some((r) => /AI approval/i.test(r))).toBe(true);
    assertInvariant(d);
  });

  test("blocks on failing hard gate (requireGreenGates)", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      rule: rule({ requireGreenGates: true }),
      hasFailedGates: true,
    });
    expect(d.merge).toBe(false);
    expect(d.blocking?.some((r) => /green gates/i.test(r))).toBe(true);
    assertInvariant(d);
  });

  test("blocks on missing required check", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      requiredCheckNames: ["lint", "test"],
      passingCheckNames: ["lint"],
      hasFailedGates: true, // K3 caller would set this consistently
    });
    expect(d.merge).toBe(false);
    expect(d.blocking?.some((r) => /test/i.test(r))).toBe(true);
    assertInvariant(d);
  });

  test("allows when all required checks pass", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      requiredCheckNames: ["lint", "test"],
      passingCheckNames: ["lint", "test"],
      hasFailedGates: false,
    });
    expect(d.merge).toBe(true);
    assertInvariant(d);
  });

  test("blocks when human review required and not present", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      rule: rule({ requireHumanReview: true }),
      humanApprovalCount: 0,
    });
    expect(d.merge).toBe(false);
    expect(d.blocking?.some((r) => /human review/i.test(r))).toBe(true);
    assertInvariant(d);
  });

  test("size cap blocks when over limit", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      diffStats: { files: 50, lines: 5000 },
      caps: { maxChangedFiles: 10, maxChangedLines: 1000 },
    });
    expect(d.merge).toBe(false);
    expect(d.blocking?.length).toBeGreaterThanOrEqual(2);
    assertInvariant(d);
  });

  test("size cap does not block when under limit", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      diffStats: { files: 3, lines: 80 },
      caps: { maxChangedFiles: 10, maxChangedLines: 1000 },
    });
    expect(d.merge).toBe(true);
    assertInvariant(d);
  });

  test("size cap skipped when no caps provided even with big diff", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      diffStats: { files: 9999, lines: 9999 },
    });
    expect(d.merge).toBe(true);
    assertInvariant(d);
  });

  test("accumulates multiple blocking reasons", () => {
    const d = decideAutoMerge({
      ...happyArgs(),
      rule: rule({
        requireAiApproval: true,
        requireGreenGates: true,
        requireHumanReview: true,
      }),
      isDraft: true,
      aiApproved: false,
      humanApprovalCount: 0,
      hasFailedGates: true,
    });
    expect(d.merge).toBe(false);
    expect(d.blocking?.length).toBeGreaterThanOrEqual(4);
    assertInvariant(d);
  });

  test("invariant: blocking list non-empty iff merge=false (random shapes)", () => {
    const shapes = [
      happyArgs(),
      { ...happyArgs(), rule: null },
      { ...happyArgs(), isDraft: true },
      { ...happyArgs(), rule: rule({ enableAutoMerge: false }) },
    ];
    for (const s of shapes) {
      assertInvariant(decideAutoMerge(s));
    }
  });
});

describe("aiCommentLooksApproved", () => {
  test("approves a clean AI summary", () => {
    const body =
      "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\n**AI review:** no blocking issues found.\n\nLooks good.";
    expect(aiCommentLooksApproved(body)).toBe(true);
  });

  test("rejects when API was unavailable", () => {
    const body =
      "<!-- gluecron-ai-review:summary -->\n## AI review unavailable\n\nThe AI review attempt failed: timeout.";
    expect(aiCommentLooksApproved(body)).toBe(false);
  });

  test("rejects on severity: blocking marker (case-insensitive)", () => {
    const body =
      "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\nFindings:\n- Severity: BLOCKING — auth bypass at line 42.";
    expect(aiCommentLooksApproved(body)).toBe(false);
  });

  test("rejects when AI flagged items for human attention", () => {
    const body =
      "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\n**AI review:** flagged 3 item(s) for human attention.";
    expect(aiCommentLooksApproved(body)).toBe(false);
  });

  test("rejects empty body", () => {
    expect(aiCommentLooksApproved("")).toBe(false);
  });
});