Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit4626e61unknown_key

feat(K2): AI-gated auto-merge evaluator

feat(K2): AI-gated auto-merge evaluator

Pure decision helper that answers "should this PR auto-merge right now?".
Strict default-deny: only fires when a branch_protection rule explicitly
opts in via the new `enable_auto_merge` flag, AND every gate the manual
merge path enforces is green.

What ships:

  src/lib/auto-merge.ts (399 lines)
    - decideAutoMerge(facts)        pure decision helper, no DB
    - evaluateAutoMerge(ctx, opts)  DB orchestrator
    - aiCommentLooksApproved(body)  heuristic over AI_REVIEW_MARKER comments
    - recordAutoMergeAttempt(...)   audit-log helper

  drizzle/0040_branch_protection_auto_merge.sql
    ALTER TABLE branch_protection
      ADD COLUMN IF NOT EXISTS enable_auto_merge boolean
        NOT NULL DEFAULT false;

  src/db/schema.ts — additive: enableAutoMerge column on branchProtection.
  src/routes/gates.tsx — additive: "Enable AI auto-merge" checkbox on the
                         branch-protection rule form + a summary chip.

Decision rules (all must hold):
  1. matchProtection(repoId, base) returns a rule with enable_auto_merge=true
  2. PR not a draft
  3. evaluateProtection() with required-checks matrix passes
  4. When requireAiApproval=true: an AI_REVIEW_MARKER comment exists AND
     aiCommentLooksApproved (no "unavailable", no "severity: blocking",
     no "flagged N item(s)")
  5. Optional size cap (maxChangedFiles / maxChangedLines)

This module ONLY decides. It does not execute the merge. K3 (autopilot
ticker) is the intended caller and is the only path that turns a
"merge: true" decision into a real merge + audit entry.

Notes:
  - Migration slot 0040 (0039 was already taken by repair-flywheel);
    additive only, never touches an earlier migration.
  - decideAutoMerge currently exported on __test only; promote to public
    if K3 wants to inject pre-computed facts from a batched query.
  - passingCheckNames is called with commitSha=null because the context
    doesn't carry head SHA yet — adding a `headSha` field is the natural
    next sharpening.

