Skip to content

Commit ecdddee

Browse files
committed
feat(gate): force a fresh rebase + CI recheck immediately before merge when base has advanced
The update_branch action (which merges the current base into head and would naturally re-trigger CI on a truly-rebased branch) only fired when liveMergeState === "behind" inside the review-gating pass, not immediately before the merge action itself. The merge decision's own gate treats GitHub's mergeable_state as the sole base-freshness signal, and mergeable_state only detects git-level TEXTUAL conflicts -- a base that has advanced with a new, non-conflicting sibling commit (e.g. a second PR's distinct-but-colliding migration file) still reads clean. So a PR sitting green + approved + clean gets merged without ever being forced through a fresh rebase-and-CI-recheck cycle against whatever landed on the base moments earlier from a concurrent merge. Added a new config-driven gate.requireFreshRebaseWindow setting (minutes, off by default) wired through the full config-as-code chain: migration 0093, Drizzle schema, RepositorySettings type, the DB round-trip in repositories.ts (default/row-mapping/insert/update), focus-manifest.ts's gate config parse/serialize/resolve, the OpenAPI schema, and .gittensory.yml.example docs. The base-freshness check itself lives in runAgentMaintenancePlanAndExecute, immediately before planAgentMaintenanceActions is called with merge-eligible inputs: a new fetchLiveBaseBranchAdvancedAt (backfill.ts) reads the base branch's live tip-commit timestamp via GET /commits/{ref}. When it landed within the configured window, maybeForceFreshRebase forces an update_branch through the SAME action class/write-permission/ dry-run/kill-switch stack prReadyForReview's own BEHIND-branch path already uses -- not a new action class, just a new trigger condition. A bounded retry cap (3 attempts, keyed per head SHA in the transient cache, mirroring ciPendingDeferStuck's pattern) prevents a fast-moving base from live-locking the PR; past the cap it falls through to a normal merge with an audit trail. Every failure mode (unreadable base commit, cap reached, forced action not authorized) fails open to today's merge behavior. Off by default -- zero behavior change for any repo that hasn't opted in, and the new live GitHub read only fires when a repo has explicitly configured the window AND the PR is otherwise merge-mechanically-ready. Tests: DB round-trip in data-spine.test.ts, full gate.requireFreshRebaseWindow parse/round-trip/resolve coverage in focus-manifest.test.ts, and 6 new end-to-end queue.test.ts scenarios covering a normal merge, a forced rebase, the off-by-default path, the bounded-retry fallback, an unreadable base commit, and an unauthorized forced action -- each driving the real processJob path and asserting on audit_events. 100% branch coverage on the new code. Closes #2552
1 parent 8e06d2c commit ecdddee

13 files changed

Lines changed: 415 additions & 3 deletions

File tree

.gittensory.yml.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,15 @@ gate:
183183
# PR, so it is opt-in rather than a new default.
184184
premergeContentRecheck: false
185185

186+
# Force-rebase-before-merge window in minutes (#2552, anti-race). When the base branch has advanced within
187+
# this many minutes of the actual merge-decision moment, an agent-driven merge forces an update_branch +
188+
# fresh CI recheck cycle before merging, instead of trusting a mergeable_state: clean read that may already
189+
# be stale relative to a sibling commit that just landed on the base. A bounded retry cap prevents a
190+
# fast-moving base from live-locking the PR — after a few forced attempts it falls through to a normal
191+
# merge with an audit note. Positive integer (minutes), or omit/null. Default: null (never force).
192+
# DB-backed (dashboard-settable too); this overrides the stored value.
193+
requireFreshRebaseWindow: 10
194+
186195
# AI maintainer review. Opt-in; the AI capabilities are switched on at the
187196
# deployment level.
188197
aiReview:

apps/gittensory-ui/public/openapi.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8682,6 +8682,12 @@
86828682
},
86838683
"badgeEnabled": {
86848684
"type": "boolean"
8685+
},
8686+
"requireFreshRebaseWindowMinutes": {
8687+
"type": "integer",
8688+
"nullable": true,
8689+
"minimum": 0,
8690+
"exclusiveMinimum": true
86858691
}
86868692
},
86878693
"required": [
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Force-rebase-before-merge gate (#2552): optional per-repo window (minutes). NULL (the default) means the
2+
-- gate never forces a rebase -- byte-identical behavior for every existing row. When set, an agent-driven
3+
-- merge whose base branch advanced within this window forces an update_branch + fresh CI recheck before
4+
-- merging, instead of trusting a mergeable_state: clean read that may already be stale relative to the base.
5+
ALTER TABLE repository_settings ADD COLUMN require_fresh_rebase_window_minutes INTEGER;

src/db/repositories.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
510510
reviewNagCooldownDays: 5,
511511
reviewNagLabel: "review-nag-cooldown",
512512
autoCloseExemptLogins: [],
513+
requireFreshRebaseWindowMinutes: null,
513514
};
514515
}
515516
return {
@@ -562,6 +563,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
562563
reviewNagCooldownDays: normalizePositiveIntWithDefault(row.reviewNagCooldownDays, 5),
563564
reviewNagLabel: row.reviewNagLabel,
564565
autoCloseExemptLogins: parseAutoCloseExemptLogins(row.autoCloseExemptLoginsJson),
566+
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(row.requireFreshRebaseWindowMinutes),
565567
createdAt: row.createdAt,
566568
updatedAt: row.updatedAt,
567569
};
@@ -646,6 +648,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
646648
reviewNagCooldownDays: normalizePositiveIntWithDefault(settings.reviewNagCooldownDays, 5),
647649
reviewNagLabel: settings.reviewNagLabel ?? "review-nag-cooldown",
648650
autoCloseExemptLogins: normalizeAutoCloseExemptLogins(settings.autoCloseExemptLogins).logins,
651+
requireFreshRebaseWindowMinutes: normalizeOpenItemCap(settings.requireFreshRebaseWindowMinutes),
649652
};
650653
const db = getDb(env.DB);
651654
await db
@@ -700,6 +703,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
700703
reviewNagCooldownDays: resolved.reviewNagCooldownDays,
701704
reviewNagLabel: resolved.reviewNagLabel,
702705
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
706+
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
703707
updatedAt: nowIso(),
704708
})
705709
.onConflictDoUpdate({
@@ -755,6 +759,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
755759
reviewNagCooldownDays: resolved.reviewNagCooldownDays,
756760
reviewNagLabel: resolved.reviewNagLabel,
757761
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
762+
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
758763
updatedAt: nowIso(),
759764
},
760765
});

