@@ -78,6 +78,7 @@ import {
7878 enqueueRepositoryOpenDataBackfill ,
7979 fetchAndStorePullRequestFilesForReview ,
8080 fetchLinkedIssueFacts ,
81+ fetchLiveBaseBranchAdvancedAt ,
8182 fetchLiveCiAggregatePreferGraphQl ,
8283 type LiveCiAggregate ,
8384 fetchLiveIssueState ,
@@ -2069,6 +2070,39 @@ async function runAgentMaintenancePlanAndExecute(
20692070 ) ;
20702071 if ( breakerOnPlan . length === 0 ) return ;
20712072
2073+ // #2552 (gate review finding, round 2): force a fresh rebase + CI recheck when the base has advanced within
2074+ // the configured window, immediately before what would otherwise be an agent-driven merge — mergeable_state
2075+ // only detects git-level TEXTUAL conflicts, so a base that advanced with a new, non-conflicting sibling
2076+ // commit (e.g. a second PR's distinct-but-colliding migration file) still reads `clean`, on a decision that
2077+ // predates the base's latest commit. Deliberately placed AFTER the full plan (gate/CI/blockers/breakers) is
2078+ // resolved, not on the raw mergeableState alone: the original placement ran this unconditionally whenever
2079+ // mergeableState was clean, so a PR sitting on red CI or a gate blocker (still git-clean) could burn the
2080+ // bounded retry cap on rebases nobody was about to act on, exhausting it before the PR was ever actually
2081+ // merge-eligible. Only fires when the resolved plan contains a merge THAT WOULD EXECUTE NOW (requiresApproval
2082+ // stages for a human, not an immediate merge). A forced rebase's resulting `synchronize` webhook re-triggers
2083+ // a fresh evaluation on the new head, so this pass stops here rather than executing against stale inputs.
2084+ const requireFreshRebaseWindowMinutes = settings . requireFreshRebaseWindowMinutes ;
2085+ const planHasImminentMerge = breakerOnPlan . some ( ( action ) => action . actionClass === "merge" && ! action . requiresApproval ) ;
2086+ if (
2087+ typeof requireFreshRebaseWindowMinutes === "number" &&
2088+ baseRef &&
2089+ planHasImminentMerge &&
2090+ ( liveMergeState ?? pr . mergeableState ) === "clean" &&
2091+ ( await maybeForceFreshRebase ( env , {
2092+ installationId,
2093+ repoFullName,
2094+ pr,
2095+ settings,
2096+ windowMinutes : requireFreshRebaseWindowMinutes ,
2097+ baseRef,
2098+ token,
2099+ admissionKey,
2100+ deliveryId,
2101+ } ) )
2102+ ) {
2103+ return ;
2104+ }
2105+
20722106 const installation = await getInstallation ( env , installationId ) ;
20732107 /* v8 ignore next -- an installed-App PR webhook always carries an installation record; the null is defensive. */
20742108 const installationPermissions = installation ?. permissions ?? null ;
@@ -2545,6 +2579,115 @@ async function ciPendingDeferStuck(
25452579 }
25462580}
25472581
2582+ // #2552: bounded-retry cap for the force-fresh-rebase gate — without this, a fast-moving base could keep the
2583+ // freshness window perpetually "hot" and never let the PR clear to a real merge. Past the cap, the gate falls
2584+ // through to a normal merge decision (with an audit trail) rather than holding the PR hostage to base
2585+ // velocity. Deliberately keyed by PR NUMBER ONLY, NOT head SHA (gate review finding on the first version of
2586+ // this PR): a SUCCESSFUL forced update_branch itself produces a NEW head SHA, so a headSha-keyed counter would
2587+ // mint a fresh key — and reset to attempt 0 — on every single successful force, making the cap unreachable via
2588+ // the exact path it exists to bound. A 24h TTL on the stored counter still gives an eventual fresh start.
2589+ const MAX_FRESH_REBASE_FORCES = 3 ;
2590+ function freshRebaseForceCountKey ( repoFullName : string , prNumber : number ) : string {
2591+ return `fresh-rebase-forced:${ repoFullName . toLowerCase ( ) } #${ prNumber } ` ;
2592+ }
2593+
2594+ /**
2595+ * #2552: when the repo has opted into `gate.requireFreshRebaseWindow` and the base branch's live tip commit
2596+ * landed within that window of NOW, force an `update_branch` (merges base into head, re-triggering CI on the
2597+ * rebased result — the SAME action class/write-permission/dry-run/kill-switch stack `prReadyForReview`'s
2598+ * BEHIND-branch path already uses, not a new one) immediately before what would otherwise be a merge, instead
2599+ * of trusting a `mergeable_state: clean` read that predates the base's latest commit. Returns true when it
2600+ * forced the rebase (the caller stops this pass — the resulting `synchronize` webhook re-triggers a fresh
2601+ * evaluation on the new head); false when the freshness check doesn't apply, the cap was already reached, or
2602+ * the forced action itself couldn't complete (not authorized / dry-run / transient failure) — in every false
2603+ * case the caller falls through to the normal merge decision, so this gate fails open to today's behavior.
2604+ */
2605+ async function maybeForceFreshRebase (
2606+ env : Env ,
2607+ args : {
2608+ installationId : number ;
2609+ repoFullName : string ;
2610+ pr : PullRequestRecord ;
2611+ settings : RepositorySettings ;
2612+ // Narrowed by the caller (typeof settings.requireFreshRebaseWindowMinutes === "number") -- re-deriving and
2613+ // re-checking the same nullable field here would just be an unreachable duplicate of that guard.
2614+ windowMinutes : number ;
2615+ baseRef : string ;
2616+ token : string | undefined ;
2617+ admissionKey : GitHubRateLimitAdmissionKey | undefined ;
2618+ deliveryId : string ;
2619+ } ,
2620+ ) : Promise < boolean > {
2621+ const { installationId, repoFullName, pr, settings, windowMinutes, baseRef, token, admissionKey, deliveryId } = args ;
2622+ /* v8 ignore next -- structurally unreachable: the caller only invokes this after confirming
2623+ * (liveMergeState ?? pr.mergeableState) === "clean", which GitHub can never compute for a PR with no
2624+ * head commit; the null check is belt-and-suspenders against the field's optional TS type. */
2625+ if ( ! pr . headSha ) return false ;
2626+ const advancedAt = await fetchLiveBaseBranchAdvancedAt ( env , repoFullName , baseRef , token , admissionKey ) ;
2627+ if ( ! advancedAt ) return false ; // fail-open: unreadable base commit -> no forced rebase
2628+ const advancedAtMs = Date . parse ( advancedAt ) ;
2629+ if ( ! Number . isFinite ( advancedAtMs ) || Date . now ( ) - advancedAtMs >= windowMinutes * 60_000 ) return false ;
2630+
2631+ const countKey = freshRebaseForceCountKey ( repoFullName , pr . number ) ;
2632+ const storedCount = Number ( await getTransientKey ( env , countKey ) ) ;
2633+ const attempt = Number . isFinite ( storedCount ) && storedCount > 0 ? storedCount : 0 ;
2634+ if ( attempt >= MAX_FRESH_REBASE_FORCES ) {
2635+ await recordAuditEvent ( env , {
2636+ eventType : "agent.action.fresh_rebase_window_cap_exceeded" ,
2637+ actor : "gittensory" ,
2638+ targetKey : `${ repoFullName } #${ pr . number } ` ,
2639+ outcome : "completed" ,
2640+ detail : `base advanced within the ${ windowMinutes } m freshness window, but the ${ MAX_FRESH_REBASE_FORCES } -attempt forced-rebase cap was already reached for this PR — falling through to a normal merge decision` ,
2641+ metadata : { deliveryId, repoFullName, headSha : pr . headSha , windowMinutes } ,
2642+ } ) . catch (
2643+ /* v8 ignore next -- fail-safe: an audit write failure never blocks the caller's fallthrough */
2644+ ( ) => undefined ,
2645+ ) ;
2646+ return false ;
2647+ }
2648+
2649+ const autonomyLevel = resolveAutonomy ( settings . autonomy , "update_branch" ) ;
2650+ const installation = await getInstallation ( env , installationId ) ;
2651+ const [ outcome ] = await executeAgentMaintenanceActions (
2652+ env ,
2653+ {
2654+ installationId,
2655+ repoFullName,
2656+ pullNumber : pr . number ,
2657+ headSha : pr . headSha ,
2658+ autonomy : settings . autonomy ,
2659+ agentPaused : settings . agentPaused ,
2660+ agentDryRun : settings . agentDryRun ,
2661+ /* v8 ignore next -- an installed-App PR webhook always carries an installation record; the null is defensive (mirrors runAgentMaintenancePlanAndExecute's own identical merge-time read). */
2662+ installationPermissions : installation ?. permissions ?? null ,
2663+ authorLogin : pr . authorLogin ,
2664+ } ,
2665+ [
2666+ {
2667+ actionClass : "update_branch" ,
2668+ requiresApproval : autonomyRequiresApproval ( autonomyLevel ) ,
2669+ reason : `base branch advanced within the ${ windowMinutes } m freshness window; forcing a fresh rebase + CI recheck before merge` ,
2670+ expectedHeadSha : pr . headSha ,
2671+ } ,
2672+ ] ,
2673+ ) ;
2674+ if ( outcome ?. outcome !== "completed" ) return false ;
2675+ const nextAttempt = attempt + 1 ;
2676+ await putTransientKey ( env , countKey , String ( nextAttempt ) , 24 * 3600 ) ;
2677+ await recordAuditEvent ( env , {
2678+ eventType : "agent.action.forced_rebase_freshness" ,
2679+ actor : "gittensory" ,
2680+ targetKey : `${ repoFullName } #${ pr . number } ` ,
2681+ outcome : "completed" ,
2682+ detail : `forced update_branch (attempt ${ nextAttempt } /${ MAX_FRESH_REBASE_FORCES } ) — base advanced within the ${ windowMinutes } m freshness window` ,
2683+ metadata : { deliveryId, repoFullName, headSha : pr . headSha , windowMinutes, attempt : nextAttempt } ,
2684+ } ) . catch (
2685+ /* v8 ignore next -- fail-safe: an audit write failure never blocks the caller */
2686+ ( ) => undefined ,
2687+ ) ;
2688+ return true ;
2689+ }
2690+
25482691// One CI run fires MANY check_run (one per job) + check_suite completions. Re-reviewing on every one storms the
25492692// PR with duplicate reviews (and races the request_changes/approve dedup). reviewbot's CI_COALESCE_WINDOW parity:
25502693// re-review a given PR at most once per this window. The re-review always re-fetches the LIVE CI, so the window
0 commit comments