Skip to content

Commit f56edd2

Browse files
authored
Merge pull request #5997 from JSONbored/fix/config-size-gate-thresholds
fix(review): make size-gate file/line thresholds configurable
2 parents 3ec7caa + df54527 commit f56edd2

16 files changed

Lines changed: 117 additions & 32 deletions

File tree

.loopover.yml.example

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,12 +202,15 @@ gate:
202202
minScore: null
203203

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

212215
# Lockfile-tamper-risk gate. Scans a changed package-lock.json diff for a
213216
# resolved/integrity value that changed WITHOUT the same package's version

apps/loopover-ui/public/openapi.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9659,6 +9659,12 @@
96599659
"appSlug"
96609660
]
96619661
}
9662+
},
9663+
"sizeGateMaxFiles": {
9664+
"type": "number"
9665+
},
9666+
"sizeGateMaxLines": {
9667+
"type": "number"
96629668
}
96639669
},
96649670
"required": [

config/examples/loopover.full.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,15 @@ gate:
216216
minScore: null
217217

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

226229
# Lockfile-tamper-risk gate. Scans a changed package-lock.json diff for a
227230
# resolved/integrity value that changed WITHOUT the same package's version

packages/loopover-engine/src/advisory/gate-advisory.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,13 @@ export type GateCheckPolicy = {
101101
/** PR-size HOLD (#gate-size). When set (advisory/block), a PR with >= sizeGateMaxFiles changed files OR
102102
* >= sizeGateMaxLines changed (added+deleted) lines that would OTHERWISE pass is HELD for manual review — a
103103
* neutral gate → "manual" verdict, never auto-merged and never a hard failure. Defaults off; thresholds default
104-
* to 10 files / 1000 lines. This is a HOLD (advisory dry-run friendly), not a close. */
104+
* to 10 files / 1000 lines when sizeGateMaxFiles/sizeGateMaxLines are unset. This is a HOLD (advisory dry-run
105+
* friendly), not a close. */
105106
sizeGateMode?: GateRuleMode | undefined;
107+
/** PR-size HOLD file-count threshold (#gate-size). `null`/undefined ⇒ the 10-file default. */
108+
sizeGateMaxFiles?: number | null | undefined;
109+
/** PR-size HOLD changed-line-count threshold (#gate-size). `null`/undefined ⇒ the 1000-line default. */
110+
sizeGateMaxLines?: number | null | undefined;
106111
/** Lockfile-tamper-risk gate (#2563). When `block`, a `lockfile_tamper_risk` finding (produced by
107112
* review/lockfile-tamper.ts when a changed package-lock.json's resolved/integrity value changed without a
108113
* matching package.json version bump, or points off the npm registry) becomes a hard blocker. Defaults to
@@ -388,16 +393,16 @@ function buildSizeHoldFinding(policy: GateCheckPolicy): AdvisoryFinding | null {
388393
if (files === undefined || files === null) files = 0;
389394
let lines = policy.changedLineCount;
390395
if (lines === undefined || lines === null) lines = 0;
391-
if (
392-
files < SIZE_HOLD_DEFAULT_MAX_FILES &&
393-
lines < SIZE_HOLD_DEFAULT_MAX_LINES
394-
)
395-
return null;
396+
let maxFiles = policy.sizeGateMaxFiles;
397+
if (maxFiles === undefined || maxFiles === null) maxFiles = SIZE_HOLD_DEFAULT_MAX_FILES;
398+
let maxLines = policy.sizeGateMaxLines;
399+
if (maxLines === undefined || maxLines === null) maxLines = SIZE_HOLD_DEFAULT_MAX_LINES;
400+
if (files < maxFiles && lines < maxLines) return null;
396401
return {
397402
code: "oversized_pr",
398403
severity: "warning",
399404
title: "Large change — held for manual review",
400-
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).`,
405+
detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${maxFiles} files or ${maxLines} lines).`,
401406
action: "Split this into smaller, focused PRs, or a maintainer reviews and merges it manually.",
402407
};
403408
}

packages/loopover-engine/src/focus-manifest.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export type FocusManifestGateConfig = {
102102
slopMinScore: number | null;
103103
slopAiAdvisory: boolean | null;
104104
sizeMode: GateRuleMode | null;
105+
sizeMaxFiles: number | null;
106+
sizeMaxLines: number | null;
105107
/** `gate.lockfileIntegrity` (#2563): off|advisory|block, off by default. When not off, a changed
106108
* `package-lock.json` diff is scanned for a `resolved`/`integrity` change unaccompanied by a matching
107109
* `package.json` version bump, or a `resolved` URL outside `registry.npmjs.org` — a `lockfile_tamper_risk`
@@ -982,6 +984,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
982984
slopMinScore: null,
983985
slopAiAdvisory: null,
984986
sizeMode: null,
987+
sizeMaxFiles: null,
988+
sizeMaxLines: null,
985989
lockfileIntegrityMode: null,
986990
aiReviewMode: null,
987991
aiReviewByok: null,
@@ -1385,6 +1389,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
13851389
slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings),
13861390
slopAiAdvisory: normalizeOptionalBoolean(slopRecord?.aiAdvisory, "gate.slop.aiAdvisory", warnings),
13871391
sizeMode: normalizeOptionalGateMode(sizeRecord?.mode, "gate.size.mode", warnings),
1392+
sizeMaxFiles: normalizeOptionalPositiveInteger(sizeRecord?.maxFiles, "gate.size.maxFiles", warnings),
1393+
sizeMaxLines: normalizeOptionalPositiveInteger(sizeRecord?.maxLines, "gate.size.maxLines", warnings),
13881394
lockfileIntegrityMode: normalizeOptionalGateMode(record.lockfileIntegrity, "gate.lockfileIntegrity", warnings),
13891395
aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings),
13901396
aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings),
@@ -1451,6 +1457,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
14511457
gate.slopMinScore !== null ||
14521458
gate.slopAiAdvisory !== null ||
14531459
gate.sizeMode !== null ||
1460+
gate.sizeMaxFiles !== null ||
1461+
gate.sizeMaxLines !== null ||
14541462
gate.lockfileIntegrityMode !== null ||
14551463
gate.aiReviewMode !== null ||
14561464
gate.aiReviewByok !== null ||
@@ -1500,7 +1508,13 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
15001508
if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore;
15011509
out.readiness = readiness;
15021510
}
1503-
if (gate.sizeMode !== null) out.size = { mode: gate.sizeMode };
1511+
if (gate.sizeMode !== null || gate.sizeMaxFiles !== null || gate.sizeMaxLines !== null) {
1512+
const size: Record<string, JsonValue> = {};
1513+
if (gate.sizeMode !== null) size.mode = gate.sizeMode;
1514+
if (gate.sizeMaxFiles !== null) size.maxFiles = gate.sizeMaxFiles;
1515+
if (gate.sizeMaxLines !== null) size.maxLines = gate.sizeMaxLines;
1516+
out.size = size;
1517+
}
15041518
if (gate.lockfileIntegrityMode !== null) out.lockfileIntegrity = gate.lockfileIntegrityMode;
15051519
if (gate.slopMode !== null || gate.slopMinScore !== null || gate.slopAiAdvisory !== null) {
15061520
const slop: Record<string, JsonValue> = {};

packages/loopover-engine/src/predicted-gate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ export function buildPredictedGateVerdict(args: {
308308
// never sent to this predictor, so the size hold can only be predicted from file count (disclosed in the
309309
// note above) — never claim a line count this function has no way to know.
310310
sizeGateMode: gate.sizeMode ?? undefined,
311+
sizeGateMaxFiles: gate.sizeMaxFiles ?? undefined,
312+
sizeGateMaxLines: gate.sizeMaxLines ?? undefined,
311313
...(hasChangedPaths
312314
? {
313315
changedFileCount: changedPaths.length,

packages/loopover-engine/src/types/manifest-deps-types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,16 @@ export type RepositorySettings = {
185185
* only, applies to every author like every blocker). Default `off` — opt-in via .loopover.yml. */
186186
slopGateMode: GateRuleMode;
187187
/** PR-size manual-review HOLD (#gate-size). `off` (default/absent) = no size hold; `advisory`/`block` = a PR with
188-
* >= 10 changed files OR >= 1000 changed (added+deleted) lines that would otherwise pass is HELD for manual review
189-
* (neutral gate → "manual" verdict), never auto-merged and never a hard failure. Opt-in via `gate.size.mode`. */
188+
* >= sizeGateMaxFiles changed files OR >= sizeGateMaxLines changed (added+deleted) lines that would otherwise
189+
* pass is HELD for manual review (neutral gate → "manual" verdict), never auto-merged and never a hard failure.
190+
* Opt-in via `gate.size.mode`. */
190191
sizeGateMode?: GateRuleMode | undefined;
192+
/** PR-size HOLD file-count threshold (#gate-size), config-only — set via `.loopover.yml gate.size.maxFiles`.
193+
* `undefined` ⇒ the 10-file default. */
194+
sizeGateMaxFiles?: number | undefined;
195+
/** PR-size HOLD changed-line-count threshold (#gate-size), config-only — set via `.loopover.yml
196+
* gate.size.maxLines`. `undefined` ⇒ the 1000-line default. */
197+
sizeGateMaxLines?: number | undefined;
191198
/** Lockfile-tamper-risk gate (#2563). `off` (default/absent) = no scan; `advisory`/`block` = a changed
192199
* `package-lock.json` whose diff changes a `resolved`/`integrity` value WITHOUT the same package's version
193200
* changing in a changed `package.json`, or whose `resolved` URL points outside `registry.npmjs.org`, produces

packages/loopover-engine/src/types/predicted-gate-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ export type FocusManifestGateConfig = {
292292
slopMinScore: number | null;
293293
slopAiAdvisory: boolean | null;
294294
sizeMode: GateRuleMode | null;
295+
sizeMaxFiles: number | null;
296+
sizeMaxLines: number | null;
295297
lockfileIntegrityMode: GateRuleMode | null;
296298
aiReviewMode: GateRuleMode | null;
297299
aiReviewByok: boolean | null;

scripts/check-docs-drift.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export const SETTINGS_ALIAS_MANIFEST = [
135135
{ field: "aiReviewOnMerge", aliases: ["onMerge"] },
136136
{ field: "aiReviewReviewers", aliases: ["reviewers:"] },
137137
{ field: "requireFreshRebaseWindowMinutes", aliases: ["requireFreshRebaseWindow"] },
138+
{ field: "sizeGateMaxFiles", aliases: ["maxFiles"] },
139+
{ field: "sizeGateMaxLines", aliases: ["maxLines"] },
138140
];
139141

140142
/** camelCase -> snake_case, matching the casing convention `.loopover.yml`'s `review:` block (and everything

src/openapi/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,8 @@ export const RepositorySettingsSchema = z
681681
qualityGateMinScore: z.number().nullable().optional(),
682682
slopGateMode: z.enum(["off", "advisory", "block"]),
683683
sizeGateMode: z.enum(["off", "advisory", "block"]).optional(),
684+
sizeGateMaxFiles: z.number().optional(),
685+
sizeGateMaxLines: z.number().optional(),
684686
lockfileIntegrityGateMode: z.enum(["off", "advisory", "block"]).optional(),
685687
claGateMode: z.enum(["off", "advisory", "block"]).optional(),
686688
claConsentPhrase: z.string().nullable().optional(),

0 commit comments

Comments
 (0)