src/db/schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ export const repositorySettings = sqliteTable("repository_settings", {
9999
reviewNagLabel: text("review_nag_label").notNull().default("review-nag-cooldown"),
100100
// Shared repo-scoped exemption list (#2463): a JSON array of GitHub logins.
101101
autoCloseExemptLoginsJson: text("auto_close_exempt_logins_json").notNull().default("[]"),
102+
// Force-rebase-before-merge window in minutes (#2552): null = never force (default). Enforcement lands in
103+
// runAgentMaintenancePlanAndExecute, not here.
104+
requireFreshRebaseWindowMinutes: integer("require_fresh_rebase_window_minutes"),
102105
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
103106
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
104107
});

src/github/backfill.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2637,6 +2637,32 @@ export async function fetchLivePullRequestMergeState(
26372637
return result?.data.mergeable_state ?? undefined;
26382638
}
26392639

2640+
/**
2641+
* The base branch's LIVE tip-commit timestamp via REST `GET /commits/{ref}`, for the force-fresh-rebase gate
2642+
* (#2552): `mergeable_state` only detects git-level TEXTUAL conflicts, so a base that advanced with a new,
2643+
* non-conflicting sibling commit (e.g. a second PR's distinct-but-colliding migration file) still reads
2644+
* `clean`, letting a merge proceed on a decision that is stale relative to what just landed on the base.
2645+
* Comparing this timestamp against "now" lets the caller force a fresh rebase + CI recheck instead of
2646+
* trusting a `clean` read that predates the base's latest commit. Best-effort: a fetch error returns
2647+
* undefined (caller fails open — no forced rebase, same as today).
2648+
*/
2649+
export async function fetchLiveBaseBranchAdvancedAt(
2650+
env: Env,
2651+
repoFullName: string,
2652+
baseRef: string,
2653+
token: string | undefined,
2654+
admissionKey?: GitHubRateLimitAdmissionKey,
2655+
): Promise<string | undefined> {
2656+
const result = await githubJsonWithHeaders<{ commit?: { committer?: { date?: string | null } | null } | null }>(
2657+
env,
2658+
repoFullName,
2659+
`/commits/${encodeURIComponent(baseRef)}`,
2660+
token,
2661+
githubRateLimitOptions(admissionKey),
2662+
).catch(() => undefined);
2663+
return result?.data.commit?.committer?.date ?? undefined;
2664+
}
2665+
26402666
/** The PR's LIVE state ("open" / "closed") via REST `GET /pulls/{n}`. The stored open-PR cache lags GitHub, so a
26412667
* sibling closed/merged on GitHub can still read `open` locally; the duplicate-winner election (#dup-winner /
26422668
* audit #15) confirms a lower sibling's live state before treating this PR as a cluster loser. Best-effort:

src/openapi/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ export const RepositorySettingsSchema = z
598598
sizeGateMode: z.enum(["off", "advisory", "block"]).optional(),
599599
gateDryRun: z.boolean().optional(),
600600
premergeContentRecheck: z.boolean().optional(),
601+
requireFreshRebaseWindowMinutes: z.number().int().positive().nullable().optional(),
601602
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
602603
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
603604
selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),

src/queue/processors.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,112 @@ async function ciPendingDeferStuck(
25452574
}
25462575
}
25472576

2577+
// #2552: bounded-retry cap for the force-fresh-rebase gate, keyed per head SHA (a genuine new push resets the
2578+
// count, same as ciPendingDeferStuck above) — without this, a fast-moving base could keep the freshness window
2579+
// perpetually "hot" and never let the PR clear to a real merge. Past the cap, the gate falls through to a
2580+
// normal merge decision (with an audit trail) rather than holding the PR hostage to base velocity.
2581+
const MAX_FRESH_REBASE_FORCES = 3;
2582+
function freshRebaseForceCountKey(repoFullName: string, prNumber: number, headSha: string): string {
2583+
return `fresh-rebase-forced:${repoFullName.toLowerCase()}#${prNumber}:${headSha}`;
2584+
}
2585+
2586+
/**
2587+
* #2552: when the repo has opted into `gate.requireFreshRebaseWindow` and the base branch's live tip commit
2588+
* landed within that window of NOW, force an `update_branch` (merges base into head, re-triggering CI on the
2589+
* rebased result — the SAME action class/write-permission/dry-run/kill-switch stack `prReadyForReview`'s
2590+
* BEHIND-branch path already uses, not a new one) immediately before what would otherwise be a merge, instead
2591+
* of trusting a `mergeable_state: clean` read that predates the base's latest commit. Returns true when it
2592+
* forced the rebase (the caller stops this pass — the resulting `synchronize` webhook re-triggers a fresh
2593+
* evaluation on the new head); false when the freshness check doesn't apply, the cap was already reached, or
2594+
* the forced action itself couldn't complete (not authorized / dry-run / transient failure) — in every false
2595+
* case the caller falls through to the normal merge decision, so this gate fails open to today's behavior.
2596+
*/
2597+
async function maybeForceFreshRebase(
2598+
env: Env,
2599+
args: {
2600+
installationId: number;
2601+
repoFullName: string;
2602+
pr: PullRequestRecord;
2603+
settings: RepositorySettings;
2604+
// Narrowed by the caller (typeof settings.requireFreshRebaseWindowMinutes === "number") -- re-deriving and
2605+
// re-checking the same nullable field here would just be an unreachable duplicate of that guard.
2606+
windowMinutes: number;
2607+
baseRef: string;
2608+
token: string | undefined;
2609+
admissionKey: GitHubRateLimitAdmissionKey | undefined;
2610+
deliveryId: string;
2611+
},
2612+
): Promise<boolean> {
2613+
const { installationId, repoFullName, pr, settings, windowMinutes, baseRef, token, admissionKey, deliveryId } = args;
2614+
/* v8 ignore next -- structurally unreachable: the caller only invokes this after confirming
2615+
* (liveMergeState ?? pr.mergeableState) === "clean", which GitHub can never compute for a PR with no
2616+
* head commit; the null check is belt-and-suspenders against the field's optional TS type. */
2617+
if (!pr.headSha) return false;
2618+
const advancedAt = await fetchLiveBaseBranchAdvancedAt(env, repoFullName, baseRef, token, admissionKey);
2619+
if (!advancedAt) return false; // fail-open: unreadable base commit -> no forced rebase
2620+
const advancedAtMs = Date.parse(advancedAt);
2621+
if (!Number.isFinite(advancedAtMs) || Date.now() - advancedAtMs >= windowMinutes * 60_000) return false;
2622+
2623+
const countKey = freshRebaseForceCountKey(repoFullName, pr.number, pr.headSha);
2624+
const storedCount = Number(await getTransientKey(env, countKey));
2625+
const attempt = Number.isFinite(storedCount) && storedCount > 0 ? storedCount : 0;
2626+
if (attempt >= MAX_FRESH_REBASE_FORCES) {
2627+
await recordAuditEvent(env, {
2628+
eventType: "agent.action.fresh_rebase_window_cap_exceeded",
2629+
actor: "gittensory",
2630+
targetKey: `${repoFullName}#${pr.number}`,
2631+
outcome: "completed",
2632+
detail: `base advanced within the ${windowMinutes}m freshness window, but the ${MAX_FRESH_REBASE_FORCES}-attempt forced-rebase cap was already reached for this head SHA — falling through to a normal merge decision`,
2633+
metadata: { deliveryId, repoFullName, headSha: pr.headSha, windowMinutes },
2634+
}).catch(
2635+
/* v8 ignore next -- fail-safe: an audit write failure never blocks the caller's fallthrough */
2636+
() => undefined,
2637+
);
2638+
return false;
2639+
}
2640+
2641+
const autonomyLevel = resolveAutonomy(settings.autonomy, "update_branch");
2642+
const installation = await getInstallation(env, installationId);
2643+
const [outcome] = await executeAgentMaintenanceActions(
2644+
env,
2645+
{
2646+
installationId,
2647+
repoFullName,
2648+
pullNumber: pr.number,
2649+
headSha: pr.headSha,
2650+
autonomy: settings.autonomy,
2651+
agentPaused: settings.agentPaused,
2652+
agentDryRun: settings.agentDryRun,
2653+
/* 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). */
2654+
installationPermissions: installation?.permissions ?? null,
2655+
authorLogin: pr.authorLogin,
2656+
},
2657+
[
2658+
{
2659+
actionClass: "update_branch",
2660+
requiresApproval: autonomyRequiresApproval(autonomyLevel),
2661+
reason: `base branch advanced within the ${windowMinutes}m freshness window; forcing a fresh rebase + CI recheck before merge`,
2662+
expectedHeadSha: pr.headSha,
2663+
},
2664+
],
2665+
);
2666+
if (outcome?.outcome !== "completed") return false;
2667+
const nextAttempt = attempt + 1;
2668+
await putTransientKey(env, countKey, String(nextAttempt), 24 * 3600);
2669+
await recordAuditEvent(env, {
2670+
eventType: "agent.action.forced_rebase_freshness",
2671+
actor: "gittensory",
2672+
targetKey: `${repoFullName}#${pr.number}`,
2673+
outcome: "completed",
2674+
detail: `forced update_branch (attempt ${nextAttempt}/${MAX_FRESH_REBASE_FORCES}) — base advanced within the ${windowMinutes}m freshness window`,
2675+
metadata: { deliveryId, repoFullName, headSha: pr.headSha, windowMinutes, attempt: nextAttempt },
2676+
}).catch(
2677+
/* v8 ignore next -- fail-safe: an audit write failure never blocks the caller */
2678+
() => undefined,
2679+
);
2680+
return true;
2681+
}
2682+
25482683
// One CI run fires MANY check_run (one per job) + check_suite completions. Re-reviewing on every one storms the
25492684
// PR with duplicate reviews (and races the request_changes/approve dedup). reviewbot's CI_COALESCE_WINDOW parity:
25502685
// re-review a given PR at most once per this window. The re-review always re-fetches the LIVE CI, so the window

