Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

auto-merge.tsBlame767 lines · 2 contributors
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";
9dd96b9Test User34import {
35 branchProtection,
36 prComments,
37 pullRequests,
38 repositories,
39 users,
40} from "../db/schema";
4626e61Claude41import type { BranchProtection } from "../db/schema";
42import {
43 countHumanApprovals,
44 evaluateProtection,
45 listRequiredChecks,
46 matchProtection,
47 passingCheckNames,
48} from "./branch-protection";
49import { AI_REVIEW_MARKER } from "./ai-review";
50import { audit } from "./notify";
51import { getRepoPath } from "../git/repository";
534f04aClaude52import { getLatestCachedPrRisk } from "./pr-risk";
9dd96b9Test User53import { performMerge, type PerformMergeResult } from "./pr-merge";
4626e61Claude54
55// ---------------------------------------------------------------------------
56// Public types
57// ---------------------------------------------------------------------------
58
59export interface AutoMergeContext {
60 pullRequestId: string;
61 repositoryId: string;
62 baseBranch: string;
63 isDraft: boolean;
64 authorUserId: string;
65}
66
67export interface AutoMergeDecision {
68 merge: boolean;
69 reason: string;
70 blocking?: string[];
71}
72
73export 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.
534f04aClaude97 *
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.
4626e61Claude101 */
102export 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 };
534f04aClaude112 risk?: { score: number; band: "low" | "medium" | "high" | "critical" } | null;
4626e61Claude113}): 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
534f04aClaude156 // 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
4626e61Claude162 // 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 */
217export 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 */
236async 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 */
267async 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 */
302export 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
534f04aClaude363 // 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
4626e61Claude377 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,
534f04aClaude392 risk: riskForDecision,
4626e61Claude393 });
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 */
406export 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
9dd96b9Test User424// ---------------------------------------------------------------------------
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). */
429const FAST_LANE_AUTO_MERGE_MARKER = "<!-- gluecron:auto-merge:v1 -->";
430
431/** Default poll interval (ms) while waiting for the AI-review summary comment. */
432const FAST_LANE_DEFAULT_POLL_MS = 5_000;
433
434/** Default ceiling (ms) on the AI-review wait. */
435const 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 */
442interface 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
457export 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
478export 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. */
500async 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 */
549async 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 */
571async 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. */
606async 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 */
638export 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
4626e61Claude751// ---------------------------------------------------------------------------
752// Test-only surface
753// ---------------------------------------------------------------------------
754
755export const __test = {
756 decideAutoMerge,
757 aiCommentLooksApproved,
758 diffStatsForBranches,
759 aiApprovedForPr,
9dd96b9Test User760 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,
4626e61Claude767};