CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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"; | |
| 9dd96b9 | 34 | import { |
| 35 | branchProtection, | |
| 36 | prComments, | |
| 37 | pullRequests, | |
| 38 | repositories, | |
| 39 | users, | |
| 40 | } from "../db/schema"; | |
| 4626e61 | 41 | import type { BranchProtection } from "../db/schema"; |
| 42 | import { | |
| 43 | countHumanApprovals, | |
| 44 | evaluateProtection, | |
| 45 | listRequiredChecks, | |
| 46 | matchProtection, | |
| 47 | passingCheckNames, | |
| 48 | } from "./branch-protection"; | |
| 49 | import { AI_REVIEW_MARKER } from "./ai-review"; | |
| 50 | import { audit } from "./notify"; | |
| 51 | import { getRepoPath } from "../git/repository"; | |
| 534f04a | 52 | import { getLatestCachedPrRisk } from "./pr-risk"; |
| 9dd96b9 | 53 | import { performMerge, type PerformMergeResult } from "./pr-merge"; |
| 4626e61 | 54 | |
| 55 | // --------------------------------------------------------------------------- | |
| 56 | // Public types | |
| 57 | // --------------------------------------------------------------------------- | |
| 58 | ||
| 59 | export interface AutoMergeContext { | |
| 60 | pullRequestId: string; | |
| 61 | repositoryId: string; | |
| 62 | baseBranch: string; | |
| 63 | isDraft: boolean; | |
| 64 | authorUserId: string; | |
| 65 | } | |
| 66 | ||
| 67 | export interface AutoMergeDecision { | |
| 68 | merge: boolean; | |
| 69 | reason: string; | |
| 70 | blocking?: string[]; | |
| 71 | } | |
| 72 | ||
| 73 | export interface AutoMergeOptions { | |
| 74 | maxChangedFiles?: number; | |
| 75 | maxChangedLines?: number; | |
| 76 | /** Injectable clock for tests. Unused today but reserved for K3 cooldowns. */ | |
| 77 | now?: Date; | |
| 78 | /** | |
| 79 | * Test-only injection of an owner/repo pair so the diff-size check can | |
| 80 | * shell out to the bare repo. In production the caller resolves these | |
| 81 | * from the repository row before calling. When omitted, the size cap is | |
| 82 | * skipped (treated as "no cap configured"). | |
| 83 | */ | |
| 84 | ownerName?: string; | |
| 85 | repoName?: string; | |
| 86 | /** Head branch for the diff-size check. Required when caps are set. */ | |
| 87 | headBranch?: string; | |
| 88 | } | |
| 89 | ||
| 90 | // --------------------------------------------------------------------------- | |
| 91 | // Pure decision helper | |
| 92 | // --------------------------------------------------------------------------- | |
| 93 | ||
| 94 | /** | |
| 95 | * Internal pure decision helper. All DB-derived facts are passed in as | |
| 96 | * arguments so tests can drive every branch without a real database. | |
| 534f04a | 97 | * |
| 98 | * Block M3 — when `risk` is provided AND its band is `critical`, the | |
| 99 | * auto-merge is blocked with a clear reason. Lower bands never block | |
| 100 | * auto-merge; the score is informational on the manual path only. | |
| 4626e61 | 101 | */ |
| 102 | export function decideAutoMerge(args: { | |
| 103 | rule: BranchProtection | null; | |
| 104 | isDraft: boolean; | |
| 105 | aiApproved: boolean; | |
| 106 | humanApprovalCount: number; | |
| 107 | hasFailedGates: boolean; | |
| 108 | passingCheckNames: string[]; | |
| 109 | requiredCheckNames: string[]; | |
| 110 | diffStats?: { files: number; lines: number } | null; | |
| 111 | caps?: { maxChangedFiles?: number; maxChangedLines?: number }; | |
| 534f04a | 112 | risk?: { score: number; band: "low" | "medium" | "high" | "critical" } | null; |
| 4626e61 | 113 | }): AutoMergeDecision { |
| 114 | const blocking: string[] = []; | |
| 115 | ||
| 116 | // 1. Default-deny: must have a matching rule AND it must opt in. | |
| 117 | if (!args.rule) { | |
| 118 | blocking.push( | |
| 119 | "No branch_protection rule matches the base branch — auto-merge is default-deny." | |
| 120 | ); | |
| 121 | return { merge: false, reason: blocking[0], blocking }; | |
| 122 | } | |
| 123 | if (!args.rule.enableAutoMerge) { | |
| 124 | blocking.push( | |
| 125 | `Branch protection '${args.rule.pattern}' does not have auto-merge enabled.` | |
| 126 | ); | |
| 127 | } | |
| 128 | ||
| 129 | // 2. Draft check. | |
| 130 | if (args.isDraft) { | |
| 131 | blocking.push("Pull request is marked as a draft."); | |
| 132 | } | |
| 133 | ||
| 134 | // 3. Reuse the manual-merge gating exactly. Whatever blocks a human Merge | |
| 135 | // click must also block auto-merge. | |
| 136 | const decision = evaluateProtection( | |
| 137 | args.rule, | |
| 138 | { | |
| 139 | aiApproved: args.aiApproved, | |
| 140 | humanApprovalCount: args.humanApprovalCount, | |
| 141 | gateResultGreen: !args.hasFailedGates, | |
| 142 | hasFailedGates: args.hasFailedGates, | |
| 143 | passingCheckNames: args.passingCheckNames, | |
| 144 | }, | |
| 145 | args.requiredCheckNames | |
| 146 | ); | |
| 147 | if (!decision.allowed) { | |
| 148 | for (const r of decision.reasons) blocking.push(r); | |
| 149 | } | |
| 150 | ||
| 151 | // 4. AI-approval semantics — already covered by evaluateProtection when | |
| 152 | // requireAiApproval=true. We do NOT double-add the same reason here; the | |
| 153 | // caller is responsible for sourcing `aiApproved` from a marker-bearing | |
| 154 | // AI comment that survives `aiCommentLooksApproved`. | |
| 155 | ||
| 534f04a | 156 | // M3. Pre-merge risk score — `critical` blocks auto-merge regardless |
| 157 | // of every other gate being green. Lower bands are informational only. | |
| 158 | if (args.risk && args.risk.band === "critical") { | |
| 159 | blocking.push(`PR risk score is critical (${args.risk.score}/10)`); | |
| 160 | } | |
| 161 | ||
| 4626e61 | 162 | // 5. Optional size cap. |
| 163 | if (args.caps && args.diffStats) { | |
| 164 | const { maxChangedFiles, maxChangedLines } = args.caps; | |
| 165 | if ( | |
| 166 | typeof maxChangedFiles === "number" && | |
| 167 | args.diffStats.files > maxChangedFiles | |
| 168 | ) { | |
| 169 | blocking.push( | |
| 170 | `PR changes ${args.diffStats.files} file(s); auto-merge cap is ${maxChangedFiles}.` | |
| 171 | ); | |
| 172 | } | |
| 173 | if ( | |
| 174 | typeof maxChangedLines === "number" && | |
| 175 | args.diffStats.lines > maxChangedLines | |
| 176 | ) { | |
| 177 | blocking.push( | |
| 178 | `PR changes ${args.diffStats.lines} line(s); auto-merge cap is ${maxChangedLines}.` | |
| 179 | ); | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | if (blocking.length === 0) { | |
| 184 | return { | |
| 185 | merge: true, | |
| 186 | reason: `All auto-merge conditions met for '${args.rule.pattern}'.`, | |
| 187 | }; | |
| 188 | } | |
| 189 | return { merge: false, reason: blocking.join(" "), blocking }; | |
| 190 | } | |
| 191 | ||
| 192 | // --------------------------------------------------------------------------- | |
| 193 | // AI-comment approval heuristic | |
| 194 | // --------------------------------------------------------------------------- | |
| 195 | ||
| 196 | /** | |
| 197 | * Decide whether a single AI-review comment body indicates approval. | |
| 198 | * | |
| 199 | * `triggerAiReview` (in src/lib/ai-review.ts) emits a marker-bearing | |
| 200 | * summary comment in two shapes: | |
| 201 | * | |
| 202 | * - On success: starts with `## AI Code Review`, followed by either | |
| 203 | * `"no blocking issues found"` (approved) or | |
| 204 | * `"flagged N item(s) for human attention"` (not approved). | |
| 205 | * - On API failure: starts with `## AI review unavailable`. | |
| 206 | * | |
| 207 | * Per spec, approval is defined negatively: | |
| 208 | * - body does NOT contain `"AI review unavailable"`, AND | |
| 209 | * - body does NOT contain `"severity: blocking"` (case-insensitive). | |
| 210 | * | |
| 211 | * We additionally treat the "flagged N item(s)" verdict as not-approved | |
| 212 | * because that's what triggerAiReview itself uses to signal blocking | |
| 213 | * findings, even though it doesn't use the `severity: blocking` token. | |
| 214 | * If future reviewers do emit `severity: blocking`, that branch still | |
| 215 | * matches via the case-insensitive substring rule. | |
| 216 | */ | |
| 217 | export function aiCommentLooksApproved(body: string): boolean { | |
| 218 | if (!body) return false; | |
| 219 | const lower = body.toLowerCase(); | |
| 220 | if (lower.includes("ai review unavailable")) return false; | |
| 221 | if (lower.includes("severity: blocking")) return false; | |
| 222 | // triggerAiReview's "flagged N item(s)" wording — explicit non-approval. | |
| 223 | if (/flagged \d+ item/i.test(body)) return false; | |
| 224 | return true; | |
| 225 | } | |
| 226 | ||
| 227 | // --------------------------------------------------------------------------- | |
| 228 | // DB-backed orchestrator | |
| 229 | // --------------------------------------------------------------------------- | |
| 230 | ||
| 231 | /** | |
| 232 | * Locate the AI-review summary comment for a PR and return whether it | |
| 233 | * looks like an approval. Returns false when no marker-bearing AI | |
| 234 | * comment is found — i.e. AI review hasn't completed yet. | |
| 235 | */ | |
| 236 | async function aiApprovedForPr(pullRequestId: string): Promise<boolean> { | |
| 237 | try { | |
| 238 | const rows = await db | |
| 239 | .select({ body: prComments.body }) | |
| 240 | .from(prComments) | |
| 241 | .where( | |
| 242 | and( | |
| 243 | eq(prComments.pullRequestId, pullRequestId), | |
| 244 | eq(prComments.isAiReview, true) | |
| 245 | ) | |
| 246 | ); | |
| 247 | const markerRows = rows.filter((r) => | |
| 248 | (r.body || "").includes(AI_REVIEW_MARKER) | |
| 249 | ); | |
| 250 | if (markerRows.length === 0) return false; | |
| 251 | // If *any* marker comment looks approved, count as approved. In | |
| 252 | // practice triggerAiReview writes exactly one summary marker, so this | |
| 253 | // collapses to the single comment's verdict. | |
| 254 | return markerRows.some((r) => aiCommentLooksApproved(r.body || "")); | |
| 255 | } catch { | |
| 256 | return false; | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Best-effort diff stats for size caps. Shells out to `git diff | |
| 262 | * --numstat base...head` in the bare repo. Returns null on any error so | |
| 263 | * the caller can decide whether to fail-closed (we currently treat null | |
| 264 | * stats as "size unknown → don't enforce the cap" which is permissive | |
| 265 | * but documented). | |
| 266 | */ | |
| 267 | async function diffStatsForBranches( | |
| 268 | ownerName: string, | |
| 269 | repoName: string, | |
| 270 | baseBranch: string, | |
| 271 | headBranch: string | |
| 272 | ): Promise<{ files: number; lines: number } | null> { | |
| 273 | try { | |
| 274 | const cwd = getRepoPath(ownerName, repoName); | |
| 275 | const proc = Bun.spawn( | |
| 276 | ["git", "diff", "--numstat", `${baseBranch}...${headBranch}`, "--"], | |
| 277 | { cwd, stdout: "pipe", stderr: "pipe" } | |
| 278 | ); | |
| 279 | const text = await new Response(proc.stdout).text(); | |
| 280 | await proc.exited; | |
| 281 | let files = 0; | |
| 282 | let lines = 0; | |
| 283 | for (const line of text.split("\n")) { | |
| 284 | if (!line.trim()) continue; | |
| 285 | const [add, del] = line.split("\t"); | |
| 286 | files += 1; | |
| 287 | const a = add === "-" ? 0 : parseInt(add, 10) || 0; | |
| 288 | const d = del === "-" ? 0 : parseInt(del, 10) || 0; | |
| 289 | lines += a + d; | |
| 290 | } | |
| 291 | return { files, lines }; | |
| 292 | } catch { | |
| 293 | return null; | |
| 294 | } | |
| 295 | } | |
| 296 | ||
| 297 | /** | |
| 298 | * Headline entry point. Resolves the DB-derived facts and delegates to | |
| 299 | * `decideAutoMerge`. K3 (autopilot ticker) calls this; the optional | |
| 300 | * AI-review completion path may also call it to flip the merge-now bit. | |
| 301 | */ | |
| 302 | export async function evaluateAutoMerge( | |
| 303 | ctx: AutoMergeContext, | |
| 304 | opts: AutoMergeOptions = {} | |
| 305 | ): Promise<AutoMergeDecision> { | |
| 306 | // 1. Match the protection rule. matchProtection returns the most | |
| 307 | // specific rule, or null when none configured. | |
| 308 | const rule = await matchProtection(ctx.repositoryId, ctx.baseBranch); | |
| 309 | ||
| 310 | // 2. Source the AI-approval signal only if the rule actually requires | |
| 311 | // it. Avoids the DB hit on rules that don't care. | |
| 312 | const aiApproved = | |
| 313 | rule && rule.requireAiApproval ? await aiApprovedForPr(ctx.pullRequestId) : true; | |
| 314 | ||
| 315 | // 3. Human approvals — same query the manual-merge path uses. | |
| 316 | const humanApprovalCount = await countHumanApprovals(ctx.pullRequestId); | |
| 317 | ||
| 318 | // 4. Required-checks matrix. Skip the DB hit if the rule has no | |
| 319 | // matched required checks. | |
| 320 | let requiredCheckNames: string[] = []; | |
| 321 | let passing: string[] = []; | |
| 322 | if (rule) { | |
| 323 | try { | |
| 324 | const required = await listRequiredChecks(rule.id); | |
| 325 | requiredCheckNames = required.map((r) => r.checkName); | |
| 326 | if (requiredCheckNames.length > 0) { | |
| 327 | // We don't have the head SHA in the context. Passing | |
| 328 | // commitSha=null causes passingCheckNames to scan the most | |
| 329 | // recent 200 rows for the repo, which is good enough for the | |
| 330 | // K2 decision surface — the K3 ticker is the source of truth | |
| 331 | // for fresh status. This matches the spirit of the manual path | |
| 332 | // (which uses the freshly-resolved head SHA). | |
| 333 | passing = await passingCheckNames(ctx.repositoryId, null); | |
| 334 | } | |
| 335 | } catch { | |
| 336 | requiredCheckNames = []; | |
| 337 | passing = []; | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | // 5. hasFailedGates: derived from required checks. We don't run the | |
| 342 | // full `runAllGateChecks` here because that's a heavyweight side- | |
| 343 | // effecting call; K3 is expected to have already triggered gate runs. | |
| 344 | // Treat "any required check is not in the passing set" as failing. | |
| 345 | const hasFailedGates = | |
| 346 | requiredCheckNames.length > 0 && | |
| 347 | requiredCheckNames.some((n) => !passing.includes(n)); | |
| 348 | ||
| 349 | // 6. Optional size cap. | |
| 350 | let diffStats: { files: number; lines: number } | null = null; | |
| 351 | const hasCap = | |
| 352 | typeof opts.maxChangedFiles === "number" || | |
| 353 | typeof opts.maxChangedLines === "number"; | |
| 354 | if (hasCap && opts.ownerName && opts.repoName && opts.headBranch) { | |
| 355 | diffStats = await diffStatsForBranches( | |
| 356 | opts.ownerName, | |
| 357 | opts.repoName, | |
| 358 | ctx.baseBranch, | |
| 359 | opts.headBranch | |
| 360 | ); | |
| 361 | } | |
| 362 | ||
| 534f04a | 363 | // M3 — best-effort cached risk lookup. Never throws; missing risk |
| 364 | // simply means the decision falls through to the existing gates. | |
| 365 | let riskForDecision: | |
| 366 | | { score: number; band: "low" | "medium" | "high" | "critical" } | |
| 367 | | null = null; | |
| 368 | try { | |
| 369 | const cached = await getLatestCachedPrRisk(ctx.pullRequestId); | |
| 370 | if (cached) { | |
| 371 | riskForDecision = { score: cached.score, band: cached.band }; | |
| 372 | } | |
| 373 | } catch { | |
| 374 | riskForDecision = null; | |
| 375 | } | |
| 376 | ||
| 4626e61 | 377 | return decideAutoMerge({ |
| 378 | rule, | |
| 379 | isDraft: ctx.isDraft, | |
| 380 | aiApproved, | |
| 381 | humanApprovalCount, | |
| 382 | hasFailedGates, | |
| 383 | passingCheckNames: passing, | |
| 384 | requiredCheckNames, | |
| 385 | diffStats, | |
| 386 | caps: hasCap | |
| 387 | ? { | |
| 388 | maxChangedFiles: opts.maxChangedFiles, | |
| 389 | maxChangedLines: opts.maxChangedLines, | |
| 390 | } | |
| 391 | : undefined, | |
| 534f04a | 392 | risk: riskForDecision, |
| 4626e61 | 393 | }); |
| 394 | } | |
| 395 | ||
| 396 | // --------------------------------------------------------------------------- | |
| 397 | // Audit helper | |
| 398 | // --------------------------------------------------------------------------- | |
| 399 | ||
| 400 | /** | |
| 401 | * Record an auto-merge attempt in the audit log. K3 should call this | |
| 402 | * once per evaluation tick so operators can see the decision trail. | |
| 403 | * Uses `auto_merge.evaluated` for any decision, and `auto_merge.merged` | |
| 404 | * separately when K3 actually performs the merge (K3's responsibility). | |
| 405 | */ | |
| 406 | export async function recordAutoMergeAttempt( | |
| 407 | repositoryId: string, | |
| 408 | pullRequestId: string, | |
| 409 | decision: AutoMergeDecision | |
| 410 | ): Promise<void> { | |
| 411 | await audit({ | |
| 412 | repositoryId, | |
| 413 | action: "auto_merge.evaluated", | |
| 414 | targetType: "pull_request", | |
| 415 | targetId: pullRequestId, | |
| 416 | metadata: { | |
| 417 | merge: decision.merge, | |
| 418 | reason: decision.reason, | |
| 419 | blocking: decision.blocking ?? [], | |
| 420 | }, | |
| 421 | }); | |
| 422 | } | |
| 423 | ||
| 9dd96b9 | 424 | // --------------------------------------------------------------------------- |
| 425 | // R3 — Fast-lane auto-merge (PR-create / PR-head-update event path) | |
| 426 | // --------------------------------------------------------------------------- | |
| 427 | ||
| 428 | /** Stable marker for the auto-merge success comment (shared with autopilot). */ | |
| 429 | const FAST_LANE_AUTO_MERGE_MARKER = "<!-- gluecron:auto-merge:v1 -->"; | |
| 430 | ||
| 431 | /** Default poll interval (ms) while waiting for the AI-review summary comment. */ | |
| 432 | const FAST_LANE_DEFAULT_POLL_MS = 5_000; | |
| 433 | ||
| 434 | /** Default ceiling (ms) on the AI-review wait. */ | |
| 435 | const FAST_LANE_DEFAULT_WAIT_MS = 60_000; | |
| 436 | ||
| 437 | /** | |
| 438 | * PR + repo facts the fast-lane needs to drive `performMerge`. Resolved | |
| 439 | * once at the top of `tryAutoMergeNow` so each side-effect handler shares | |
| 440 | * the same snapshot. | |
| 441 | */ | |
| 442 | interface FastLaneContext { | |
| 443 | prId: string; | |
| 444 | prNumber: number; | |
| 445 | prTitle: string; | |
| 446 | prBody: string | null; | |
| 447 | baseBranch: string; | |
| 448 | headBranch: string; | |
| 449 | isDraft: boolean; | |
| 450 | repositoryId: string; | |
| 451 | authorUserId: string; | |
| 452 | ownerUsername: string; | |
| 453 | repoName: string; | |
| 454 | state: string; | |
| 455 | } | |
| 456 | ||
| 457 | export interface TryAutoMergeNowDeps { | |
| 458 | /** Inject the PR-context loader. */ | |
| 459 | loadContext?: (prId: string) => Promise<FastLaneContext | null>; | |
| 460 | /** Inject the AI-review comment existence probe. */ | |
| 461 | hasAiReviewComment?: (prId: string) => Promise<boolean>; | |
| 462 | /** Inject the decision evaluator. */ | |
| 463 | evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>; | |
| 464 | /** Inject the merge executor. */ | |
| 465 | merge?: (ctx: FastLaneContext) => Promise<PerformMergeResult>; | |
| 466 | /** Inject the audit-log helper for the `auto_merge.evaluated` row. */ | |
| 467 | recordAttempt?: ( | |
| 468 | repositoryId: string, | |
| 469 | pullRequestId: string, | |
| 470 | decision: AutoMergeDecision | |
| 471 | ) => Promise<void>; | |
| 472 | /** Inject the success-path side-effect (audit + marker comment). */ | |
| 473 | onMerged?: (ctx: FastLaneContext, result: PerformMergeResult) => Promise<void>; | |
| 474 | /** Inject sleep so tests don't actually wait 60s. */ | |
| 475 | sleep?: (ms: number) => Promise<void>; | |
| 476 | } | |
| 477 | ||
| 478 | export interface TryAutoMergeNowOptions { | |
| 479 | /** Inject for tests. */ | |
| 480 | now?: Date; | |
| 481 | /** | |
| 482 | * Skip the AI-review polling and decide based on whatever comments | |
| 483 | * exist right now. Default false — we wait briefly for the AI | |
| 484 | * review to land. | |
| 485 | */ | |
| 486 | skipAiReviewWait?: boolean; | |
| 487 | /** | |
| 488 | * Max time (ms) to wait for an AI-review comment to appear before | |
| 489 | * giving up. Default 60_000 (60s). The autopilot 5-min sweep is the | |
| 490 | * safety net if the AI review takes longer. | |
| 491 | */ | |
| 492 | waitForAiReviewMs?: number; | |
| 493 | /** Poll cadence while waiting; default 5_000 ms. Mostly for tests. */ | |
| 494 | aiReviewPollIntervalMs?: number; | |
| 495 | /** DI seam for tests. Pass through to the inner helpers. */ | |
| 496 | deps?: TryAutoMergeNowDeps; | |
| 497 | } | |
| 498 | ||
| 499 | /** Default PR-context loader — single join across pr/repo/user. */ | |
| 500 | async function defaultLoadFastLaneContext( | |
| 501 | prId: string | |
| 502 | ): Promise<FastLaneContext | null> { | |
| 503 | try { | |
| 504 | const [row] = await db | |
| 505 | .select({ | |
| 506 | prId: pullRequests.id, | |
| 507 | prNumber: pullRequests.number, | |
| 508 | prTitle: pullRequests.title, | |
| 509 | prBody: pullRequests.body, | |
| 510 | baseBranch: pullRequests.baseBranch, | |
| 511 | headBranch: pullRequests.headBranch, | |
| 512 | isDraft: pullRequests.isDraft, | |
| 513 | repositoryId: pullRequests.repositoryId, | |
| 514 | authorUserId: pullRequests.authorId, | |
| 515 | ownerUsername: users.username, | |
| 516 | repoName: repositories.name, | |
| 517 | state: pullRequests.state, | |
| 518 | }) | |
| 519 | .from(pullRequests) | |
| 520 | .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId)) | |
| 521 | .innerJoin(users, eq(users.id, repositories.ownerId)) | |
| 522 | .where(eq(pullRequests.id, prId)) | |
| 523 | .limit(1); | |
| 524 | if (!row) return null; | |
| 525 | return { | |
| 526 | prId: row.prId, | |
| 527 | prNumber: row.prNumber, | |
| 528 | prTitle: row.prTitle, | |
| 529 | prBody: row.prBody, | |
| 530 | baseBranch: row.baseBranch, | |
| 531 | headBranch: row.headBranch, | |
| 532 | isDraft: row.isDraft, | |
| 533 | repositoryId: row.repositoryId, | |
| 534 | authorUserId: row.authorUserId, | |
| 535 | ownerUsername: row.ownerUsername, | |
| 536 | repoName: row.repoName, | |
| 537 | state: row.state, | |
| 538 | }; | |
| 539 | } catch (err) { | |
| 540 | console.error("[auto-merge:fast-lane] loadContext failed:", err); | |
| 541 | return null; | |
| 542 | } | |
| 543 | } | |
| 544 | ||
| 545 | /** | |
| 546 | * Default AI-review comment probe. Returns true when at least one | |
| 547 | * marker-bearing AI-review comment exists for the PR. | |
| 548 | */ | |
| 549 | async function defaultHasAiReviewComment(prId: string): Promise<boolean> { | |
| 550 | try { | |
| 551 | const rows = await db | |
| 552 | .select({ body: prComments.body }) | |
| 553 | .from(prComments) | |
| 554 | .where( | |
| 555 | and( | |
| 556 | eq(prComments.pullRequestId, prId), | |
| 557 | eq(prComments.isAiReview, true) | |
| 558 | ) | |
| 559 | ); | |
| 560 | return rows.some((r) => (r.body || "").includes(AI_REVIEW_MARKER)); | |
| 561 | } catch { | |
| 562 | return false; | |
| 563 | } | |
| 564 | } | |
| 565 | ||
| 566 | /** | |
| 567 | * Default success-path: same shape as the K3 sweep's `defaultOnMerged` | |
| 568 | * (auto_merge.merged audit row + marker comment). Both side-effects are | |
| 569 | * best-effort; failures are logged, not thrown. | |
| 570 | */ | |
| 571 | async function defaultFastLaneOnMerged( | |
| 572 | ctx: FastLaneContext, | |
| 573 | result: PerformMergeResult | |
| 574 | ): Promise<void> { | |
| 575 | try { | |
| 576 | await audit({ | |
| 577 | repositoryId: ctx.repositoryId, | |
| 578 | action: "auto_merge.merged", | |
| 579 | targetType: "pull_request", | |
| 580 | targetId: ctx.prId, | |
| 581 | metadata: { | |
| 582 | prNumber: ctx.prNumber, | |
| 583 | baseBranch: ctx.baseBranch, | |
| 584 | headBranch: ctx.headBranch, | |
| 585 | closedIssueNumbers: result.closedIssueNumbers, | |
| 586 | resolvedFiles: result.resolvedFiles, | |
| 587 | fastLane: true, | |
| 588 | }, | |
| 589 | }); | |
| 590 | } catch (err) { | |
| 591 | console.error("[auto-merge:fast-lane] merged audit failed:", err); | |
| 592 | } | |
| 593 | try { | |
| 594 | await db.insert(prComments).values({ | |
| 595 | pullRequestId: ctx.prId, | |
| 596 | authorId: ctx.authorUserId, | |
| 597 | isAiReview: true, | |
| 598 | body: `${FAST_LANE_AUTO_MERGE_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`, | |
| 599 | }); | |
| 600 | } catch (err) { | |
| 601 | console.error("[auto-merge:fast-lane] comment insert failed:", err); | |
| 602 | } | |
| 603 | } | |
| 604 | ||
| 605 | /** Default `performMerge` wrapper that maps a FastLaneContext to the args shape. */ | |
| 606 | async function defaultFastLaneMerge( | |
| 607 | ctx: FastLaneContext | |
| 608 | ): Promise<PerformMergeResult> { | |
| 609 | return performMerge({ | |
| 610 | pr: { | |
| 611 | id: ctx.prId, | |
| 612 | number: ctx.prNumber, | |
| 613 | title: ctx.prTitle, | |
| 614 | body: ctx.prBody, | |
| 615 | baseBranch: ctx.baseBranch, | |
| 616 | headBranch: ctx.headBranch, | |
| 617 | repositoryId: ctx.repositoryId, | |
| 618 | authorId: ctx.authorUserId, | |
| 619 | state: ctx.state as "open", | |
| 620 | isDraft: ctx.isDraft, | |
| 621 | }, | |
| 622 | ownerName: ctx.ownerUsername, | |
| 623 | repoName: ctx.repoName, | |
| 624 | actorUserId: ctx.authorUserId, | |
| 625 | }); | |
| 626 | } | |
| 627 | ||
| 628 | /** | |
| 629 | * Fast-lane auto-merge evaluator. Fired from PR-create (or PR-head-update) | |
| 630 | * events. Evaluates auto-merge immediately and, when the decision is | |
| 631 | * `merge: true`, performs the merge via the shared `performMerge()` path. | |
| 632 | * Otherwise records the evaluation audit and exits — the 5-min autopilot | |
| 633 | * sweep is the safety net. | |
| 634 | * | |
| 635 | * Always fire-and-forget from the caller. Never throws — every side | |
| 636 | * effect is wrapped in try/catch and logged. | |
| 637 | */ | |
| 638 | export async function tryAutoMergeNow( | |
| 639 | pullRequestId: string, | |
| 640 | opts: TryAutoMergeNowOptions = {} | |
| 641 | ): Promise<void> { | |
| 642 | const deps = opts.deps ?? {}; | |
| 643 | const loadContext = deps.loadContext ?? defaultLoadFastLaneContext; | |
| 644 | const hasAiReviewComment = | |
| 645 | deps.hasAiReviewComment ?? defaultHasAiReviewComment; | |
| 646 | const evaluate = | |
| 647 | deps.evaluate ?? ((ctx: AutoMergeContext) => evaluateAutoMerge(ctx, {})); | |
| 648 | const merge = deps.merge ?? defaultFastLaneMerge; | |
| 649 | const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt; | |
| 650 | const onMerged = deps.onMerged ?? defaultFastLaneOnMerged; | |
| 651 | const sleep = | |
| 652 | deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms))); | |
| 653 | ||
| 654 | try { | |
| 655 | // 1. Load the PR + repo facts. Abort cleanly if the PR is gone. | |
| 656 | let ctx: FastLaneContext | null = null; | |
| 657 | try { | |
| 658 | ctx = await loadContext(pullRequestId); | |
| 659 | } catch (err) { | |
| 660 | console.error("[auto-merge:fast-lane] loadContext threw:", err); | |
| 661 | return; | |
| 662 | } | |
| 663 | if (!ctx) return; | |
| 664 | ||
| 665 | // 2. Wait briefly for the AI-review summary comment. The 5-min | |
| 666 | // sweep handles the case where the AI takes longer than the cap. | |
| 667 | // Even when waitMs is 0 we do ONE probe — "wait for it" semantics | |
| 668 | // require *presence*, not just patience; the sweep is the safety net | |
| 669 | // when the AI hasn't landed yet. | |
| 670 | const waitMs = opts.waitForAiReviewMs ?? FAST_LANE_DEFAULT_WAIT_MS; | |
| 671 | const pollMs = opts.aiReviewPollIntervalMs ?? FAST_LANE_DEFAULT_POLL_MS; | |
| 672 | if (!opts.skipAiReviewWait) { | |
| 673 | const deadline = Date.now() + Math.max(0, waitMs); | |
| 674 | // Poll cadence: check once, then every pollMs until the deadline. | |
| 675 | while (true) { | |
| 676 | let present = false; | |
| 677 | try { | |
| 678 | present = await hasAiReviewComment(pullRequestId); | |
| 679 | } catch { | |
| 680 | present = false; | |
| 681 | } | |
| 682 | if (present) break; | |
| 683 | if (Date.now() >= deadline) return; // give up; sweep is the safety net | |
| 684 | try { | |
| 685 | await sleep(pollMs); | |
| 686 | } catch { | |
| 687 | return; | |
| 688 | } | |
| 689 | } | |
| 690 | } | |
| 691 | ||
| 692 | // 3. Evaluate the auto-merge decision via the shared K2 helper. | |
| 693 | let decision: AutoMergeDecision; | |
| 694 | try { | |
| 695 | decision = await evaluate({ | |
| 696 | pullRequestId: ctx.prId, | |
| 697 | repositoryId: ctx.repositoryId, | |
| 698 | baseBranch: ctx.baseBranch, | |
| 699 | isDraft: ctx.isDraft, | |
| 700 | authorUserId: ctx.authorUserId, | |
| 701 | }); | |
| 702 | } catch (err) { | |
| 703 | console.error("[auto-merge:fast-lane] evaluate threw:", err); | |
| 704 | // Still record the evaluation as best-effort so the paper trail exists. | |
| 705 | try { | |
| 706 | await recordAttempt(ctx.repositoryId, ctx.prId, { | |
| 707 | merge: false, | |
| 708 | reason: `evaluate threw: ${err instanceof Error ? err.message : String(err)}`, | |
| 709 | blocking: ["evaluator error"], | |
| 710 | }); | |
| 711 | } catch { | |
| 712 | /* swallow */ | |
| 713 | } | |
| 714 | return; | |
| 715 | } | |
| 716 | ||
| 717 | // 4. Always record the evaluation, regardless of outcome. | |
| 718 | try { | |
| 719 | await recordAttempt(ctx.repositoryId, ctx.prId, decision); | |
| 720 | } catch (err) { | |
| 721 | console.error("[auto-merge:fast-lane] recordAttempt failed:", err); | |
| 722 | } | |
| 723 | ||
| 724 | if (!decision.merge) return; | |
| 725 | ||
| 726 | // 5. Perform the merge via the shared executor. On success, fire the | |
| 727 | // audit + marker comment side-effects (same shape as the K3 sweep). | |
| 728 | try { | |
| 729 | const result = await merge(ctx); | |
| 730 | if (result.ok) { | |
| 731 | try { | |
| 732 | await onMerged(ctx, result); | |
| 733 | } catch (err) { | |
| 734 | console.error("[auto-merge:fast-lane] onMerged threw:", err); | |
| 735 | } | |
| 736 | } else { | |
| 737 | console.error( | |
| 738 | `[auto-merge:fast-lane] performMerge failed for pr=${ctx.prId}: ${result.error}` | |
| 739 | ); | |
| 740 | } | |
| 741 | } catch (err) { | |
| 742 | console.error("[auto-merge:fast-lane] performMerge threw:", err); | |
| 743 | } | |
| 744 | } catch (err) { | |
| 745 | // Belt-and-braces: nothing above should throw, but the contract is | |
| 746 | // "fire-and-forget, never throws". Final catch swallows + logs. | |
| 747 | console.error("[auto-merge:fast-lane] unexpected error:", err); | |
| 748 | } | |
| 749 | } | |
| 750 | ||
| 4626e61 | 751 | // --------------------------------------------------------------------------- |
| 752 | // Test-only surface | |
| 753 | // --------------------------------------------------------------------------- | |
| 754 | ||
| 755 | export const __test = { | |
| 756 | decideAutoMerge, | |
| 757 | aiCommentLooksApproved, | |
| 758 | diffStatsForBranches, | |
| 759 | aiApprovedForPr, | |
| 9dd96b9 | 760 | defaultLoadFastLaneContext, |
| 761 | defaultHasAiReviewComment, | |
| 762 | defaultFastLaneOnMerged, | |
| 763 | defaultFastLaneMerge, | |
| 764 | FAST_LANE_AUTO_MERGE_MARKER, | |
| 765 | FAST_LANE_DEFAULT_POLL_MS, | |
| 766 | FAST_LANE_DEFAULT_WAIT_MS, | |
| 4626e61 | 767 | }; |