Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,16 @@ gate:
# DB-backed (dashboard-settable too); this overrides the stored value.
requireFreshRebaseWindow: 10

# Stale-base auto-rebase threshold. When the repository's current default branch is at least this many
# commits ahead of a PR's own base commit, the pre-review readiness gate forces an update_branch before
# review runs — independent of GitHub's own mergeable_state "behind" signal, which only fires when the
# repo's branch protection requires branches to be up to date before merging (a repo without that setting
# can have a branch genuinely dozens of commits behind and never see it auto-rebased otherwise). Positive
# integer (commit count), or omit/null. Default: null (never force via this path). Costs one extra GitHub
# compare-API call per non-"behind" readiness check, so it is opt-in rather than a new default.
# DB-backed (dashboard-settable too); this overrides the stored value.
staleBaseAheadByThreshold: 10

# AI maintainer review. Opt-in; the AI capabilities are switched on at the
# deployment level.
aiReview:
Expand Down
6 changes: 6 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9739,6 +9739,12 @@
},
"issuePlanMilestoneReuse": {
"type": "boolean"
},
"staleBaseAheadByThreshold": {
"type": "integer",
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true
}
},
"required": [
Expand Down
10 changes: 10 additions & 0 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,16 @@ gate:
# DB-backed (dashboard-settable too); this overrides the stored value.
requireFreshRebaseWindow: 10

# Stale-base auto-rebase threshold. When the repository's current default branch is at least this many
# commits ahead of a PR's own base commit, the pre-review readiness gate forces an update_branch before
# review runs — independent of GitHub's own mergeable_state "behind" signal, which only fires when the
# repo's branch protection requires branches to be up to date before merging (a repo without that setting
# can have a branch genuinely dozens of commits behind and never see it auto-rebased otherwise). Positive
# integer (commit count), or omit/null. Default: null (never force via this path). Costs one extra GitHub
# compare-API call per non-"behind" readiness check, so it is opt-in rather than a new default.
# DB-backed (dashboard-settable too); this overrides the stored value.
staleBaseAheadByThreshold: 10

# AI maintainer review. Opt-in; the AI capabilities are switched on at the
# deployment level.
aiReview:
Expand Down
7 changes: 7 additions & 0 deletions migrations/0169_gate_stale_base_ahead_by_threshold.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Stale-base auto-rebase threshold (#review-grounding stale-base fact): optional per-repo commit-count
-- threshold. NULL (the default) means this path never forces a rebase -- byte-identical behavior for every
-- existing row. When set, the pre-review readiness gate forces an update_branch whenever the repo's current
-- default branch is at least this many commits ahead of a PR's own base commit, independent of GitHub's own
-- mergeable_state "behind" signal (which only fires when the repo's branch protection requires branches to be
-- up to date before merging).
ALTER TABLE repository_settings ADD COLUMN stale_base_ahead_by_threshold INTEGER;
17 changes: 17 additions & 0 deletions packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,19 @@ export type FocusManifestGateConfig = {
* (byte-identical to today) — a discrete positive-minutes count, not a score, so it is neither clamped
* nor rounded; an invalid value (fractional, non-positive, non-finite) is dropped with a warning. */
requireFreshRebaseWindowMinutes: number | null;
/** `gate.staleBaseAheadByThreshold` (#review-grounding stale-base fact): a commit count. When the repo's
* current default branch is at least this many commits ahead of a PR's own base commit, the pre-review
* readiness gate forces an `update_branch` (same action class as the existing `mergeableState: "behind"`
* path in `prReadyForReview`), independent of whether GitHub itself reports `mergeableState: "behind"` —
* that signal only ever fires when the repo's branch protection has "require branches up to date before
* merging" enabled, so a repo without that setting can have a branch genuinely dozens of commits behind
* and never see it auto-rebased before review otherwise. null (unset) ⇒ never force via this path
* (byte-identical to today) — this costs one extra GitHub compare-API call per non-"behind" readiness
* check, so it is opt-in rather than a new default, mirroring requireFreshRebaseWindowMinutes's own
* opt-in-for-cost rationale directly above. A discrete positive-commit count, not a score, so it is
* neither clamped nor rounded; an invalid value (fractional, non-positive, non-finite) is dropped with a
* warning, same validation as requireFreshRebaseWindowMinutes. */
staleBaseAheadByThreshold: number | null;
/** `gate.claMode` (#2564): off/advisory/block. null (unset) ⇒ off (byte-identical to today) — a repo must
* explicitly opt in before any CLA consent check runs. */
claMode: GateRuleMode | null;
Expand Down Expand Up @@ -1243,6 +1256,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
dryRun: null,
premergeContentRecheck: null,
requireFreshRebaseWindowMinutes: null,
staleBaseAheadByThreshold: null,
claMode: null,
claConsentPhrase: null,
claCheckRunName: null,
Expand Down Expand Up @@ -1712,6 +1726,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings),
premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings),
requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings),
staleBaseAheadByThreshold: normalizeOptionalPositiveInteger(record.staleBaseAheadByThreshold, "gate.staleBaseAheadByThreshold", warnings),
claMode: normalizeOptionalGateMode(record.claMode, "gate.claMode", warnings),
claConsentPhrase: parsePublicSafeText(claRecord?.consentPhrase, "gate.cla.consentPhrase", warnings),
claCheckRunName: parsePublicSafeText(claRecord?.checkRunName, "gate.cla.checkRunName", warnings),
Expand Down Expand Up @@ -1767,6 +1782,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.dryRun !== null ||
gate.premergeContentRecheck !== null ||
gate.requireFreshRebaseWindowMinutes !== null ||
gate.staleBaseAheadByThreshold !== null ||
gate.claMode !== null ||
gate.claConsentPhrase !== null ||
gate.claCheckRunName !== null ||
Expand Down Expand Up @@ -1848,6 +1864,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
if (gate.dryRun !== null) out.dryRun = gate.dryRun;
if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck;
if (gate.requireFreshRebaseWindowMinutes !== null) out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes;
if (gate.staleBaseAheadByThreshold !== null) out.staleBaseAheadByThreshold = gate.staleBaseAheadByThreshold;
if (gate.claMode !== null) out.claMode = gate.claMode;
if (gate.claConsentPhrase !== null || gate.claCheckRunName !== null || gate.claCheckRunAppSlug !== null) {
const cla: Record<string, JsonValue> = {};
Expand Down
7 changes: 7 additions & 0 deletions packages/loopover-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,13 @@ export type RepositorySettings = {
* force -- a `mergeable_state: clean` read is trusted exactly as it is today. Layered like every other
* settings field (`.loopover.yml` `gate.requireFreshRebaseWindow` > DB > `null`). */
requireFreshRebaseWindowMinutes?: number | null | undefined;
/** Stale-base auto-rebase threshold (#review-grounding stale-base fact): a commit count. When the repo's
* current default branch is at least this many commits ahead of a PR's own base commit, the pre-review
* readiness gate forces an `update_branch` before review, independent of GitHub's own `mergeableState:
* "behind"` signal (which only fires when the repo's branch protection requires branches to be up to date).
* `null`/undefined (default) = never force via this path (byte-identical to today). Layered like every
* other settings field (`.loopover.yml` `gate.staleBaseAheadByThreshold` > DB > `null`). */
staleBaseAheadByThreshold?: number | null | undefined;
/** Account-age throttle (#2561, anti-abuse): an account younger than this many days gets the
* {@link newAccountLabel} and a tighter effective contributor cap — friction/visibility, NEVER an
* automatic close on account age alone. `null`/undefined (default) = off. Never fires for the repo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ export type FocusManifestGateConfig = {
dryRun: boolean | null;
premergeContentRecheck: boolean | null;
requireFreshRebaseWindowMinutes: number | null;
staleBaseAheadByThreshold: number | null;
claMode: GateRuleMode | null;
claConsentPhrase: string | null;
claCheckRunName: string | null;
Expand Down
1 change: 1 addition & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,7 @@ const maintainerSettingsSchema = z
agentPaused: z.boolean(),
agentDryRun: z.boolean(),
requireFreshRebaseWindowMinutes: z.number().int().positive().nullable(),
staleBaseAheadByThreshold: z.number().int().positive().nullable(),
commandAuthorization: z.object({
default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4).optional(),
commands: z.record(z.string().trim().min(1).max(64), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])).max(4)).optional(),
Expand Down
5 changes: 5 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
reviewNagMonitoredMentions: [],
autoCloseExemptLogins: [],
requireFreshRebaseWindowMinutes: null,
staleBaseAheadByThreshold: null,
accountAgeThresholdDays: null,
newAccountLabel: "new-account",
commandRateLimitPolicy: "off",
Expand Down Expand Up @@ -735,6 +736,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
reviewNagMonitoredMentions: [],
autoCloseExemptLogins: [],
requireFreshRebaseWindowMinutes: normalizePositiveIntOrNull(row.requireFreshRebaseWindowMinutes),
staleBaseAheadByThreshold: normalizePositiveIntOrNull(row.staleBaseAheadByThreshold),
accountAgeThresholdDays: null,
newAccountLabel: "new-account",
commandRateLimitPolicy: "off",
Expand Down Expand Up @@ -873,6 +875,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewNagMonitoredMentions: [] as string[],
autoCloseExemptLogins: [] as string[],
requireFreshRebaseWindowMinutes: normalizePositiveIntOrNull(settings.requireFreshRebaseWindowMinutes),
staleBaseAheadByThreshold: normalizePositiveIntOrNull(settings.staleBaseAheadByThreshold),
accountAgeThresholdDays: null,
newAccountLabel: "new-account",
commandRateLimitPolicy: "off" as const,
Expand Down Expand Up @@ -914,6 +917,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
staleBaseAheadByThreshold: resolved.staleBaseAheadByThreshold,
skipAutomationBotAuthors: resolved.skipAutomationBotAuthors,
draftPrClosePolicy: resolved.draftPrClosePolicy,
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
Expand Down Expand Up @@ -947,6 +951,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
commandAuthorizationJson: jsonString(resolved.commandAuthorization),
autonomyJson: jsonString(resolved.autonomy),
requireFreshRebaseWindowMinutes: resolved.requireFreshRebaseWindowMinutes,
staleBaseAheadByThreshold: resolved.staleBaseAheadByThreshold,
skipAutomationBotAuthors: resolved.skipAutomationBotAuthors,
draftPrClosePolicy: resolved.draftPrClosePolicy,
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
Expand Down
4 changes: 4 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export const repositorySettings = sqliteTable("repository_settings", {
// Force-rebase-before-merge window in minutes (#2552): null = never force (default). Enforcement lands in
// runAgentMaintenancePlanAndExecute, not here.
requireFreshRebaseWindowMinutes: integer("require_fresh_rebase_window_minutes"),
// Stale-base auto-rebase threshold (#review-grounding stale-base fact): a commit count; null = never force
// via this path (default). Independent of mergeableState's own "behind" signal -- enforcement lands in
// prReadyForReview, not here.
staleBaseAheadByThreshold: integer("stale_base_ahead_by_threshold"),
// Draft-PR close policy (#draft-pr-close-policy): off by default -- enforces on ANY draft (including the
// first one, before a review has run), so a maintainer opts in deliberately rather than getting it on by
// default.
Expand Down
27 changes: 16 additions & 11 deletions src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3280,28 +3280,33 @@ export async function fetchLiveBaseBranchAdvancedAt(
}

/**
* How many commits the repo's CURRENT default branch has landed since this PR's own base commit, via REST
* `GET /compare/{baseSha}...{defaultBranchRef}` (`ahead_by` from `baseSha`'s perspective — #review-grounding
* stale-base fact). `mergeable_state: "behind"` (the signal `prReadyForReview`'s auto-rebase-before-review path
* already uses) only ever fires when the repo's branch protection has "require branches to be up to date before
* merging" enabled — a repo without that setting can have a branch genuinely dozens of commits behind and GitHub
* will still never report it as "behind". This compare-API read is unconditional: it works regardless of branch
* protection config, so it can ground the AI reviewer in the TRUE fact even on a repo where the mergeable_state
* signal never fires. Best-effort: any fetch/shape error returns undefined so the caller degrades to "unknown"
* (no stale-base fact rendered) rather than throwing or asserting a wrong number.
* How many commits the repo's CURRENT default branch has landed that a PR's HEAD commit doesn't have, via REST
* `GET /compare/{prHeadSha}...{defaultBranchRef}` (`ahead_by` from `prHeadSha`'s perspective — #review-grounding
* stale-base fact). Deliberately anchored on the PR's HEAD, not its `base.sha` -- GitHub updates `base.sha` to
* track the LIVE tip of the target branch as it moves, so comparing THAT against the current default branch
* would read ~0 regardless of how stale the PR's actual code is; `headSha` is the PR's real current code, and
* the compare API computes the true git merge-base against it, independent of any GitHub-side metadata timing.
* `mergeable_state: "behind"` (the signal `prReadyForReview`'s auto-rebase-before-review path already uses)
* only ever fires when the repo's branch protection has "require branches to be up to date before merging"
* enabled — a repo without that setting can have a branch genuinely dozens of commits behind and GitHub will
* still never report it as "behind". This compare-API read is unconditional: it works regardless of branch
* protection config, so it can ground the AI reviewer (or gate the auto-rebase path) in the TRUE fact even on a
* repo where the mergeable_state signal never fires. Best-effort: any fetch/shape error returns undefined so
* the caller degrades to "unknown" (no stale-base fact rendered / no forced rebase) rather than throwing or
* asserting a wrong number.
*/
export async function fetchBaseAheadBy(
env: Env,
repoFullName: string,
baseSha: string,
prHeadSha: string,
defaultBranchRef: string,
token: string | undefined,
admissionKey?: GitHubRateLimitAdmissionKey,
): Promise<number | undefined> {
const result = await githubJsonWithHeaders<{ ahead_by?: number | null }>(
env,
repoFullName,
`/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(defaultBranchRef)}`,
`/compare/${encodeURIComponent(prHeadSha)}...${encodeURIComponent(defaultBranchRef)}`,
token,
githubRateLimitOptions(admissionKey),
).catch(() => undefined);
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,7 @@ export const RepositorySettingsSchema = z
gateDryRun: z.boolean().optional(),
premergeContentRecheck: z.boolean().optional(),
requireFreshRebaseWindowMinutes: z.number().int().positive().nullable().optional(),
staleBaseAheadByThreshold: z.number().int().positive().nullable().optional(),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),
Expand Down
8 changes: 4 additions & 4 deletions src/queue/ai-review-orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,10 +547,10 @@ export async function runAiReviewForAdvisory(
args.pr.number,
),
installationId: repo?.installationId ?? null,
// #review-grounding stale-base fact (metagraphed #7305-class incident): both are additive — either
// absent (no baseSha on a rare malformed webhook record, or an unregistered repo with no stored
// defaultBranch) just skips the BASE BRANCH STATUS fact, same as before it existed.
baseSha: args.pr.baseSha,
// #review-grounding stale-base fact (metagraphed #7305-class incident): additive — reuses the
// SAME headSha already passed above (the PR's real current code, not its base.sha, which GitHub
// keeps pointed at the live target-branch tip regardless of staleness). An unregistered repo with
// no stored defaultBranch just skips the BASE BRANCH STATUS fact, same as before it existed.
defaultBranchRef: repo?.defaultBranch,
});
})()
Expand Down
Loading
Loading