src/signals/focus-manifest.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ export type FocusManifestGateConfig = {
5353
* costs one extra, uncached GitHub Trees-API call for any PR that touches migrations/**, so it is opt-in
5454
* rather than a new default. */
5555
premergeContentRecheck: boolean | null;
56+
/** `gate.requireFreshRebaseWindow` (#2552, anti-race): minutes. When the base branch has advanced within
57+
* this window of the actual merge-decision moment, an agent-driven merge forces an `update_branch` +
58+
* fresh CI recheck cycle before merging, instead of trusting a `mergeableState: clean` read that may
59+
* already be stale relative to a sibling commit that just landed on the base. null (unset) ⇒ never force
60+
* (byte-identical to today) — a discrete positive-minutes count, not a score, so it is neither clamped
61+
* nor rounded; an invalid value (fractional, non-positive, non-finite) is dropped with a warning. */
62+
requireFreshRebaseWindowMinutes: number | null;
5663
};
5764

5865
// The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private
@@ -302,6 +309,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
302309
dryRun: null,
303310
firstTimeContributorGrace: null,
304311
premergeContentRecheck: null,
312+
requireFreshRebaseWindowMinutes: null,
305313
};
306314

307315
const EMPTY_FEATURES_CONFIG: FocusManifestFeaturesConfig = {
@@ -521,6 +529,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
521529
dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings),
522530
firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings),
523531
premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings),
532+
requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings),
524533
};
525534
// #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a
526535
// maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface
@@ -551,7 +560,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
551560
gate.selfAuthoredLinkedIssue !== null ||
552561
gate.dryRun !== null ||
553562
gate.firstTimeContributorGrace !== null ||
554-
gate.premergeContentRecheck !== null;
563+
gate.premergeContentRecheck !== null ||
564+
gate.requireFreshRebaseWindowMinutes !== null;
555565
return gate;
556566
}
557567

