Skip to content

Commit 59eeb91

Browse files
committed
fix(settings): bound contributor open caps
1 parent a20fbae commit 59eeb91

8 files changed

Lines changed: 44 additions & 17 deletions

File tree

apps/gittensory-ui/public/openapi.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9085,13 +9085,15 @@
90859085
"type": "integer",
90869086
"nullable": true,
90879087
"minimum": 0,
9088-
"exclusiveMinimum": true
9088+
"exclusiveMinimum": true,
9089+
"maximum": 100
90899090
},
90909091
"contributorOpenIssueCap": {
90919092
"type": "integer",
90929093
"nullable": true,
90939094
"minimum": 0,
9094-
"exclusiveMinimum": true
9095+
"exclusiveMinimum": true,
9096+
"maximum": 100
90959097
},
90969098
"contributorCapLabel": {
90979099
"type": "string",

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,14 @@ function normalizeOptionalPositiveInteger(value: JsonValue | undefined, field: s
12731273
return null;
12741274
}
12751275

1276+
const MAX_CONTRIBUTOR_OPEN_ITEM_CAP = 100;
1277+
1278+
function normalizeOptionalContributorOpenItemCap(value: JsonValue | undefined, field: string, warnings: string[]): number | null {
1279+
const parsed = normalizeOptionalPositiveInteger(value, field, warnings);
1280+
if (parsed === null) return null;
1281+
return Math.min(parsed, MAX_CONTRIBUTOR_OPEN_ITEM_CAP);
1282+
}
1283+
12761284
const REVIEW_VISUAL_MAX_ROUTES_LIMIT = 5;
12771285

12781286
function normalizeOptionalVisualMaxRoutes(value: JsonValue | undefined, warnings: string[]): number | null {
@@ -1664,8 +1672,9 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
16641672
if (entries.length > 0) out.contributorBlacklist = entries;
16651673
}
16661674
// Per-contributor open PR/issue caps (#2270): discrete counts, not scores — reuse the same positive-integer
1667-
// normalizer as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning
1668-
// instead of configuring a nonsensical cap. UNLIKE contributorBlacklist above, an explicit yml `null` here is
1675+
// shape as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning
1676+
// instead of configuring a nonsensical cap. Valid counts clamp to the fixed live-verification budget. UNLIKE
1677+
// contributorBlacklist above, an explicit yml `null` here is
16691678
// load-bearing (not the same as omitting the key): the documented `yml > DB > null` precedence means a
16701679
// maintainer must be able to force a DB-configured cap back to "no cap" via `.gittensory.yml` without deleting
16711680
// the DB row. `normalizeOptionalPositiveInteger` collapses "absent" and "null" to the same silent `null`
@@ -1675,13 +1684,13 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
16751684
if (r.contributorOpenPrCap === null) {
16761685
out.contributorOpenPrCap = null;
16771686
} else {
1678-
const contributorOpenPrCap = normalizeOptionalPositiveInteger(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings);
1687+
const contributorOpenPrCap = normalizeOptionalContributorOpenItemCap(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings);
16791688
if (contributorOpenPrCap !== null) out.contributorOpenPrCap = contributorOpenPrCap;
16801689
}
16811690
if (r.contributorOpenIssueCap === null) {
16821691
out.contributorOpenIssueCap = null;
16831692
} else {
1684-
const contributorOpenIssueCap = normalizeOptionalPositiveInteger(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings);
1693+
const contributorOpenIssueCap = normalizeOptionalContributorOpenItemCap(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings);
16851694
if (contributorOpenIssueCap !== null) out.contributorOpenIssueCap = contributorOpenIssueCap;
16861695
}
16871696
// #label-scoping: same load-bearing-null idiom as blacklistLabel above.

src/db/repositories.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
webhookEvents,
6262
} from "./schema";
6363
import { DEFAULT_REVIEW_EVASION_LABEL, MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions";
64+
import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP } from "../types";
6465
import type {
6566
Advisory,
6667
AdvisoryFinding,
@@ -6757,12 +6758,12 @@ function normalizeQualityGateMinScore(value: number | null | undefined): number
67576758
}
67586759

