Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

auto-merge.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.tsBlame399 lines · 1 contributor
4626e61Claude1/**
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};