@@ -596,6 +606,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
596606
if (gate.dryRun !== null) out.dryRun = gate.dryRun;
597607
if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace;
598608
if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck;
609+
if (gate.requireFreshRebaseWindowMinutes !== null) out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes;
599610
return out;
600611
}
601612

@@ -1208,6 +1219,7 @@ export function resolveEffectiveSettings(
12081219
if (gate.dryRun !== null) effective.gateDryRun = gate.dryRun;
12091220
if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace;
12101221
if (gate.premergeContentRecheck !== null) effective.premergeContentRecheck = gate.premergeContentRecheck;
1222+
if (gate.requireFreshRebaseWindowMinutes !== null) effective.requireFreshRebaseWindowMinutes = gate.requireFreshRebaseWindowMinutes;
12111223
// The dashboard "Require linked issue" toggle must not silently diverge from gate blocking: when the
12121224
// boolean is on but linkedIssueGateMode is still off, treat it as a block requirement (#797).
12131225
if (effective.requireLinkedIssue && effective.linkedIssueGateMode === "off") {

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,13 @@ export type RepositorySettings = {
659659
* on top of the standing owner/admin/automation-bot exemption. Always populated by the DB layer (default
660660
* `[]`); optional so existing settings fixtures/callers need not be touched. */
661661
autoCloseExemptLogins?: string[] | undefined;
662+
/** Force-rebase-before-merge window in minutes (#2552, anti-race). When a base branch has advanced within
663+
* this many minutes of the actual merge-decision moment, an agent-driven merge forces an `update_branch` +
664+
* fresh CI recheck cycle first, rather than trusting a `mergeableState: clean` read that may already be
665+
* stale relative to a sibling commit that just landed on the base. `null`/undefined (default) = never
666+
* force -- a `mergeable_state: clean` read is trusted exactly as it is today. Layered like every other
667+
* settings field (`.gittensory.yml` `gate.requireFreshRebaseWindow` > DB > `null`). */
668+
requireFreshRebaseWindowMinutes?: number | null | undefined;
662669
/** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default
663670
* `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers
664671
* need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */

0 commit comments

Comments
 (0)