@@ -78,6 +78,7 @@ import {
7878 enqueueRepositoryOpenDataBackfill ,
7979 fetchAndStorePullRequestFilesForReview ,
8080 fetchLinkedIssueFacts ,
81+ fetchLiveBaseBranchAdvancedAt ,
8182 fetchLiveCiAggregatePreferGraphQl ,
8283 type LiveCiAggregate ,
8384 fetchLiveIssueState ,
@@ -1998,6 +1999,34 @@ async function runAgentMaintenancePlanAndExecute(
19981999 }
19992000 }
20002001
2002+ // #2552: force a fresh rebase + CI recheck when the base has advanced within the configured window,
2003+ // immediately before what would otherwise be an agent-driven merge — mergeable_state only detects
2004+ // git-level TEXTUAL conflicts, so a base that advanced with a new, non-conflicting sibling commit (e.g. a
2005+ // second PR's distinct-but-colliding migration file) still reads `clean`, on a decision that predates
2006+ // the base's latest commit. Only pays the extra live GitHub read when the repo opted in AND the PR is
2007+ // otherwise merge-mechanically-ready (mergeableClean) — nothing is gained checking a PR that isn't clean
2008+ // yet. A forced rebase's resulting `synchronize` webhook re-triggers a fresh evaluation on the new head,
2009+ // so this pass stops here rather than falling through to planAgentMaintenanceActions with stale inputs.
2010+ const requireFreshRebaseWindowMinutes = settings . requireFreshRebaseWindowMinutes ;
2011+ if (
2012+ typeof requireFreshRebaseWindowMinutes === "number" &&
2013+ baseRef &&
2014+ ( liveMergeState ?? pr . mergeableState ) === "clean" &&
2015+ ( await maybeForceFreshRebase ( env , {
2016+ installationId,
2017+ repoFullName,
2018+ pr,
2019+ settings,
2020+ windowMinutes : requireFreshRebaseWindowMinutes ,
2021+ baseRef,
2022+ token,
2023+ admissionKey,
2024+ deliveryId,
2025+ } ) )
2026+ ) {
2027+ return ;
2028+ }
2029+
20012030 const planned = planAgentMaintenanceActions ( {
20022031 conclusion : gate . conclusion ,
20032032 blockerTitles : gate . blockers . map ( ( blocker ) => blocker . title ) ,
@@ -2545,6 +2574,115 @@ async function ciPendingDeferStuck(
25452574 }
25462575}
25472576
2577+ // #2552: bounded-retry cap for the force-fresh-rebase gate — without this, a fast-moving base could keep the
2578+ // freshness window perpetually "hot" and never let the PR clear to a real merge. Past the cap, the gate falls
2579+ // through to a normal merge decision (with an audit trail) rather than holding the PR hostage to base
2580+ // velocity. Deliberately keyed by PR NUMBER ONLY, NOT head SHA (gate review finding on the first version of
2581+ // this PR): a SUCCESSFUL forced update_branch itself produces a NEW head SHA, so a headSha-keyed counter would
2582+ // mint a fresh key — and reset to attempt 0 — on every single successful force, making the cap unreachable via
2583+ // the exact path it exists to bound. A 24h TTL on the stored counter still gives an eventual fresh start.
2584+ const MAX_FRESH_REBASE_FORCES = 3 ;
2585+ function freshRebaseForceCountKey ( repoFullName : string , prNumber : number ) : string {
2586+ return `fresh-rebase-forced:${ repoFullName . toLowerCase ( ) } #${ prNumber } ` ;
2587+ }
2588+
2589+ /**
2590+ * #2552: when the repo has opted into `gate.requireFreshRebaseWindow` and the base branch's live tip commit
2591+ * landed within that window of NOW, force an `update_branch` (merges base into head, re-triggering CI on the
2592+ * rebased result — the SAME action class/write-permission/dry-run/kill-switch stack `prReadyForReview`'s
2593+ * BEHIND-branch path already uses, not a new one) immediately before what would otherwise be a merge, instead
2594+ * of trusting a `mergeable_state: clean` read that predates the base's latest commit. Returns true when it
2595+ * forced the rebase (the caller stops this pass — the resulting `synchronize` webhook re-triggers a fresh
2596+ * evaluation on the new head); false when the freshness check doesn't apply, the cap was already reached, or
2597+ * the forced action itself couldn't complete (not authorized / dry-run / transient failure) — in every false
2598+ * case the caller falls through to the normal merge decision, so this gate fails open to today's behavior.
2599+ */
2600+ async function maybeForceFreshRebase (
2601+ env : Env ,
2602+ args : {
2603+ installationId : number ;
2604+ repoFullName : string ;
2605+ pr : PullRequestRecord ;
2606+ settings : RepositorySettings ;
2607+ // Narrowed by the caller (typeof settings.requireFreshRebaseWindowMinutes === "number") -- re-deriving and
2608+ // re-checking the same nullable field here would just be an unreachable duplicate of that guard.
2609+ windowMinutes : number ;
2610+ baseRef : string ;
2611+ token : string | undefined ;
2612+ admissionKey : GitHubRateLimitAdmissionKey | undefined ;
2613+ deliveryId : string ;
2614+ } ,
2615+ ) : Promise < boolean > {
2616+ const { installationId, repoFullName, pr, settings, windowMinutes, baseRef, token, admissionKey, deliveryId } = args ;
2617+ /* v8 ignore next -- structurally unreachable: the caller only invokes this after confirming
2618+ * (liveMergeState ?? pr.mergeableState) === "clean", which GitHub can never compute for a PR with no
2619+ * head commit; the null check is belt-and-suspenders against the field's optional TS type. */
2620+ if ( ! pr . headSha ) return false ;
2621+ const advancedAt = await fetchLiveBaseBranchAdvancedAt ( env , repoFullName , baseRef , token , admissionKey ) ;
2622+ if ( ! advancedAt ) return false ; // fail-open: unreadable base commit -> no forced rebase
2623+ const advancedAtMs = Date . parse ( advancedAt ) ;
2624+ if ( ! Number . isFinite ( advancedAtMs ) || Date . now ( ) - advancedAtMs >= windowMinutes * 60_000 ) return false ;
2625+
2626+ const countKey = freshRebaseForceCountKey ( repoFullName , pr . number ) ;
2627+ const storedCount = Number ( await getTransientKey ( env , countKey ) ) ;
2628+ const attempt = Number . isFinite ( storedCount ) && storedCount > 0 ? storedCount : 0 ;
2629+ if ( attempt >= MAX_FRESH_REBASE_FORCES ) {
2630+ await recordAuditEvent ( env , {
2631+ eventType : "agent.action.fresh_rebase_window_cap_exceeded" ,
2632+ actor : "gittensory" ,
2633+ targetKey : `${ repoFullName } #${ pr . number } ` ,
2634+ outcome : "completed" ,
2635+ 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` ,
2636+ metadata : { deliveryId, repoFullName, headSha : pr . headSha , windowMinutes } ,
2637+ } ) . catch (
2638+ /* v8 ignore next -- fail-safe: an audit write failure never blocks the caller's fallthrough */
2639+ ( ) => undefined ,
2640+ ) ;
2641+ return false ;
2642+ }
2643+
2644+ const autonomyLevel = resolveAutonomy ( settings . autonomy , "update_branch" ) ;
2645+ const installation = await getInstallation ( env , installationId ) ;
2646+ const [ outcome ] = await executeAgentMaintenanceActions (
2647+ env ,
2648+ {
2649+ installationId,
2650+ repoFullName,
2651+ pullNumber : pr . number ,
2652+ headSha : pr . headSha ,
2653+ autonomy : settings . autonomy ,
2654+ agentPaused : settings . agentPaused ,
2655+ agentDryRun : settings . agentDryRun ,
2656+ /* 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). */
2657+ installationPermissions : installation ?. permissions ?? null ,
2658+ authorLogin : pr . authorLogin ,
2659+ } ,
2660+ [
2661+ {
2662+ actionClass : "update_branch" ,
2663+ requiresApproval : autonomyRequiresApproval ( autonomyLevel ) ,
2664+ reason : `base branch advanced within the ${ windowMinutes } m freshness window; forcing a fresh rebase + CI recheck before merge` ,
2665+ expectedHeadSha : pr . headSha ,
2666+ } ,
2667+ ] ,
2668+ ) ;
2669+ if ( outcome ?. outcome !== "completed" ) return false ;
2670+ const nextAttempt = attempt + 1 ;
2671+ await putTransientKey ( env , countKey , String ( nextAttempt ) , 24 * 3600 ) ;
2672+ await recordAuditEvent ( env , {
2673+ eventType : "agent.action.forced_rebase_freshness" ,
2674+ actor : "gittensory" ,
2675+ targetKey : `${ repoFullName } #${ pr . number } ` ,
2676+ outcome : "completed" ,
2677+ detail : `forced update_branch (attempt ${ nextAttempt } /${ MAX_FRESH_REBASE_FORCES } ) — base advanced within the ${ windowMinutes } m freshness window` ,
2678+ metadata : { deliveryId, repoFullName, headSha : pr . headSha , windowMinutes, attempt : nextAttempt } ,
2679+ } ) . catch (
2680+ /* v8 ignore next -- fail-safe: an audit write failure never blocks the caller */
2681+ ( ) => undefined ,
2682+ ) ;
2683+ return true ;
2684+ }
2685+
25482686// One CI run fires MANY check_run (one per job) + check_suite completions. Re-reviewing on every one storms the
25492687// PR with duplicate reviews (and races the request_changes/approve dedup). reviewbot's CI_COALESCE_WINDOW parity:
25502688// 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