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.tsBlame426 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";
534f04aClaude46import { getLatestCachedPrRisk } from "./pr-risk";
4626e61Claude47
48// ---------------------------------------------------------------------------
49// Public types
50// ---------------------------------------------------------------------------
51
52export interface AutoMergeContext {
53 pullRequestId: string;
54 repositoryId: string;
55 baseBranch: string;
56 isDraft: boolean;
57 authorUserId: string;
58}
59
60export interface AutoMergeDecision {
61 merge: boolean;
62 reason: string;
63 blocking?: string[];
64}
65
66export interface AutoMergeOptions {
67 maxChangedFiles?: number;
68 maxChangedLines?: number;
69 /** Injectable clock for tests. Unused today but reserved for K3 cooldowns. */
70 now?: Date;
71 /**
72 * Test-only injection of an owner/repo pair so the diff-size check can
73 * shell out to the bare repo. In production the caller resolves these
74 * from the repository row before calling. When omitted, the size cap is
75 * skipped (treated as "no cap configured").
76 */
77 ownerName?: string;
78 repoName?: string;
79 /** Head branch for the diff-size check. Required when caps are set. */
80 headBranch?: string;
81}
82
83// ---------------------------------------------------------------------------
84// Pure decision helper
85// ---------------------------------------------------------------------------
86
87/**
88 * Internal pure decision helper. All DB-derived facts are passed in as
89 * arguments so tests can drive every branch without a real database.
534f04aClaude90 *
91 * Block M3 — when `risk` is provided AND its band is `critical`, the
92 * auto-merge is blocked with a clear reason. Lower bands never block
93 * auto-merge; the score is informational on the manual path only.
4626e61Claude94 */
95export function decideAutoMerge(args: {
96 rule: BranchProtection | null;
97 isDraft: boolean;
98 aiApproved: boolean;
99 humanApprovalCount: number;
100 hasFailedGates: boolean;
101 passingCheckNames: string[];
102 requiredCheckNames: string[];
103 diffStats?: { files: number; lines: number } | null;
104 caps?: { maxChangedFiles?: number; maxChangedLines?: number };
534f04aClaude105 risk?: { score: number; band: "low" | "medium" | "high" | "critical" } | null;
4626e61Claude106}): AutoMergeDecision {
107 const blocking: string[] = [];
108
109 // 1. Default-deny: must have a matching rule AND it must opt in.
110 if (!args.rule) {
111 blocking.push(
112 "No branch_protection rule matches the base branch — auto-merge is default-deny."
113 );
114 return { merge: false, reason: blocking[0], blocking };
115 }
116 if (!args.rule.enableAutoMerge) {
117 blocking.push(
118 `Branch protection '${args.rule.pattern}' does not have auto-merge enabled.`
119 );
120 }
121
122 // 2. Draft check.
123 if (args.isDraft) {
124 blocking.push("Pull request is marked as a draft.");
125 }
126
127 // 3. Reuse the manual-merge gating exactly. Whatever blocks a human Merge
128 // click must also block auto-merge.
129 const decision = evaluateProtection(
130 args.rule,
131 {
132 aiApproved: args.aiApproved,
133 humanApprovalCount: args.humanApprovalCount,
134 gateResultGreen: !args.hasFailedGates,
135 hasFailedGates: args.hasFailedGates,
136 passingCheckNames: args.passingCheckNames,
137 },
138 args.requiredCheckNames
139 );
140 if (!decision.allowed) {
141 for (const r of decision.reasons) blocking.push(r);
142 }
143
144 // 4. AI-approval semantics — already covered by evaluateProtection when
145 // requireAiApproval=true. We do NOT double-add the same reason here; the
146 // caller is responsible for sourcing `aiApproved` from a marker-bearing
147 // AI comment that survives `aiCommentLooksApproved`.
148
534f04aClaude149 // M3. Pre-merge risk score — `critical` blocks auto-merge regardless
150 // of every other gate being green. Lower bands are informational only.
151 if (args.risk && args.risk.band === "critical") {
152 blocking.push(`PR risk score is critical (${args.risk.score}/10)`);
153 }
154
4626e61Claude155 // 5. Optional size cap.
156 if (args.caps && args.diffStats) {
157 const { maxChangedFiles, maxChangedLines } = args.caps;
158 if (
159 typeof maxChangedFiles === "number" &&
160 args.diffStats.files > maxChangedFiles
161 ) {
162 blocking.push(
163 `PR changes ${args.diffStats.files} file(s); auto-merge cap is ${maxChangedFiles}.`
164 );
165 }
166 if (
167 typeof maxChangedLines === "number" &&
168 args.diffStats.lines > maxChangedLines
169 ) {
170 blocking.push(
171 `PR changes ${args.diffStats.lines} line(s); auto-merge cap is ${maxChangedLines}.`
172 );
173 }
174 }
175
176 if (blocking.length === 0) {
177 return {
178 merge: true,
179 reason: `All auto-merge conditions met for '${args.rule.pattern}'.`,
180 };
181 }
182 return { merge: false, reason: blocking.join(" "), blocking };
183}
184
185// ---------------------------------------------------------------------------
186// AI-comment approval heuristic
187// ---------------------------------------------------------------------------
188
189/**
190 * Decide whether a single AI-review comment body indicates approval.
191 *
192 * `triggerAiReview` (in src/lib/ai-review.ts) emits a marker-bearing
193 * summary comment in two shapes:
194 *
195 * - On success: starts with `## AI Code Review`, followed by either
196 * `"no blocking issues found"` (approved) or
197 * `"flagged N item(s) for human attention"` (not approved).
198 * - On API failure: starts with `## AI review unavailable`.
199 *
200 * Per spec, approval is defined negatively:
201 * - body does NOT contain `"AI review unavailable"`, AND
202 * - body does NOT contain `"severity: blocking"` (case-insensitive).
203 *
204 * We additionally treat the "flagged N item(s)" verdict as not-approved
205 * because that's what triggerAiReview itself uses to signal blocking
206 * findings, even though it doesn't use the `severity: blocking` token.
207 * If future reviewers do emit `severity: blocking`, that branch still
208 * matches via the case-insensitive substring rule.
209 */
210export function aiCommentLooksApproved(body: string): boolean {
211 if (!body) return false;
212 const lower = body.toLowerCase();
213 if (lower.includes("ai review unavailable")) return false;
214 if (lower.includes("severity: blocking")) return false;
215 // triggerAiReview's "flagged N item(s)" wording — explicit non-approval.
216 if (/flagged \d+ item/i.test(body)) return false;
217 return true;
218}
219
220// ---------------------------------------------------------------------------
221// DB-backed orchestrator
222// ---------------------------------------------------------------------------
223
224/**
225 * Locate the AI-review summary comment for a PR and return whether it
226 * looks like an approval. Returns false when no marker-bearing AI
227 * comment is found — i.e. AI review hasn't completed yet.
228 */
229async function aiApprovedForPr(pullRequestId: string): Promise<boolean> {
230 try {
231 const rows = await db
232 .select({ body: prComments.body })
233 .from(prComments)
234 .where(
235 and(
236 eq(prComments.pullRequestId, pullRequestId),
237 eq(prComments.isAiReview, true)
238 )
239 );
240 const markerRows = rows.filter((r) =>
241 (r.body || "").includes(AI_REVIEW_MARKER)
242 );
243 if (markerRows.length === 0) return false;
244 // If *any* marker comment looks approved, count as approved. In
245 // practice triggerAiReview writes exactly one summary marker, so this
246 // collapses to the single comment's verdict.
247 return markerRows.some((r) => aiCommentLooksApproved(r.body || ""));
248 } catch {
249 return false;
250 }
251}
252
253/**
254 * Best-effort diff stats for size caps. Shells out to `git diff
255 * --numstat base...head` in the bare repo. Returns null on any error so
256 * the caller can decide whether to fail-closed (we currently treat null
257 * stats as "size unknown → don't enforce the cap" which is permissive
258 * but documented).
259 */
260async function diffStatsForBranches(
261 ownerName: string,
262 repoName: string,
263 baseBranch: string,
264 headBranch: string
265): Promise<{ files: number; lines: number } | null> {
266 try {
267 const cwd = getRepoPath(ownerName, repoName);
268 const proc = Bun.spawn(
269 ["git", "diff", "--numstat", `${baseBranch}...${headBranch}`, "--"],
270 { cwd, stdout: "pipe", stderr: "pipe" }
271 );
272 const text = await new Response(proc.stdout).text();
273 await proc.exited;
274 let files = 0;
275 let lines = 0;
276 for (const line of text.split("\n")) {
277 if (!line.trim()) continue;
278 const [add, del] = line.split("\t");
279 files += 1;
280 const a = add === "-" ? 0 : parseInt(add, 10) || 0;
281 const d = del === "-" ? 0 : parseInt(del, 10) || 0;
282 lines += a + d;
283 }
284 return { files, lines };
285 } catch {
286 return null;
287 }
288}
289
290/**
291 * Headline entry point. Resolves the DB-derived facts and delegates to
292 * `decideAutoMerge`. K3 (autopilot ticker) calls this; the optional
293 * AI-review completion path may also call it to flip the merge-now bit.
294 */
295export async function evaluateAutoMerge(
296 ctx: AutoMergeContext,
297 opts: AutoMergeOptions = {}
298): Promise<AutoMergeDecision> {
299 // 1. Match the protection rule. matchProtection returns the most
300 // specific rule, or null when none configured.
301 const rule = await matchProtection(ctx.repositoryId, ctx.baseBranch);
302
303 // 2. Source the AI-approval signal only if the rule actually requires
304 // it. Avoids the DB hit on rules that don't care.
305 const aiApproved =
306 rule && rule.requireAiApproval ? await aiApprovedForPr(ctx.pullRequestId) : true;
307
308 // 3. Human approvals — same query the manual-merge path uses.
309 const humanApprovalCount = await countHumanApprovals(ctx.pullRequestId);
310
311 // 4. Required-checks matrix. Skip the DB hit if the rule has no
312 // matched required checks.
313 let requiredCheckNames: string[] = [];
314 let passing: string[] = [];
315 if (rule) {
316 try {
317 const required = await listRequiredChecks(rule.id);
318 requiredCheckNames = required.map((r) => r.checkName);
319 if (requiredCheckNames.length > 0) {
320 // We don't have the head SHA in the context. Passing
321 // commitSha=null causes passingCheckNames to scan the most
322 // recent 200 rows for the repo, which is good enough for the
323 // K2 decision surface — the K3 ticker is the source of truth
324 // for fresh status. This matches the spirit of the manual path
325 // (which uses the freshly-resolved head SHA).
326 passing = await passingCheckNames(ctx.repositoryId, null);
327 }
328 } catch {
329 requiredCheckNames = [];
330 passing = [];
331 }
332 }
333
334 // 5. hasFailedGates: derived from required checks. We don't run the
335 // full `runAllGateChecks` here because that's a heavyweight side-
336 // effecting call; K3 is expected to have already triggered gate runs.
337 // Treat "any required check is not in the passing set" as failing.
338 const hasFailedGates =
339 requiredCheckNames.length > 0 &&
340 requiredCheckNames.some((n) => !passing.includes(n));
341
342 // 6. Optional size cap.
343 let diffStats: { files: number; lines: number } | null = null;
344 const hasCap =
345 typeof opts.maxChangedFiles === "number" ||
346 typeof opts.maxChangedLines === "number";
347 if (hasCap && opts.ownerName && opts.repoName && opts.headBranch) {
348 diffStats = await diffStatsForBranches(
349 opts.ownerName,
350 opts.repoName,
351 ctx.baseBranch,
352 opts.headBranch
353 );
354 }
355
534f04aClaude356 // M3 — best-effort cached risk lookup. Never throws; missing risk
357 // simply means the decision falls through to the existing gates.
358 let riskForDecision:
359 | { score: number; band: "low" | "medium" | "high" | "critical" }
360 | null = null;
361 try {
362 const cached = await getLatestCachedPrRisk(ctx.pullRequestId);
363 if (cached) {
364 riskForDecision = { score: cached.score, band: cached.band };
365 }
366 } catch {
367 riskForDecision = null;
368 }
369
4626e61Claude370 return decideAutoMerge({
371 rule,
372 isDraft: ctx.isDraft,
373 aiApproved,
374 humanApprovalCount,
375 hasFailedGates,
376 passingCheckNames: passing,
377 requiredCheckNames,
378 diffStats,
379 caps: hasCap
380 ? {
381 maxChangedFiles: opts.maxChangedFiles,
382 maxChangedLines: opts.maxChangedLines,
383 }
384 : undefined,
534f04aClaude385 risk: riskForDecision,
4626e61Claude386 });
387}
388
389// ---------------------------------------------------------------------------
390// Audit helper
391// ---------------------------------------------------------------------------
392
393/**
394 * Record an auto-merge attempt in the audit log. K3 should call this
395 * once per evaluation tick so operators can see the decision trail.
396 * Uses `auto_merge.evaluated` for any decision, and `auto_merge.merged`
397 * separately when K3 actually performs the merge (K3's responsibility).
398 */
399export async function recordAutoMergeAttempt(
400 repositoryId: string,
401 pullRequestId: string,
402 decision: AutoMergeDecision
403): Promise<void> {
404 await audit({
405 repositoryId,
406 action: "auto_merge.evaluated",
407 targetType: "pull_request",
408 targetId: pullRequestId,
409 metadata: {
410 merge: decision.merge,
411 reason: decision.reason,
412 blocking: decision.blocking ?? [],
413 },
414 });
415}
416
417// ---------------------------------------------------------------------------
418// Test-only surface
419// ---------------------------------------------------------------------------
420
421export const __test = {
422 decideAutoMerge,
423 aiCommentLooksApproved,
424 diffStatsForBranches,
425 aiApprovedForPr,
426};