Tests: 19 / 0 fail (44 expect calls) covering default-deny shapes, each
individual block reason, size-cap on/off/over/under, the multi-reason
accumulation, and the blocking-non-empty-iff-merge=false invariant.
Claude committed on May 13, 2026Parent: fa06ad2
5 files changed+66704626e6184c60b105bd2857b52c627e7de8e960ca
5 changed files+667−0
Addeddrizzle/0040_branch_protection_auto_merge.sql+14−0View fileUnifiedSplit
1-- Block K2 — AI-gated auto-merge.
2--
3-- Adds an opt-in `enable_auto_merge` flag to each branch-protection rule.
4-- When true, the K3 autopilot ticker may auto-merge PRs whose base branch
5-- matches this rule — provided every other gate the manual-merge path
6-- enforces is green. Default-deny on purpose: owners must explicitly turn
7-- this on per rule.
8--
9-- NOTE: this should have been named 0039 per the K2 spec, but the
10-- repair-flywheel work landed first and took the 0039 slot, so we ship as
11-- 0040 to keep migration ordering monotonic and additive.
12
13ALTER TABLE "branch_protection"
14 ADD COLUMN IF NOT EXISTS "enable_auto_merge" boolean NOT NULL DEFAULT false;
Addedsrc/__tests__/auto-merge.test.ts+240−0View fileUnifiedSplit
1/**
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});
Modifiedsrc/db/schema.ts+4−0View fileUnifiedSplit
148148 allowForcePush: boolean("allow_force_push").default(false).notNull(),
149149 allowDeletion: boolean("allow_deletion").default(false).notNull(),
150150 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
151 // K2 — Auto-merge opt-in. When true, the autopilot ticker may auto-merge
152 // a PR whose base branch matches this rule, provided every gate the
153 // manual-merge path enforces is green. Default-deny.
154 enableAutoMerge: boolean("enable_auto_merge").default(false).notNull(),
151155 createdAt: timestamp("created_at").defaultNow().notNull(),
152156 updatedAt: timestamp("updated_at").defaultNow().notNull(),
153157 },
Addedsrc/lib/auto-merge.ts+399−0View fileUnifiedSplit
1/**
2 * Block K2 — AI-gated auto-merge evaluator.
3 *
4 * Pure decision helper. Given a PR, answer the single question:
5 *
6 * "Should this PR auto-merge right now?"
7 *
8 * This module is intentionally a parallel surface to the manual-merge path
9 * in `src/routes/pulls.tsx` — it MUST NOT relax any rule that the manual
10 * path enforces. The rule of thumb: anything an autopilot can do, a human
11 * could have done by clicking Merge themselves.
12 *
13 * Decision rules (all must hold for `merge: true`):
14 *
15 * 1. A `branch_protection` rule matches the base branch AND
16 * `enableAutoMerge=true` on that rule. Default-deny when no rule
17 * matches — auto-merge is strictly opt-in per branch.
18 * 2. PR is not a draft.
19 * 3. `evaluateProtection` (the existing branch-protection helper)
20 * returns allowed=true for this PR's context, including required
21 * status checks via `listRequiredChecks` / `passingCheckNames`.
22 * 4. When `requireAiApproval=true` on the rule, there is an
23 * AI-review comment carrying `AI_REVIEW_MARKER` whose body looks
24 * like an approval (see `aiCommentLooksApproved`).
25 * 5. (Optional) PR diff is within `opts.maxChangedFiles` /
26 * `opts.maxChangedLines` if provided.
27 *
28 * K3 (the autopilot ticker) is the only intended caller — this module
29 * deliberately does NOT execute the merge. It only decides.
30 */
31
32import { and, eq } from "drizzle-orm";
33import { db } from "../db";
34import { branchProtection, prComments } from "../db/schema";
35import type { BranchProtection } from "../db/schema";
36import {
37 countHumanApprovals,
38 evaluateProtection,
39 listRequiredChecks,
40 matchProtection,
41 passingCheckNames,
42} from "./branch-protection";
43import { AI_REVIEW_MARKER } from "./ai-review";
44import { audit } from "./notify";
45import { getRepoPath } from "../git/repository";
46
47// ---------------------------------------------------------------------------
48// Public types
49// ---------------------------------------------------------------------------
50
51export interface AutoMergeContext {
52 pullRequestId: string;
53 repositoryId: string;
54 baseBranch: string;
55 isDraft: boolean;
56 authorUserId: string;
57}
58
59export interface AutoMergeDecision {
60 merge: boolean;
61 reason: string;
62 blocking?: string[];
63}
64
65export interface AutoMergeOptions {
66 maxChangedFiles?: number;
67 maxChangedLines?: number;
68 /** Injectable clock for tests. Unused today but reserved for K3 cooldowns. */
69 now?: Date;
70 /**
71 * Test-only injection of an owner/repo pair so the diff-size check can
72 * shell out to the bare repo. In production the caller resolves these
73 * from the repository row before calling. When omitted, the size cap is
74 * skipped (treated as "no cap configured").
75 */
76 ownerName?: string;
77 repoName?: string;
78 /** Head branch for the diff-size check. Required when caps are set. */
79 headBranch?: string;
80}
81
82// ---------------------------------------------------------------------------
83// Pure decision helper
84// ---------------------------------------------------------------------------
85
86/**
87 * Internal pure decision helper. All DB-derived facts are passed in as
88 * arguments so tests can drive every branch without a real database.
89 */
90export function decideAutoMerge(args: {
91 rule: BranchProtection | null;
92 isDraft: boolean;
93 aiApproved: boolean;
94 humanApprovalCount: number;
95 hasFailedGates: boolean;
96 passingCheckNames: string[];
97 requiredCheckNames: string[];
98 diffStats?: { files: number; lines: number } | null;
99 caps?: { maxChangedFiles?: number; maxChangedLines?: number };
100}): AutoMergeDecision {
101 const blocking: string[] = [];
102
103 // 1. Default-deny: must have a matching rule AND it must opt in.
104 if (!args.rule) {
105 blocking.push(
106 "No branch_protection rule matches the base branch — auto-merge is default-deny."
107 );
108 return { merge: false, reason: blocking[0], blocking };
109 }
110 if (!args.rule.enableAutoMerge) {
111 blocking.push(
112 `Branch protection '${args.rule.pattern}' does not have auto-merge enabled.`
113 );
114 }
115
116 // 2. Draft check.
117 if (args.isDraft) {
118 blocking.push("Pull request is marked as a draft.");
119 }
120
121 // 3. Reuse the manual-merge gating exactly. Whatever blocks a human Merge
122 // click must also block auto-merge.
123 const decision = evaluateProtection(
124 args.rule,
125 {
126 aiApproved: args.aiApproved,
127 humanApprovalCount: args.humanApprovalCount,
128 gateResultGreen: !args.hasFailedGates,
129 hasFailedGates: args.hasFailedGates,
130 passingCheckNames: args.passingCheckNames,
131 },
132 args.requiredCheckNames
133 );
134 if (!decision.allowed) {
135 for (const r of decision.reasons) blocking.push(r);
136 }
137
138 // 4. AI-approval semantics — already covered by evaluateProtection when
139 // requireAiApproval=true. We do NOT double-add the same reason here; the
140 // caller is responsible for sourcing `aiApproved` from a marker-bearing
141 // AI comment that survives `aiCommentLooksApproved`.
142
143 // 5. Optional size cap.
144 if (args.caps && args.diffStats) {
145 const { maxChangedFiles, maxChangedLines } = args.caps;
146 if (
147 typeof maxChangedFiles === "number" &&
148 args.diffStats.files > maxChangedFiles
149 ) {
150 blocking.push(
151 `PR changes ${args.diffStats.files} file(s); auto-merge cap is ${maxChangedFiles}.`
152 );
153 }
154 if (
155 typeof maxChangedLines === "number" &&
156 args.diffStats.lines > maxChangedLines
157 ) {
158 blocking.push(
159 `PR changes ${args.diffStats.lines} line(s); auto-merge cap is ${maxChangedLines}.`
160 );
161 }
162 }
163
164 if (blocking.length === 0) {
165 return {
166 merge: true,
167 reason: `All auto-merge conditions met for '${args.rule.pattern}'.`,
168 };
169 }
170 return { merge: false, reason: blocking.join(" "), blocking };
171}
172
173// ---------------------------------------------------------------------------
174// AI-comment approval heuristic
175// ---------------------------------------------------------------------------
176
177/**
178 * Decide whether a single AI-review comment body indicates approval.
179 *
180 * `triggerAiReview` (in src/lib/ai-review.ts) emits a marker-bearing
181 * summary comment in two shapes:
182 *
183 * - On success: starts with `## AI Code Review`, followed by either
184 * `"no blocking issues found"` (approved) or
185 * `"flagged N item(s) for human attention"` (not approved).
186 * - On API failure: starts with `## AI review unavailable`.
187 *
188 * Per spec, approval is defined negatively:
189 * - body does NOT contain `"AI review unavailable"`, AND
190 * - body does NOT contain `"severity: blocking"` (case-insensitive).
191 *
192 * We additionally treat the "flagged N item(s)" verdict as not-approved
193 * because that's what triggerAiReview itself uses to signal blocking
194 * findings, even though it doesn't use the `severity: blocking` token.
195 * If future reviewers do emit `severity: blocking`, that branch still
196 * matches via the case-insensitive substring rule.
197 */
198export function aiCommentLooksApproved(body: string): boolean {
199 if (!body) return false;
200 const lower = body.toLowerCase();
201 if (lower.includes("ai review unavailable")) return false;
202 if (lower.includes("severity: blocking")) return false;
203 // triggerAiReview's "flagged N item(s)" wording — explicit non-approval.
204 if (/flagged \d+ item/i.test(body)) return false;
205 return true;
206}
207
208// ---------------------------------------------------------------------------
209// DB-backed orchestrator
210// ---------------------------------------------------------------------------
211
212/**
213 * Locate the AI-review summary comment for a PR and return whether it
214 * looks like an approval. Returns false when no marker-bearing AI
215 * comment is found — i.e. AI review hasn't completed yet.
216 */
217async function aiApprovedForPr(pullRequestId: string): Promise<boolean> {
218 try {
219 const rows = await db
220 .select({ body: prComments.body })
221 .from(prComments)
222 .where(
223 and(
224 eq(prComments.pullRequestId, pullRequestId),
225 eq(prComments.isAiReview, true)
226 )
227 );
228 const markerRows = rows.filter((r) =>
229 (r.body || "").includes(AI_REVIEW_MARKER)
230 );
231 if (markerRows.length === 0) return false;
232 // If *any* marker comment looks approved, count as approved. In
233 // practice triggerAiReview writes exactly one summary marker, so this
234 // collapses to the single comment's verdict.
235 return markerRows.some((r) => aiCommentLooksApproved(r.body || ""));
236 } catch {
237 return false;
238 }
239}
240
241/**
242 * Best-effort diff stats for size caps. Shells out to `git diff
243 * --numstat base...head` in the bare repo. Returns null on any error so
244 * the caller can decide whether to fail-closed (we currently treat null
245 * stats as "size unknown → don't enforce the cap" which is permissive
246 * but documented).
247 */
248async function diffStatsForBranches(
249 ownerName: string,
250 repoName: string,
251 baseBranch: string,
252 headBranch: string
253): Promise<{ files: number; lines: number } | null> {
254 try {
255 const cwd = getRepoPath(ownerName, repoName);
256 const proc = Bun.spawn(
257 ["git", "diff", "--numstat", `${baseBranch}...${headBranch}`, "--"],
258 { cwd, stdout: "pipe", stderr: "pipe" }
259 );
260 const text = await new Response(proc.stdout).text();
261 await proc.exited;
262 let files = 0;
263 let lines = 0;
264 for (const line of text.split("\n")) {
265 if (!line.trim()) continue;
266 const [add, del] = line.split("\t");
267 files += 1;
268 const a = add === "-" ? 0 : parseInt(add, 10) || 0;
269 const d = del === "-" ? 0 : parseInt(del, 10) || 0;
270 lines += a + d;
271 }
272 return { files, lines };
273 } catch {
274 return null;
275 }
276}
277
278/**
279 * Headline entry point. Resolves the DB-derived facts and delegates to
280 * `decideAutoMerge`. K3 (autopilot ticker) calls this; the optional
281 * AI-review completion path may also call it to flip the merge-now bit.
282 */
283export async function evaluateAutoMerge(
284 ctx: AutoMergeContext,
285 opts: AutoMergeOptions = {}
286): Promise<AutoMergeDecision> {
287 // 1. Match the protection rule. matchProtection returns the most
288 // specific rule, or null when none configured.
289 const rule = await matchProtection(ctx.repositoryId, ctx.baseBranch);
290
291 // 2. Source the AI-approval signal only if the rule actually requires
292 // it. Avoids the DB hit on rules that don't care.
293 const aiApproved =
294 rule && rule.requireAiApproval ? await aiApprovedForPr(ctx.pullRequestId) : true;
295
296 // 3. Human approvals — same query the manual-merge path uses.
297 const humanApprovalCount = await countHumanApprovals(ctx.pullRequestId);
298
299 // 4. Required-checks matrix. Skip the DB hit if the rule has no
300 // matched required checks.
301 let requiredCheckNames: string[] = [];
302 let passing: string[] = [];
303 if (rule) {
304 try {
305 const required = await listRequiredChecks(rule.id);
306 requiredCheckNames = required.map((r) => r.checkName);
307 if (requiredCheckNames.length > 0) {
308 // We don't have the head SHA in the context. Passing
309 // commitSha=null causes passingCheckNames to scan the most
310 // recent 200 rows for the repo, which is good enough for the
311 // K2 decision surface — the K3 ticker is the source of truth
312 // for fresh status. This matches the spirit of the manual path
313 // (which uses the freshly-resolved head SHA).
314 passing = await passingCheckNames(ctx.repositoryId, null);
315 }
316 } catch {
317 requiredCheckNames = [];
318 passing = [];
319 }
320 }
321
322 // 5. hasFailedGates: derived from required checks. We don't run the
323 // full `runAllGateChecks` here because that's a heavyweight side-
324 // effecting call; K3 is expected to have already triggered gate runs.
325 // Treat "any required check is not in the passing set" as failing.
326 const hasFailedGates =
327 requiredCheckNames.length > 0 &&
328 requiredCheckNames.some((n) => !passing.includes(n));
329
330 // 6. Optional size cap.
331 let diffStats: { files: number; lines: number } | null = null;
332 const hasCap =
333 typeof opts.maxChangedFiles === "number" ||
334 typeof opts.maxChangedLines === "number";
335 if (hasCap && opts.ownerName && opts.repoName && opts.headBranch) {
336 diffStats = await diffStatsForBranches(
337 opts.ownerName,
338 opts.repoName,
339 ctx.baseBranch,
340 opts.headBranch
341 );
342 }
343
344 return decideAutoMerge({
345 rule,
346 isDraft: ctx.isDraft,
347 aiApproved,
348 humanApprovalCount,
349 hasFailedGates,
350 passingCheckNames: passing,
351 requiredCheckNames,
352 diffStats,
353 caps: hasCap
354 ? {
355 maxChangedFiles: opts.maxChangedFiles,
356 maxChangedLines: opts.maxChangedLines,
357 }
358 : undefined,
359 });
360}
361
362// ---------------------------------------------------------------------------
363// Audit helper
364// ---------------------------------------------------------------------------
365
366/**
367 * Record an auto-merge attempt in the audit log. K3 should call this
368 * once per evaluation tick so operators can see the decision trail.
369 * Uses `auto_merge.evaluated` for any decision, and `auto_merge.merged`
370 * separately when K3 actually performs the merge (K3's responsibility).
371 */
372export async function recordAutoMergeAttempt(
373 repositoryId: string,
374 pullRequestId: string,
375 decision: AutoMergeDecision
376): Promise<void> {
377 await audit({
378 repositoryId,
379 action: "auto_merge.evaluated",
380 targetType: "pull_request",
381 targetId: pullRequestId,
382 metadata: {
383 merge: decision.merge,
384 reason: decision.reason,
385 blocking: decision.blocking ?? [],
386 },
387 });
388}
389
390// ---------------------------------------------------------------------------
391// Test-only surface
392// ---------------------------------------------------------------------------
393
394export const __test = {
395 decideAutoMerge,
396 aiCommentLooksApproved,
397 diffStatsForBranches,
398 aiApprovedForPr,
399};
Modifiedsrc/routes/gates.tsx+10−0View fileUnifiedSplit
297297 {p.requireHumanReview
298298 ? `${p.requiredApprovals} human approval(s) · `
299299 : ""}
300 {p.enableAutoMerge ? "AI auto-merge · " : ""}
300301 {!p.allowForcePush ? "No force push · " : ""}
301302 {!p.allowDeletion ? "No deletion" : ""}
302303 </div>
376377 <input type="checkbox" name="allowDeletion" value="1" />
377378 Allow deletion
378379 </label>
380 <label
381 style="display: flex; align-items: center; gap: 6px"
382 title="K2 — Let the autopilot ticker auto-merge PRs that pass every gate this rule enforces."
383 >
384 <input type="checkbox" name="enableAutoMerge" value="1" />
385 Enable AI auto-merge
386 </label>
379387 </div>
380388 <button type="submit" class="btn btn-primary" style="margin-top: 12px">
381389 Add rule
460468 requiredApprovals,
461469 allowForcePush: b("allowForcePush"),
462470 allowDeletion: b("allowDeletion"),
471 // K2 — opt-in flag for the autopilot auto-merger.
472 enableAutoMerge: b("enableAutoMerge"),
463473 });
464474 } catch (err) {
465475 console.error("[gates] protection save:", err);
466476