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.
| 4626e61 | 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 | ||
| 32 | import { and, eq } from "drizzle-orm"; | |
| 33 | import { db } from "../db"; | |
| 34 | import { branchProtection, prComments } from "../db/schema"; | |
| 35 | import type { BranchProtection } from "../db/schema"; | |
| 36 | import { | |
| 37 | countHumanApprovals, | |
| 38 | evaluateProtection, | |
| 39 | listRequiredChecks, | |
| 40 | matchProtection, | |
| 41 | passingCheckNames, | |
| 42 | } from "./branch-protection"; | |
| 43 | import { AI_REVIEW_MARKER } from "./ai-review"; | |
| 44 | import { audit } from "./notify"; | |
| 45 | import { getRepoPath } from "../git/repository"; | |
| 46 | ||
| 47 | // --------------------------------------------------------------------------- | |
| 48 | // Public types | |
| 49 | // --------------------------------------------------------------------------- | |
| 50 | ||
| 51 | export interface AutoMergeContext { | |
| 52 | pullRequestId: string; | |
| 53 | repositoryId: string; | |
| 54 | baseBranch: string; | |
| 55 | isDraft: boolean; | |
| 56 | authorUserId: string; | |
| 57 | } | |
| 58 | ||
| 59 | export interface AutoMergeDecision { | |
| 60 | merge: boolean; | |
| 61 | reason: string; | |
| 62 | blocking?: string[]; | |
| 63 | } | |
| 64 | ||
| 65 | export 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 | */ | |
| 90 | export 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 | */ | |
| 198 | export 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 | */ | |
| 217 | async 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 | */ | |
| 248 | async 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 | */ | |
| 283 | export 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 | */ | |
| 372 | export 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 | ||
| 394 | export const __test = { | |
| 395 | decideAutoMerge, | |
| 396 | aiCommentLooksApproved, | |
| 397 | diffStatsForBranches, | |
| 398 | aiApprovedForPr, | |
| 399 | }; |