diff --git a/src/settings/global-contributor-cap.ts b/src/settings/global-contributor-cap.ts index 07a046acd9..9f9a605e08 100644 --- a/src/settings/global-contributor-cap.ts +++ b/src/settings/global-contributor-cap.ts @@ -8,16 +8,20 @@ // whole, mirroring how global_contributor_blacklist is a tenant-free singleton rather than a per-repo column. // Off by default (unset/invalid ⇒ null ⇒ no cap): zero behavior change for a single-repo install or one that // hasn't opted in. +import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP } from "../types"; + const GLOBAL_ENV_KEY = "GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP"; -/** Parse+validate the install-wide open-item cap from env. Same non-clamping, non-rounding shape as the - * per-repo caps' normalizeOpenItemCap (db/repositories.ts): a discrete count of open items, not a score, so a +/** Parse+validate the install-wide open-item cap from env. Same non-rounding shape as the per-repo caps' + * normalizeOpenItemCap (db/repositories.ts): a discrete count of open items, not a score, so a * fractional/non-positive/non-numeric value is a malformed cap and is dropped to `null` (no cap) rather than - * coerced into a nonsensical threshold. Never throws. */ + * coerced into a nonsensical threshold. Never throws. Clamped to {@link MAX_CONTRIBUTOR_OPEN_ITEM_CAP} for the + * same reason normalizeOpenItemCap is: live enforcement only ever samples a fixed 100-row budget, so a + * configured value above that is silently unenforceable. */ export function resolveGlobalContributorOpenItemCap(env: { GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string | undefined }): number | null { const raw = env[GLOBAL_ENV_KEY]; if (typeof raw !== "string" || raw.trim() === "") return null; const parsed = Number(raw); if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return null; - return parsed; + return Math.min(parsed, MAX_CONTRIBUTOR_OPEN_ITEM_CAP); } diff --git a/test/unit/global-contributor-cap.test.ts b/test/unit/global-contributor-cap.test.ts index f40af74fc0..58386be6ed 100644 --- a/test/unit/global-contributor-cap.test.ts +++ b/test/unit/global-contributor-cap.test.ts @@ -14,6 +14,13 @@ describe("resolveGlobalContributorOpenItemCap (#2562)", () => { expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "1" })).toBe(1); }); + // Live enforcement only ever samples a fixed 100-row budget (MAX_CONTRIBUTOR_OPEN_ITEM_CAP), so a configured + // value above that is silently unenforceable -- mirrors normalizeOpenItemCap's own clamp (db/repositories.ts). + it("clamps a value above the live-check sample budget to 100, and preserves exactly 100 unclamped", () => { + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "500" })).toBe(100); + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "100" })).toBe(100); + }); + it("drops a fractional/non-positive/non-numeric value to null (no cap), never coerced", () => { expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "2.5" })).toBeNull(); expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "0" })).toBeNull();