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