diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 927e9ac44..9527d8c88 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -261,3 +261,12 @@ settings: # default: [maintainer, collaborator, confirmed_miner] # commands: # gate-override: [maintainer, collaborator] + + # Per-contributor open-PR/open-issue caps (#2270, anti-abuse): the max PRs/issues a single + # non-owner/non-admin/non-bot contributor may have open on this repo at once. These fields are + # currently INERT (stored and parsed, not yet enforced) — enforcement (closing the newest item(s) + # above the cap with a clear reason) lands in a follow-up PR; see #2270 for status. Positive whole + # number, or omit/null for no cap. Default: null (disabled) for both. Set explicitly to `null` (not + # just omitted) to force-clear a cap that a dashboard/DB write previously set. + # contributorOpenPrCap: 2 + # contributorOpenIssueCap: 5 diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 99f63791d..b519efcee 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8589,6 +8589,18 @@ }, "blacklistLabel": { "type": "string" + }, + "contributorOpenPrCap": { + "type": "integer", + "nullable": true, + "minimum": 0, + "exclusiveMinimum": true + }, + "contributorOpenIssueCap": { + "type": "integer", + "nullable": true, + "minimum": 0, + "exclusiveMinimum": true } }, "required": [ diff --git a/migrations/0089_contributor_open_caps.sql b/migrations/0089_contributor_open_caps.sql new file mode 100644 index 000000000..0d0df64fd --- /dev/null +++ b/migrations/0089_contributor_open_caps.sql @@ -0,0 +1,5 @@ +-- Per-contributor open PR/issue caps (#2270, anti-abuse): optional per-repo ceilings on how many PRs/issues a +-- single contributor may have open at once. NULL (the default) means no cap — byte-identical behavior for every +-- existing row. Enforcement (auto-close over the cap) lands in a follow-up PR; this column only carries config. +ALTER TABLE repository_settings ADD COLUMN contributor_open_pr_cap INTEGER; +ALTER TABLE repository_settings ADD COLUMN contributor_open_issue_cap INTEGER; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index c18a9f59f..a53dc2b73 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -501,6 +501,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise contributorBlacklist: [], autonomy: {}, autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY }, + contributorOpenPrCap: null, + contributorOpenIssueCap: null, }; } return { @@ -545,6 +547,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise contributorBlacklist: parseContributorBlacklist(row.contributorBlacklistJson), autonomy: parseAutonomyPolicy(row.autonomyJson), autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson), + contributorOpenPrCap: normalizeOpenItemCap(row.contributorOpenPrCap), + contributorOpenIssueCap: normalizeOpenItemCap(row.contributorOpenIssueCap), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -621,6 +625,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index f6766d071..ebb45414e 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -630,6 +630,8 @@ export const RepositorySettingsSchema = z autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(), agentPaused: z.boolean().optional(), agentDryRun: z.boolean().optional(), + contributorOpenPrCap: z.number().int().positive().nullable().optional(), + contributorOpenIssueCap: z.number().int().positive().nullable().optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 6ec35db00..3f8347fab 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -125,6 +125,8 @@ export type FocusManifestSettings = Partial< | "commandAuthorization" | "contributorBlacklist" | "blacklistLabel" + | "contributorOpenPrCap" + | "contributorOpenIssueCap" > >; @@ -792,6 +794,27 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) warnings.push(...blacklistWarnings); if (entries.length > 0) out.contributorBlacklist = entries; } + // Per-contributor open PR/issue caps (#2270): discrete counts, not scores — reuse the same positive-integer + // normalizer as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning + // instead of configuring a nonsensical cap. UNLIKE contributorBlacklist above, an explicit yml `null` here is + // load-bearing (not the same as omitting the key): the documented `yml > DB > null` precedence means a + // maintainer must be able to force a DB-configured cap back to "no cap" via `.gittensory.yml` without deleting + // the DB row. `normalizeOptionalPositiveInteger` collapses "absent" and "null" to the same silent `null` + // return, so that distinction has to be made HERE, before calling it: a literal `null` sets the key to `null` + // (clears); omitted (`undefined`) leaves the key unset (preserves the DB value via the resolver's spread); an + // invalid non-null value (fractional/non-positive/wrong type) warns and also leaves the key unset. + if (r.contributorOpenPrCap === null) { + out.contributorOpenPrCap = null; + } else { + const contributorOpenPrCap = normalizeOptionalPositiveInteger(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings); + if (contributorOpenPrCap !== null) out.contributorOpenPrCap = contributorOpenPrCap; + } + if (r.contributorOpenIssueCap === null) { + out.contributorOpenIssueCap = null; + } else { + const contributorOpenIssueCap = normalizeOptionalPositiveInteger(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings); + if (contributorOpenIssueCap !== null) out.contributorOpenIssueCap = contributorOpenIssueCap; + } return out; } diff --git a/src/types.ts b/src/types.ts index 07b3dcbd5..045f8340e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -613,6 +613,14 @@ export type RepositorySettings = { * the label a repo sets. Always populated by the DB layer (default `"slop"`); optional so existing settings * fixtures/callers need not be touched (mirrors the sibling `contributorBlacklist`). */ blacklistLabel?: string | undefined; + /** Per-contributor open-PR cap (#2270, anti-abuse): the max PRs a single non-owner/admin/bot contributor may + * have open on this repo at once. `null`/absent (default) = no cap, byte-identical to today. Layered like + * every other settings field (`.gittensory.yml` `settings.contributorOpenPrCap` > DB > `null`). Enforcement + * (closing the newest PR(s) over the cap) is a separate follow-up; this field only carries the threshold. */ + contributorOpenPrCap?: number | null | undefined; + /** Per-contributor open-issue cap (#2270, anti-abuse): same shape and precedence as {@link contributorOpenPrCap}, + * applied to open issues instead of open PRs. `null`/absent (default) = no cap. */ + contributorOpenIssueCap?: number | null | undefined; /** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default * `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers * need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */ diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index f28d07c59..f1270a6ee 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -302,6 +302,22 @@ describe("data spine repositories", () => { await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentPaused: false }); expect((await getRepositorySettings(env, "owner/saferepo")).agentPaused).toBe(false); // update persists expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ agentPaused: false, agentDryRun: false }); // defaults + // #2270 per-contributor open PR/issue caps: no row and no cap set both default to null (disabled). + expect(await getRepositorySettings(env, "missing/repo")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null }); + expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null }); + // Round-trips on insert and persists on update. + await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorOpenPrCap: 2, contributorOpenIssueCap: 5 }); + expect(await getRepositorySettings(env, "owner/caprepo")).toMatchObject({ contributorOpenPrCap: 2, contributorOpenIssueCap: 5 }); + await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorOpenPrCap: 3, contributorOpenIssueCap: null }); + expect(await getRepositorySettings(env, "owner/caprepo")).toMatchObject({ contributorOpenPrCap: 3, contributorOpenIssueCap: null }); // update persists + can clear + // A cap must be a positive whole number: fractional, non-positive, and non-finite values are all + // dropped to null rather than silently coerced (there's no such thing as "allow 2.5 open PRs"). + await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: 2.5 as never }); + expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull(); + await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: 0 }); + expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull(); + await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: Number.NaN as never }); + expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull(); expect(updated.slopAiAdvisory).toBe(false); expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); expect(await getPullRequest(env, "owner/repo", 404)).toBeNull(); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index c29a9e0f5..2e9ebab51 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -1321,6 +1321,41 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(noOverride.contributorBlacklist?.map((e) => e.login)).toEqual(["keep-me"]); }); + it("parses + resolves contributorOpenPrCap/contributorOpenIssueCap from the settings: block, overlaying the DB (#2270)", () => { + const manifest = parseFocusManifest({ settings: { contributorOpenPrCap: 2, contributorOpenIssueCap: 5 } }); + expect(manifest.settings.contributorOpenPrCap).toBe(2); + expect(manifest.settings.contributorOpenIssueCap).toBe(5); + // yml overlays a DB-configured cap. + const eff = resolveEffectiveSettings({ contributorOpenPrCap: 10, contributorOpenIssueCap: 10 } as unknown as RepositorySettings, manifest); + expect(eff.contributorOpenPrCap).toBe(2); + expect(eff.contributorOpenIssueCap).toBe(5); + // Omitted in yml ⇒ the DB-configured cap survives untouched (not blanked to undefined/null). + const noOverride = resolveEffectiveSettings({ contributorOpenPrCap: 4, contributorOpenIssueCap: null } as unknown as RepositorySettings, parseFocusManifest({})); + expect(noOverride.contributorOpenPrCap).toBe(4); + expect(noOverride.contributorOpenIssueCap).toBeNull(); + // A cap is a discrete count, not a 0-100 score: fractional, non-positive, and non-numeric values are all + // dropped with a warning rather than silently coerced or clamped into range. + const invalid = parseFocusManifest({ settings: { contributorOpenPrCap: 2.5, contributorOpenIssueCap: 0 } }); + expect(invalid.settings.contributorOpenPrCap).toBeUndefined(); + expect(invalid.settings.contributorOpenIssueCap).toBeUndefined(); + expect(invalid.warnings.some((w) => /settings\.contributorOpenPrCap/.test(w))).toBe(true); + expect(invalid.warnings.some((w) => /settings\.contributorOpenIssueCap/.test(w))).toBe(true); + const nonNumber = parseFocusManifest({ settings: { contributorOpenPrCap: "two" as never } }); + expect(nonNumber.settings.contributorOpenPrCap).toBeUndefined(); + }); + + it("an EXPLICIT yml null force-clears a DB-configured cap, distinct from an omitted key (regression, gate finding on #2467)", () => { + // Omitted key preserves the DB value (already covered above); an explicit `null` must ALSO be able to + // override a DB-configured cap back to "no cap" — the documented `yml > DB > null` precedence otherwise + // has no way to un-set a cap without a separate dashboard/DB write, which contradicts config-as-code. + const explicitNull = parseFocusManifest({ settings: { contributorOpenPrCap: null, contributorOpenIssueCap: null } }); + expect(explicitNull.settings.contributorOpenPrCap).toBeNull(); + expect(explicitNull.settings.contributorOpenIssueCap).toBeNull(); + const eff = resolveEffectiveSettings({ contributorOpenPrCap: 4, contributorOpenIssueCap: 4 } as unknown as RepositorySettings, explicitNull); + expect(eff.contributorOpenPrCap).toBeNull(); + expect(eff.contributorOpenIssueCap).toBeNull(); + }); + it("resolves contributor blacklist by unioning the shared/global list with effective per-repo settings", () => { const manifest = parseFocusManifest({ settings: { contributorBlacklist: [{ login: "repo-only", reason: "manifest" }, { login: "Global-Repo", reason: "manifest-overrides-global" }] } }); const eff = resolveEffectiveSettings(