67596760
// A per-contributor open-item cap (#2270) counts discrete open PRs/issues, not a 0-100 score, so unlike
6760-
// normalizeQualityGateMinScore it is neither clamped into a range nor rounded — a fractional or non-positive
6761-
// value is a malformed cap (there's no such thing as "allow 2.5 open PRs"), so it is dropped to null (no cap)
6762-
// rather than silently coerced into a nonsensical threshold.
6761+
// normalizeQualityGateMinScore it is not rounded — a fractional or non-positive value is a malformed cap
6762+
// (there's no such thing as "allow 2.5 open PRs"), so it is dropped to null (no cap). Valid counts are
6763+
// clamped to the fixed live-verification sample budget so the cap cannot exceed the rows enforcement sees.
67636764
function normalizeOpenItemCap(value: number | null | undefined): number | null {
67646765
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) return null;
6765-
return value;
6766+
return Math.min(value, MAX_CONTRIBUTOR_OPEN_ITEM_CAP);
67666767
}
67676768

67686769
function parsePublicSurface(value: string): RepositorySettings["publicSurface"] {

src/openapi/schemas.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod";
22
import { MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions";
3+
import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP } from "../types";
34
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
45

56
extendZodWithOpenApi(z);
@@ -733,8 +734,8 @@ export const RepositorySettingsSchema = z
733734
autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(),
734735
agentPaused: z.boolean().optional(),
735736
agentDryRun: z.boolean().optional(),
736-
contributorOpenPrCap: z.number().int().positive().nullable().optional(),
737-
contributorOpenIssueCap: z.number().int().positive().nullable().optional(),
737+
contributorOpenPrCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(),
738+
contributorOpenIssueCap: z.number().int().positive().max(MAX_CONTRIBUTOR_OPEN_ITEM_CAP).nullable().optional(),
738739
contributorCapLabel: z.string().nullable().optional(),
739740
contributorCapCancelCi: z.boolean().nullable().optional(),
740741
reviewNagPolicy: z.enum(["off", "hold", "close"]).optional(),

src/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,8 @@ export type CombineStrategy = "single" | "consensus" | "synthesis";
622622
* {@link CombineStrategy} for why the canonical definition lives here rather than `services/ai-review.ts`. */
623623
export type OnMerge = "either" | "both";
624624

625+
export const MAX_CONTRIBUTOR_OPEN_ITEM_CAP = 100;
626+
625627
export type RepositorySettings = {
626628
repoFullName: string;
627629
commentMode: "off" | "detected_contributors_only" | "all_prs";
@@ -850,11 +852,12 @@ export type RepositorySettings = {
850852
blacklistLabel?: string | null | undefined;
851853
/** Per-contributor open-PR cap (#2270, anti-abuse): the max PRs a single non-owner/admin/bot contributor may
852854
* have open on this repo at once. `null`/absent (default) = no cap, byte-identical to today. Layered like
853-
* every other settings field (`.gittensory.yml` `settings.contributorOpenPrCap` > DB > `null`). Enforcement
854-
* (closing the newest PR(s) over the cap) is a separate follow-up; this field only carries the threshold. */
855+
* every other settings field (`.gittensory.yml` `settings.contributorOpenPrCap` > DB > `null`). Capped at
856+
* {@link MAX_CONTRIBUTOR_OPEN_ITEM_CAP} so the fixed live-verification sample can enforce the threshold. */
855857
contributorOpenPrCap?: number | null | undefined;
856858
/** Per-contributor open-issue cap (#2270, anti-abuse): same shape and precedence as {@link contributorOpenPrCap},
857-
* applied to open issues instead of open PRs. `null`/absent (default) = no cap. */
859+
* applied to open issues instead of open PRs. `null`/absent (default) = no cap. Also capped at
860+
* {@link MAX_CONTRIBUTOR_OPEN_ITEM_CAP}. */
858861
contributorOpenIssueCap?: number | null | undefined;
859862
/** The label applied to a PR/issue closed for exceeding a per-contributor open-item cap (#2270). Same
860863
* configurable-with-fallback shape as {@link blacklistLabel} (including the explicit-`null`-closes-without-a-

test/unit/ci-openapi-settings-parity.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,9 @@ describe("OpenAPI settings-parity check (#2556)", () => {
4949
const schemaFields = new Set(Object.keys(RepositorySettingsSchema.shape));
5050
expect(diffFieldSets(typeFields, schemaFields)).toEqual({ missingFromSchema: [], extraInSchema: [] });
5151
});
52+
it("rejects contributor open caps above the enforcement sample budget", () => {
53+
expect(() => RepositorySettingsSchema.partial().parse({ contributorOpenPrCap: 101 })).toThrow();
54+
expect(() => RepositorySettingsSchema.partial().parse({ contributorOpenIssueCap: 101 })).toThrow();
55+
expect(RepositorySettingsSchema.partial().parse({ contributorOpenPrCap: 100, contributorOpenIssueCap: 100 })).toMatchObject({ contributorOpenPrCap: 100, contributorOpenIssueCap: 100 });
56+
});
5257
});

test/unit/data-spine.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ describe("data spine repositories", () => {
312312
expect(await getRepositorySettings(env, "owner/caprepo")).toMatchObject({ contributorOpenPrCap: 2, contributorOpenIssueCap: 5 });
313313
await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorOpenPrCap: 3, contributorOpenIssueCap: null });
314314
expect(await getRepositorySettings(env, "owner/caprepo")).toMatchObject({ contributorOpenPrCap: 3, contributorOpenIssueCap: null }); // update persists + can clear
315+
await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorOpenPrCap: 101, contributorOpenIssueCap: 150 });
316+
expect(await getRepositorySettings(env, "owner/caprepo")).toMatchObject({ contributorOpenPrCap: 100, contributorOpenIssueCap: 100 }); // clamps to the live-check sample budget
315317
// A cap must be a positive whole number: fractional, non-positive, and non-finite values are all
316318
// dropped to null rather than silently coerced (there's no such thing as "allow 2.5 open PRs").
317319
await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: 2.5 as never });

test/unit/focus-manifest.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,8 +1905,12 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
19051905
const noOverride = resolveEffectiveSettings({ contributorOpenPrCap: 4, contributorOpenIssueCap: null } as unknown as RepositorySettings, parseFocusManifest({}));
19061906
expect(noOverride.contributorOpenPrCap).toBe(4);
19071907
expect(noOverride.contributorOpenIssueCap).toBeNull();
1908-
// A cap is a discrete count, not a 0-100 score: fractional, non-positive, and non-numeric values are all
1909-
// dropped with a warning rather than silently coerced or clamped into range.
1908+
// A cap is a discrete count, not a score: over-budget valid integers clamp to the fixed enforcement
1909+
// sample, while fractional, non-positive, and non-numeric values are dropped with a warning.
1910+
const overBudget = parseFocusManifest({ settings: { contributorOpenPrCap: 101, contributorOpenIssueCap: 150 } });
1911+
expect(overBudget.settings.contributorOpenPrCap).toBe(100);
1912+
expect(overBudget.settings.contributorOpenIssueCap).toBe(100);
1913+
19101914
const invalid = parseFocusManifest({ settings: { contributorOpenPrCap: 2.5, contributorOpenIssueCap: 0 } });
19111915
expect(invalid.settings.contributorOpenPrCap).toBeUndefined();
19121916
expect(invalid.settings.contributorOpenIssueCap).toBeUndefined();

0 commit comments

Comments
 (0)