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
11 changes: 7 additions & 4 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,15 @@ gate:
minScore: null

# Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold
# (engine defaults, not configurable here) gets a manual-review HOLD finding —
# never a hard blocker; mode only turns this hold signal on or off.
# off | advisory | block. Default: off. Config-as-code only — no DB column or
# dashboard toggle; this can only be set here.
# gets a manual-review HOLD finding — never a hard blocker; mode only turns this
# hold signal on or off. off | advisory | block. Default: off. Config-as-code
# only — no DB column or dashboard toggle; this can only be set here.
size:
mode: off
# File-count threshold. Positive integer. Default: 10.
# maxFiles: 10
# Changed (added+deleted) line-count threshold. Positive integer. Default: 1000.
# maxLines: 1000

# Lockfile-tamper-risk gate. Scans a changed package-lock.json diff for a
# resolved/integrity value that changed WITHOUT the same package's version
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 @@ -9659,6 +9659,12 @@
"appSlug"
]
}
},
"sizeGateMaxFiles": {
"type": "number"
},
"sizeGateMaxLines": {
"type": "number"
}
},
"required": [
Expand Down
11 changes: 7 additions & 4 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,15 @@ gate:
minScore: null

# Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold
# (engine defaults, not configurable here) gets a manual-review HOLD finding —
# never a hard blocker; mode only turns this hold signal on or off.
# off | advisory | block. Default: off. Config-as-code only — no DB column or
# dashboard toggle; this can only be set here.
# gets a manual-review HOLD finding — never a hard blocker; mode only turns this
# hold signal on or off. off | advisory | block. Default: off. Config-as-code
# only — no DB column or dashboard toggle; this can only be set here.
size:
mode: off
# File-count threshold. Positive integer. Default: 10.
# maxFiles: 10
# Changed (added+deleted) line-count threshold. Positive integer. Default: 1000.
# maxLines: 1000

# Lockfile-tamper-risk gate. Scans a changed package-lock.json diff for a
# resolved/integrity value that changed WITHOUT the same package's version
Expand Down
19 changes: 12 additions & 7 deletions packages/loopover-engine/src/advisory/gate-advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,13 @@ export type GateCheckPolicy = {
/** PR-size HOLD (#gate-size). When set (advisory/block), a PR with >= sizeGateMaxFiles changed files OR
* >= sizeGateMaxLines changed (added+deleted) lines that would OTHERWISE pass is HELD for manual review — a
* neutral gate → "manual" verdict, never auto-merged and never a hard failure. Defaults off; thresholds default
* to 10 files / 1000 lines. This is a HOLD (advisory dry-run friendly), not a close. */
* to 10 files / 1000 lines when sizeGateMaxFiles/sizeGateMaxLines are unset. This is a HOLD (advisory dry-run
* friendly), not a close. */
sizeGateMode?: GateRuleMode | undefined;
/** PR-size HOLD file-count threshold (#gate-size). `null`/undefined ⇒ the 10-file default. */
sizeGateMaxFiles?: number | null | undefined;
/** PR-size HOLD changed-line-count threshold (#gate-size). `null`/undefined ⇒ the 1000-line default. */
sizeGateMaxLines?: number | null | undefined;
/** Lockfile-tamper-risk gate (#2563). When `block`, a `lockfile_tamper_risk` finding (produced by
* review/lockfile-tamper.ts when a changed package-lock.json's resolved/integrity value changed without a
* matching package.json version bump, or points off the npm registry) becomes a hard blocker. Defaults to
Expand Down Expand Up @@ -388,16 +393,16 @@ function buildSizeHoldFinding(policy: GateCheckPolicy): AdvisoryFinding | null {
if (files === undefined || files === null) files = 0;
let lines = policy.changedLineCount;
if (lines === undefined || lines === null) lines = 0;
if (
files < SIZE_HOLD_DEFAULT_MAX_FILES &&
lines < SIZE_HOLD_DEFAULT_MAX_LINES
)
return null;
let maxFiles = policy.sizeGateMaxFiles;
if (maxFiles === undefined || maxFiles === null) maxFiles = SIZE_HOLD_DEFAULT_MAX_FILES;
let maxLines = policy.sizeGateMaxLines;
if (maxLines === undefined || maxLines === null) maxLines = SIZE_HOLD_DEFAULT_MAX_LINES;
if (files < maxFiles && lines < maxLines) return null;
return {
code: "oversized_pr",
severity: "warning",
title: "Large change — held for manual review",
detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${SIZE_HOLD_DEFAULT_MAX_FILES} files or ${SIZE_HOLD_DEFAULT_MAX_LINES} lines).`,
detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${maxFiles} files or ${maxLines} lines).`,
action: "Split this into smaller, focused PRs, or a maintainer reviews and merges it manually.",
};
}
Expand Down
16 changes: 15 additions & 1 deletion packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export type FocusManifestGateConfig = {
slopMinScore: number | null;
slopAiAdvisory: boolean | null;
sizeMode: GateRuleMode | null;
sizeMaxFiles: number | null;
sizeMaxLines: number | null;
/** `gate.lockfileIntegrity` (#2563): off|advisory|block, off by default. When not off, a changed
* `package-lock.json` diff is scanned for a `resolved`/`integrity` change unaccompanied by a matching
* `package.json` version bump, or a `resolved` URL outside `registry.npmjs.org` — a `lockfile_tamper_risk`
Expand Down Expand Up @@ -981,6 +983,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
slopMinScore: null,
slopAiAdvisory: null,
sizeMode: null,
sizeMaxFiles: null,
sizeMaxLines: null,
lockfileIntegrityMode: null,
aiReviewMode: null,
aiReviewByok: null,
Expand Down Expand Up @@ -1384,6 +1388,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings),
slopAiAdvisory: normalizeOptionalBoolean(slopRecord?.aiAdvisory, "gate.slop.aiAdvisory", warnings),
sizeMode: normalizeOptionalGateMode(sizeRecord?.mode, "gate.size.mode", warnings),
sizeMaxFiles: normalizeOptionalPositiveInteger(sizeRecord?.maxFiles, "gate.size.maxFiles", warnings),
sizeMaxLines: normalizeOptionalPositiveInteger(sizeRecord?.maxLines, "gate.size.maxLines", warnings),
lockfileIntegrityMode: normalizeOptionalGateMode(record.lockfileIntegrity, "gate.lockfileIntegrity", warnings),
aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings),
aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings),
Expand Down Expand Up @@ -1450,6 +1456,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.slopMinScore !== null ||
gate.slopAiAdvisory !== null ||
gate.sizeMode !== null ||
gate.sizeMaxFiles !== null ||
gate.sizeMaxLines !== null ||
gate.lockfileIntegrityMode !== null ||
gate.aiReviewMode !== null ||
gate.aiReviewByok !== null ||
Expand Down Expand Up @@ -1499,7 +1507,13 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore;
out.readiness = readiness;
}
if (gate.sizeMode !== null) out.size = { mode: gate.sizeMode };
if (gate.sizeMode !== null || gate.sizeMaxFiles !== null || gate.sizeMaxLines !== null) {
const size: Record<string, JsonValue> = {};
if (gate.sizeMode !== null) size.mode = gate.sizeMode;
if (gate.sizeMaxFiles !== null) size.maxFiles = gate.sizeMaxFiles;
if (gate.sizeMaxLines !== null) size.maxLines = gate.sizeMaxLines;
out.size = size;
}
if (gate.lockfileIntegrityMode !== null) out.lockfileIntegrity = gate.lockfileIntegrityMode;
if (gate.slopMode !== null || gate.slopMinScore !== null || gate.slopAiAdvisory !== null) {
const slop: Record<string, JsonValue> = {};
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-engine/src/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ export function buildPredictedGateVerdict(args: {
// never sent to this predictor, so the size hold can only be predicted from file count (disclosed in the
// note above) — never claim a line count this function has no way to know.
sizeGateMode: gate.sizeMode ?? undefined,
sizeGateMaxFiles: gate.sizeMaxFiles ?? undefined,
sizeGateMaxLines: gate.sizeMaxLines ?? undefined,
...(hasChangedPaths
? {
changedFileCount: changedPaths.length,
Expand Down
11 changes: 9 additions & 2 deletions packages/loopover-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,16 @@ export type RepositorySettings = {
* only, applies to every author like every blocker). Default `off` — opt-in via .loopover.yml. */
slopGateMode: GateRuleMode;
/** PR-size manual-review HOLD (#gate-size). `off` (default/absent) = no size hold; `advisory`/`block` = a PR with
* >= 10 changed files OR >= 1000 changed (added+deleted) lines that would otherwise pass is HELD for manual review
* (neutral gate → "manual" verdict), never auto-merged and never a hard failure. Opt-in via `gate.size.mode`. */
* >= sizeGateMaxFiles changed files OR >= sizeGateMaxLines changed (added+deleted) lines that would otherwise
* pass is HELD for manual review (neutral gate → "manual" verdict), never auto-merged and never a hard failure.
* Opt-in via `gate.size.mode`. */
sizeGateMode?: GateRuleMode | undefined;
/** PR-size HOLD file-count threshold (#gate-size), config-only — set via `.loopover.yml gate.size.maxFiles`.
* `undefined` ⇒ the 10-file default. */
sizeGateMaxFiles?: number | undefined;
/** PR-size HOLD changed-line-count threshold (#gate-size), config-only — set via `.loopover.yml
* gate.size.maxLines`. `undefined` ⇒ the 1000-line default. */
sizeGateMaxLines?: number | undefined;
/** Lockfile-tamper-risk gate (#2563). `off` (default/absent) = no scan; `advisory`/`block` = a changed
* `package-lock.json` whose diff changes a `resolved`/`integrity` value WITHOUT the same package's version
* changing in a changed `package.json`, or whose `resolved` URL points outside `registry.npmjs.org`, produces
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-engine/src/types/predicted-gate-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ export type FocusManifestGateConfig = {
slopMinScore: number | null;
slopAiAdvisory: boolean | null;
sizeMode: GateRuleMode | null;
sizeMaxFiles: number | null;
sizeMaxLines: number | null;
lockfileIntegrityMode: GateRuleMode | null;
aiReviewMode: GateRuleMode | null;
aiReviewByok: boolean | null;
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-docs-drift.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export const SETTINGS_ALIAS_MANIFEST = [
{ field: "aiReviewOnMerge", aliases: ["onMerge"] },
{ field: "aiReviewReviewers", aliases: ["reviewers:"] },
{ field: "requireFreshRebaseWindowMinutes", aliases: ["requireFreshRebaseWindow"] },
{ field: "sizeGateMaxFiles", aliases: ["maxFiles"] },
{ field: "sizeGateMaxLines", aliases: ["maxLines"] },
];

/** camelCase -> snake_case, matching the casing convention `.loopover.yml`'s `review:` block (and everything
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,8 @@ export const RepositorySettingsSchema = z
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
sizeGateMode: z.enum(["off", "advisory", "block"]).optional(),
sizeGateMaxFiles: z.number().optional(),
sizeGateMaxLines: z.number().optional(),
lockfileIntegrityGateMode: z.enum(["off", "advisory", "block"]).optional(),
claGateMode: z.enum(["off", "advisory", "block"]).optional(),
claConsentPhrase: z.string().nullable().optional(),
Expand Down
8 changes: 5 additions & 3 deletions src/queue/gate-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,12 @@ export function gateCheckPolicy(
slopGateMinScore: settings.slopGateMinScore ?? null,
slopRisk: slopRisk ?? null,
confirmedContributor: confirmedContributorForPack,
// PR-size + guardrail manual-review HOLD (#gate-size / #gate-guardrail): the MODE comes from config; the
// thresholds default to 10 files / 1000 lines (advisory.ts constants); the live counts + guardrail-hit come from
// the per-PR sizeContext threaded by the caller.
// PR-size + guardrail manual-review HOLD (#gate-size / #gate-guardrail): the mode AND thresholds come from
// config (`gate.size.mode`/`maxFiles`/`maxLines`), falling back to advisory.ts's 10-file/1000-line constants
// when unset; the live counts + guardrail-hit come from the per-PR sizeContext threaded by the caller.
sizeGateMode: settings.sizeGateMode,
sizeGateMaxFiles: settings.sizeGateMaxFiles ?? null,
sizeGateMaxLines: settings.sizeGateMaxLines ?? null,
lockfileIntegrityGateMode: settings.lockfileIntegrityGateMode,
changedFileCount: sizeContext?.changedFileCount ?? null,
changedLineCount: sizeContext?.changedLineCount ?? null,
Expand Down
17 changes: 10 additions & 7 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,13 @@ export type GateCheckPolicy = {
/** PR-size HOLD (#gate-size). When set (advisory/block), a PR with >= sizeGateMaxFiles changed files OR
* >= sizeGateMaxLines changed (added+deleted) lines that would OTHERWISE pass is HELD for manual review — a
* neutral gate → "manual" verdict, never auto-merged and never a hard failure. Defaults off; thresholds default
* to 10 files / 1000 lines. This is a HOLD (advisory dry-run friendly), not a close. */
* to 10 files / 1000 lines when sizeGateMaxFiles/sizeGateMaxLines are unset. This is a HOLD (advisory dry-run
* friendly), not a close. */
sizeGateMode?: GateRuleMode | undefined;
/** PR-size HOLD file-count threshold (#gate-size). `null`/undefined ⇒ the 10-file default. */
sizeGateMaxFiles?: number | null | undefined;
/** PR-size HOLD changed-line-count threshold (#gate-size). `null`/undefined ⇒ the 1000-line default. */
sizeGateMaxLines?: number | null | undefined;
/** Lockfile-tamper-risk gate (#2563). When `block`, a `lockfile_tamper_risk` finding (produced by
* review/lockfile-tamper.ts when a changed package-lock.json's resolved/integrity value changed without a
* matching package.json version bump, or points off the npm registry) becomes a hard blocker. Defaults to
Expand Down Expand Up @@ -517,16 +522,14 @@ function buildSizeHoldFinding(policy: GateCheckPolicy): AdvisoryFinding | null {
if (!policy.sizeGateMode || policy.sizeGateMode === "off") return null;
const files = policy.changedFileCount ?? 0;
const lines = policy.changedLineCount ?? 0;
if (
files < SIZE_HOLD_DEFAULT_MAX_FILES &&
lines < SIZE_HOLD_DEFAULT_MAX_LINES
)
return null;
const maxFiles = policy.sizeGateMaxFiles ?? SIZE_HOLD_DEFAULT_MAX_FILES;
const maxLines = policy.sizeGateMaxLines ?? SIZE_HOLD_DEFAULT_MAX_LINES;
if (files < maxFiles && lines < maxLines) return null;
return {
code: "oversized_pr",
severity: "warning",
title: "Large change — held for manual review",
detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${SIZE_HOLD_DEFAULT_MAX_FILES} files or ${SIZE_HOLD_DEFAULT_MAX_LINES} lines).`,
detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${maxFiles} files or ${maxLines} lines).`,
action: "Split this into smaller, focused PRs, or a maintainer reviews and merges it manually.",
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,8 @@ function applyGateConfigOverrides(effective: RepositorySettings, gate: FocusMani
if (gate.readinessMode !== null) effective.qualityGateMode = gate.readinessMode;
if (gate.readinessMinScore !== null) effective.qualityGateMinScore = gate.readinessMinScore;
if (gate.sizeMode !== null) effective.sizeGateMode = gate.sizeMode;
if (gate.sizeMaxFiles !== null) effective.sizeGateMaxFiles = gate.sizeMaxFiles;
if (gate.sizeMaxLines !== null) effective.sizeGateMaxLines = gate.sizeMaxLines;
if (gate.lockfileIntegrityMode !== null) effective.lockfileIntegrityGateMode = gate.lockfileIntegrityMode;
if (gate.slopMode !== null) effective.slopGateMode = gate.slopMode;
if (gate.slopMinScore !== null) effective.slopGateMinScore = gate.slopMinScore;
Expand Down
11 changes: 9 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,9 +755,16 @@ export type RepositorySettings = {
* only, applies to every author like every blocker). Default `off` — opt-in via .loopover.yml. */
slopGateMode: GateRuleMode;
/** PR-size manual-review HOLD (#gate-size). `off` (default/absent) = no size hold; `advisory`/`block` = a PR with
* >= 10 changed files OR >= 1000 changed (added+deleted) lines that would otherwise pass is HELD for manual review
* (neutral gate → "manual" verdict), never auto-merged and never a hard failure. Opt-in via `gate.size.mode`. */
* >= sizeGateMaxFiles changed files OR >= sizeGateMaxLines changed (added+deleted) lines that would otherwise
* pass is HELD for manual review (neutral gate → "manual" verdict), never auto-merged and never a hard failure.
* Opt-in via `gate.size.mode`. */
sizeGateMode?: GateRuleMode | undefined;
/** PR-size HOLD file-count threshold (#gate-size), config-only (no DB column, mirrors sizeGateMode) — set via
* `.loopover.yml gate.size.maxFiles`. `undefined` ⇒ the 10-file default (src/rules/advisory.ts). */
sizeGateMaxFiles?: number | undefined;
/** PR-size HOLD changed-line-count threshold (#gate-size), config-only (no DB column, mirrors sizeGateMode) —
* set via `.loopover.yml gate.size.maxLines`. `undefined` ⇒ the 1000-line default (src/rules/advisory.ts). */
sizeGateMaxLines?: number | undefined;
/** Lockfile-tamper-risk gate (#2563). `off` (default/absent) = no scan; `advisory`/`block` = a changed
* `package-lock.json` whose diff changes a `resolved`/`integrity` value WITHOUT the same package's version
* changing in a changed `package.json`, or whose `resolved` URL points outside `registry.npmjs.org`, produces
Expand Down
Loading
Loading