From 41637eb3f394275e531101c7d571d42daa6be158 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:24:01 -0700 Subject: [PATCH] feat(agent-actions): add an install-wide contributor open-item cap across repos A self-hosted install that gates multiple repos shares one database, but the per-repo contributorOpenPrCap/contributorOpenIssueCap only ever count open items on the same repo, so an actor spreading low-volume spam/farming PRs across several gated repos in one install never trips any single repo's cap. This adds an optional GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP env var, checked in addition to (not instead of) the existing per-repo caps via a same-database aggregate query over every repo the install tracks -- no cross-instance networking, off by default, reusing the existing contributor_cap closeKind and close-message shape (mirrors global_contributor_blacklist's install-scoped singleton pattern). Closes #2562 --- src/db/repositories.ts | 22 ++ src/env.d.ts | 9 + src/queue/processors.ts | 58 ++- src/settings/agent-actions.ts | 20 +- src/settings/global-contributor-cap.ts | 23 ++ test/unit/agent-actions.test.ts | 17 + test/unit/global-contributor-cap.test.ts | 51 +++ test/unit/queue.test.ts | 435 +++++++++++++++++++++++ 8 files changed, 627 insertions(+), 8 deletions(-) create mode 100644 src/settings/global-contributor-cap.ts create mode 100644 test/unit/global-contributor-cap.test.ts diff --git a/src/db/repositories.ts b/src/db/repositories.ts index d91fe158c7..4a8d155b5e 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3104,6 +3104,28 @@ export async function countOpenPullRequests(env: Env, fullName: string): Promise return Number(row?.count ?? 0); } +/** + * Install-wide open-item count for one author (#2562, anti-abuse): SUM of this author's open PRs + open + * issues across EVERY repo tracked in this install's database -- deliberately NOT scoped by repoFullName, + * unlike countOpenPullRequests/countOpenIssues above. This is what makes the globalContributorOpenItemCap + * catch an actor spreading low-volume spam across several gated repos in the same self-hosted install: no + * single repo's own cap trips, but the aggregate does. Same-database aggregate only -- no cross-instance + * networking, mirroring the install-scoped singleton shape of global_contributor_blacklist. Case-insensitive + * login match (mirrors loginMatches/findBlacklistEntry elsewhere in this file). + */ +export async function countOpenItemsForAuthorAcrossRepos(env: Env, authorLogin: string): Promise { + const db = getDb(env.DB); + const [[prRow], [issueRow]] = await Promise.all([ + db.select({ count: sql`count(*)` }).from(pullRequests).where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin))), + db.select({ count: sql`count(*)` }).from(issues).where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin))), + ]); + /* v8 ignore next -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */ + const prCount = Number(prRow?.count ?? 0); + /* v8 ignore next -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */ + const issueCount = Number(issueRow?.count ?? 0); + return prCount + issueCount; +} + // Anti-farming (#anti-gaming-flood): how many PRs this author has SUBMITTED to this repo since `sinceIso` (ANY // state — open/merged/closed), so a flood that merges fast is still caught. createdAt is the row-insert time // (≈ when gittensory first saw the PR), a good proxy for submission time on live webhook-driven PRs. diff --git a/src/env.d.ts b/src/env.d.ts index 1af4ee3d3d..ce9d264d11 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -80,6 +80,15 @@ declare global { onMerge?: import("./services/ai-review").OnMerge | undefined; }; ADMIN_GITHUB_LOGINS?: string; + /** Install-wide contributor open-item cap (#2562, anti-abuse): the max PRs+issues a single non-owner/ + * admin/bot contributor may have open ACROSS EVERY repo this install gates, combined. Purely an + * install-scoped aggregate over this same database (no cross-instance networking) -- catches an actor + * spreading low-volume spam/farming PRs across several gated repos in one self-hosted install, which no + * single repo's own contributorOpenPrCap/contributorOpenIssueCap can see. Unset/invalid (the default) = no + * cap, byte-identical to today. Checked IN ADDITION TO (not instead of) the existing per-repo caps, in the + * same contributor_cap short-circuit (src/settings/agent-actions.ts). A positive integer string (e.g. "20"); + * see src/settings/global-contributor-cap.ts for parsing. */ + GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string; GITHUB_WEBHOOK_SECRET: string; GITHUB_WEBHOOK_MAX_BODY_BYTES?: string; /** Webhook secret for the central Gittensory Orb GitHub App (#1255) — distinct from the review app's diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 95d3ac7a16..8f2461f7f9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1,5 +1,6 @@ import { countOpenIssues, + countOpenItemsForAuthorAcrossRepos, countOpenPullRequests, getAgentCommandAnswer, getInstallation, @@ -245,6 +246,7 @@ import { type PlannedAgentAction, } from "../settings/agent-actions"; import { isAutoCloseExempt } from "../settings/auto-close-exempt"; +import { resolveGlobalContributorOpenItemCap } from "../settings/global-contributor-cap"; import { detectMigrationCollisions, extractMigrationNumber, KNOWN_MIGRATION_DUPLICATES } from "../db/migration-collisions"; import { listMigrationFilenamesAtRef } from "../github/migration-tree"; import { @@ -2027,7 +2029,7 @@ async function runAgentMaintenancePlanAndExecute( // default) ⇒ this block is a no-op. A below-account-age-threshold author (#2561) gets a TIGHTER effective // cap (half, rounded up, minimum 1) — visibility/friction, still never a close on account age by itself // (the close, if any, is still tagged/reasoned as the ordinary contributor-cap close). - let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined; + let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues"; scope?: "repository" | "install" | undefined } | undefined; const contributorOpenPrCap = isNewAccount && typeof settings.contributorOpenPrCap === "number" ? Math.max(1, Math.ceil(settings.contributorOpenPrCap / 2)) @@ -2056,6 +2058,22 @@ async function runAgentMaintenancePlanAndExecute( } } + // Install-wide contributor open-item cap (#2562, anti-abuse): IN ADDITION TO the per-repo cap above, not + // instead of it -- only evaluated when the per-repo cap didn't already match (short-circuit: no need for a + // second cross-repo DB read once this PR is already being closed). Off by default (resolveGlobalContributorOpenItemCap + // returns null when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is unset/invalid) ⇒ zero extra queries, zero behavior + // change for an install that hasn't opted in. Reuses the shared autoCloseExemptLogins list (#2463) so a + // maintainer-named login is exempt here exactly like the per-repo caps and review-nag cooldown. + if (contributorCapMatch === undefined && pr.authorLogin && !isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) { + const globalCap = resolveGlobalContributorOpenItemCap(env); + if (globalCap !== null) { + const installOpenCount = await countOpenItemsForAuthorAcrossRepos(env, pr.authorLogin); + if (installOpenCount > globalCap) { + contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: installOpenCount, cap: globalCap, itemKind: "pull requests", scope: "install" }; + } + } + } + const planned = planAgentMaintenanceActions({ conclusion: gate.conclusion, blockerTitles: gate.blockers.map((blocker) => blocker.title), @@ -3924,7 +3942,10 @@ async function maybeCloseIssueOverContributorCap( const { installationId, repoFullName, issue, settings } = args; const cap = settings.contributorOpenIssueCap; const authorLogin = issue.authorLogin; - if (typeof cap !== "number" || !authorLogin) return; + // Install-wide cap (#2562) is checked IN ADDITION TO the per-repo cap, so this function must still run when + // ONLY the global cap is configured (the per-repo cap stays optional/off, its usual default). + const globalCap = resolveGlobalContributorOpenItemCap(env); + if ((typeof cap !== "number" && globalCap === null) || !authorLogin) return; const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : ""; const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); @@ -3932,6 +3953,39 @@ async function maybeCloseIssueOverContributorCap( const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return; + // Install-wide check first (#2562): reuses the shared autoCloseExemptLogins list, same as the PR path. A + // match here closes THIS issue directly (unlike the per-repo cap below, there is no cross-repo sibling set to + // union/live-verify -- the aggregate count already covers every repo, so a single over-cap read is enough). + if (globalCap !== null && !isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins)) { + const installOpenCount = await countOpenItemsForAuthorAcrossRepos(env, authorLogin); + if (installOpenCount > globalCap) { + const planned = planAgentMaintenanceActions({ + conclusion: "skipped", + blockerTitles: [], + autonomy: settings.autonomy, + changedPaths: [], + hardGuardrailGlobs: [], + authorIsOwner, + authorIsAdmin, + authorIsAutomationBot, + ciState: "unverified", + contributorCapMatch: { matched: true, authorLogin, openCount: installOpenCount, cap: globalCap, itemKind: "issues", scope: "install" }, + contributorCapLabel: settings.contributorCapLabel, + pr: { labels: [] }, + }); + if (planned.length > 0) { + await executeIssueMaintenanceActions( + env, + { installationId, repoFullName, issueNumber: issue.number, autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }, + planned, + ); + } + return; + } + } + + if (typeof cap !== "number") return; + const otherOpenIssues = await listOpenIssues(env, repoFullName); const authorLoginLower = authorLogin.toLowerCase(); const otherAuthorIssueNumbers = otherOpenIssues diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 7fc5832842..e5630a645f 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -169,7 +169,11 @@ export type AgentActionPlanInput = { // so — unlike the blacklist's private-reason close — they ARE interpolated into the public close comment. // `itemKind` selects the close-comment noun ("pull requests" for the PR-path caller, "issues" for the // issue-path caller, #2270) — REQUIRED (not defaulted) so a caller can't silently mislabel the other kind. - contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined; + // `scope` (#2562) selects the close-comment's cap description: "repository" (default when absent, back-compat + // for every existing per-repo caller) says "this repository's configured limit"; "install" says "across every + // repository this install gates, combined" for the install-wide globalContributorOpenItemCap. Same closeKind + // ("contributor_cap") and label either way — this is a description-only distinction, not a new disposition. + contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues"; scope?: "repository" | "install" | undefined } | undefined; // The repo-configured label applied to an over-cap author's PR/issue (#2270), resolved from `.gittensory.yml`. // Absent ⇒ the default (`DEFAULT_CONTRIBUTOR_CAP_LABEL` = "over-contributor-limit"). contributorCapLabel?: string | undefined; @@ -307,9 +311,13 @@ function blacklistCloseMessage(): string { // The close comment for exceeding the per-contributor open-item cap (#2270). Unlike blacklistCloseMessage, this // DOES interpolate authorLogin/openCount/cap — none of that is private (the author's own login and their own // open-item count on a public repo are already public/derivable from GitHub itself), and stating the exact -// numbers is the point: a deterministic, contributor-visible cap, not a silent quality-based hold. -function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues"): string { - return `Gittensory closed this because @${authorLogin} has ${openCount} open ${itemNoun}, above this repository's configured limit of ${cap}. Close or merge an existing one to open a new one. This is an automated maintenance action.`; +// numbers is the point: a deterministic, contributor-visible cap, not a silent quality-based hold. `scope` +// (#2562) picks the cap description: "repository" (default, back-compat for every existing per-repo caller) vs. +// "install" for the install-wide globalContributorOpenItemCap — same message shape, closeKind, and label either +// way, just an accurate noun phrase for where the count was aggregated. +function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues", scope?: "repository" | "install" | undefined): string { + const scopeDescription = scope === "install" ? "this install's configured limit (across every repository it gates, combined)" : "this repository's configured limit"; + return `Gittensory closed this because @${authorLogin} has ${openCount} open ${itemNoun}, above ${scopeDescription} of ${cap}. Close or merge an existing one to open a new one. This is an automated maintenance action.`; } // The close comment for review-nag cooldown (#2463). DOES interpolate authorLogin/pingCount/maxPings — none of @@ -369,7 +377,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne // independently of the caller (defense-in-depth, matching the blacklist block's own redundant check above). const capContributor = !input.authorIsOwner && !input.authorIsAdmin && !input.authorIsAutomationBot; if (input.contributorCapMatch?.matched === true && capContributor) { - const { authorLogin, openCount, cap, itemKind } = input.contributorCapMatch; + const { authorLogin, openCount, cap, itemKind, scope } = input.contributorCapMatch; const label = input.contributorCapLabel ?? DEFAULT_CONTRIBUTOR_CAP_LABEL; if (acting("label")) actions.push({ actionClass: "label", requiresApproval: approval("label"), reason: "over the per-contributor open-item cap", label, labelOp: "add" }); if (acting("close")) { @@ -377,7 +385,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne actionClass: "close", requiresApproval: approval("close"), reason: "over the per-contributor open-item cap", - closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind)), + closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind, scope)), closeKind: "contributor_cap", }); } diff --git a/src/settings/global-contributor-cap.ts b/src/settings/global-contributor-cap.ts new file mode 100644 index 0000000000..07a046acd9 --- /dev/null +++ b/src/settings/global-contributor-cap.ts @@ -0,0 +1,23 @@ +// Install-wide contributor open-item cap (#2562, anti-abuse): a self-hosted install that gates multiple repos +// shares ONE database, but the per-repo contributorOpenPrCap/contributorOpenIssueCap (repository-settings.ts) +// only ever counts open items on the SAME repo -- an actor spreading low-volume spam/farming PRs across several +// gated repos in that install never trips any single repo's cap. This is cross-REPO-within-one-install only (no +// federation, no cross-instance privacy design): a same-database aggregate against every repo this install +// already tracks. Deliberately an env var (not a per-repo `.gittensory.yml`/DB field like the caps above) -- +// this setting aggregates ACROSS repos, so it cannot be "this repo's" setting; it belongs to the install as a +// 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. +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 + * 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. */ +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; +} diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index 1b6ffda6e8..dbfeaaf3b1 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -949,6 +949,23 @@ describe("per-contributor open-item cap short-circuit (#2270)", () => { expect(plan[1]?.closeComment).not.toContain("pull requests"); }); + it("scope 'install' (#2562) describes the cap as install-wide, not this-repository's — same closeKind/label shape", () => { + const plan = planAgentMaintenanceActions( + overCap({ contributorCapMatch: { matched: true, authorLogin: "farmer99", openCount: 5, cap: 4, itemKind: "pull requests", scope: "install" } }), + ); + expect(plan[1]).toMatchObject({ actionClass: "close", closeKind: "contributor_cap" }); + expect(plan[1]?.closeComment).toContain("@farmer99"); + expect(plan[1]?.closeComment).toContain("5 open pull requests"); + expect(plan[1]?.closeComment).toContain("across every repository it gates, combined) of 4"); + expect(plan[1]?.closeComment).not.toContain("this repository's configured limit"); + }); + + it("scope 'repository' (default, absent) keeps the original this-repository close-comment wording — back-compat", () => { + const plan = planAgentMaintenanceActions(overCap()); // overCap's base contributorCapMatch omits `scope` + expect(plan[1]?.closeComment).toContain("this repository's configured limit"); + expect(plan[1]?.closeComment).not.toContain("across every repository it gates"); + }); + it("uses the repo-configured contributorCapLabel, defaulting to 'over-contributor-limit' when unset", () => { expect(planAgentMaintenanceActions(overCap({ contributorCapLabel: "spam-cap" }))[0]).toMatchObject({ label: "spam-cap" }); expect(DEFAULT_CONTRIBUTOR_CAP_LABEL).toBe("over-contributor-limit"); diff --git a/test/unit/global-contributor-cap.test.ts b/test/unit/global-contributor-cap.test.ts new file mode 100644 index 0000000000..70777b79ed --- /dev/null +++ b/test/unit/global-contributor-cap.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { resolveGlobalContributorOpenItemCap } from "../../src/settings/global-contributor-cap"; +import { countOpenItemsForAuthorAcrossRepos, upsertIssueFromGitHub, upsertPullRequestFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +describe("resolveGlobalContributorOpenItemCap (#2562)", () => { + it("is off by default when the env var is unset", () => { + expect(resolveGlobalContributorOpenItemCap({})).toBeNull(); + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: undefined })).toBeNull(); + }); + + it("parses a valid positive-integer string", () => { + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "20" })).toBe(20); + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "1" })).toBe(1); + }); + + 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(); + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "-3" })).toBeNull(); + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "not-a-number" })).toBeNull(); + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "" })).toBeNull(); + expect(resolveGlobalContributorOpenItemCap({ GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: " " })).toBeNull(); + }); +}); + +describe("countOpenItemsForAuthorAcrossRepos (#2562)", () => { + it("sums open PRs + open issues for one author across EVERY repo in the database, not just one", async () => { + const env = createTestEnv(); + await upsertPullRequestFromGitHub(env, "org/repo-a", { number: 1, title: "a1", state: "open", user: { login: "farmer99" } }); + await upsertPullRequestFromGitHub(env, "org/repo-b", { number: 2, title: "b1", state: "open", user: { login: "farmer99" } }); + await upsertIssueFromGitHub(env, "org/repo-c", { number: 3, title: "c1", state: "open", user: { login: "farmer99" } }); + // A closed item and a different author's item must NOT count toward the total. + await upsertPullRequestFromGitHub(env, "org/repo-a", { number: 4, title: "a2 (closed)", state: "closed", user: { login: "farmer99" } }); + await upsertPullRequestFromGitHub(env, "org/repo-a", { number: 5, title: "a3 (other author)", state: "open", user: { login: "someone-else" } }); + + expect(await countOpenItemsForAuthorAcrossRepos(env, "farmer99")).toBe(3); + }); + + it("is case-insensitive on the author login (mirrors loginMatches/findBlacklistEntry elsewhere)", async () => { + const env = createTestEnv(); + await upsertPullRequestFromGitHub(env, "org/repo-a", { number: 1, title: "a1", state: "open", user: { login: "Farmer99" } }); + expect(await countOpenItemsForAuthorAcrossRepos(env, "farmer99")).toBe(1); + expect(await countOpenItemsForAuthorAcrossRepos(env, "FARMER99")).toBe(1); + }); + + it("returns 0 for an author with no open items anywhere", async () => { + const env = createTestEnv(); + expect(await countOpenItemsForAuthorAcrossRepos(env, "nobody")).toBe(0); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 19df52a122..2e72ee6276 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6768,6 +6768,241 @@ describe("queue processors", () => { expect(seen.closed).toBe(false); }); + it("install-wide contributor open-item cap (#2562): an actor over the install-wide cap but under EVERY individual repo's own cap is still caught", async () => { + // No per-repo contributorOpenPrCap is configured on EITHER repo -- only the install-wide env cap. One + // pre-existing open PR on repo-a and one on repo-b (2 total), plus the incoming 3rd (also on repo-a) = 3, + // over a global cap of 2 -- even though repo-a's own count (2) and repo-b's own count (1) would each + // individually be unremarkable (and no per-repo cap is even configured to catch them). + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "2" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/repo-a", { number: 20, title: "Farmer PR on repo-a", state: "open", user: { login: "farmer99" }, head: { sha: "fa20" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer PR on repo-b", state: "open", user: { login: "farmer99" }, head: { sha: "fb10" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/repo-a", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + // Deliberately NO contributorOpenPrCap here — only the install-wide env cap should catch this. + }); + const seen = { closed: false, labels: [] as string[], comments: [] as string[] }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/55/comments") && method === "POST") { seen.comments.push(String(JSON.parse(String(init?.body ?? "{}")).body ?? "")); return Response.json({ id: 1 }, { status: 201 }); } + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-close", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR install-wide", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.labels).toContain("over-contributor-limit"); + expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("3 open pull requests") && c.includes("across every repository it gates"))).toBe(true); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n).toBeGreaterThanOrEqual(1); + }); + + it("install-wide contributor open-item cap (#2562): off by default (env var unset) — a spread-across-repos actor is never closed", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); // no GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer PR on repo-b", state: "open", user: { login: "farmer99" }, head: { sha: "fb10" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/repo-a", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-off-by-default", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR install-wide", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + expect(seen.closed).toBe(false); + }); + + it("install-wide contributor open-item cap (#2562): a maintainer-named autoCloseExemptLogins entry is exempt from the install-wide cap", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "2" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer PR on repo-b", state: "open", user: { login: "farmer99" }, head: { sha: "fb10" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/repo-a", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + autoCloseExemptLogins: ["farmer99"], + }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-exempt", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR install-wide", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + expect(seen.closed).toBe(false); + }); + + it("install-wide contributor open-item cap (#2562): an author AT (not over) the configured install-wide cap is not closed", async () => { + // Global cap is configured (2) and reached exactly (repo-b's 1 pre-existing + this incoming PR = 2), so the + // install-wide check must fall through without matching -- the `installOpenCount > globalCap` false branch. + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "2" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer PR on repo-b", state: "open", user: { login: "farmer99" }, head: { sha: "fb10" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/repo-a", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-at-limit", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 2nd PR, at the install-wide limit", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + expect(seen.closed).toBe(false); + }); + it("contributor open-PR cap (#2270): the repo OWNER's own PR is never closed even over the cap (live processor path, not just the planner)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, { @@ -7589,6 +7824,206 @@ describe("queue processors", () => { expect(closeAudit?.n ?? 0).toBe(0); }); + it("install-wide contributor open-item cap (#2562): an over-install-cap contributor's issue is caught even with NO per-repo contributorOpenIssueCap configured", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "2" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertIssueFromGitHub(env, "JSONbored/repo-a", { number: 20, title: "Farmer issue on repo-a", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer issue on repo-b", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + // No contributorOpenIssueCap set — only the install-wide env cap should catch this. + await upsertRepositorySettings(env, { repoFullName: "JSONbored/repo-a", autonomy: { close: "auto", label: "auto" } }); + const seen = { closed: false, labels: [] as string[], comments: [] as string[] }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ state: "closed" }); } + if (url.includes("/issues/62/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/62/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/62/comments") && method === "POST") { seen.comments.push(String(JSON.parse(String(init?.body ?? "{}")).body ?? "")); return Response.json({ id: 1 }, { status: 201 }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-issue-cap-close", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 3rd issue install-wide", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.labels).toContain("over-contributor-limit"); + expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("3 open issues") && c.includes("across every repository it gates"))).toBe(true); + }); + + it("install-wide contributor open-item cap (#2562): off by default (env var unset) — an issue author spread across repos is never closed", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); // no GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertIssueFromGitHub(env, "JSONbored/repo-a", { number: 20, title: "Farmer issue on repo-a", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer issue on repo-b", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/repo-a", autonomy: { close: "auto", label: "auto" } }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-issue-cap-off-by-default", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 3rd issue install-wide", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + }); + + it("install-wide contributor open-item cap (#2562): an issue author AT (not over) the install-wide cap is not closed, and falls through to the (unset) per-repo issue cap check safely", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "2" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertIssueFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer issue on repo-b", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + // No contributorOpenIssueCap configured -- exercises the (typeof cap !== "number") early return after the + // install-wide check falls through without matching. + await upsertRepositorySettings(env, { repoFullName: "JSONbored/repo-a", autonomy: { close: "auto", label: "auto" } }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-issue-cap-at-limit", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 2nd issue, at the install-wide limit", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + }); + + it("install-wide contributor open-item cap (#2562): an over-install-cap issue plans no action (observe-only autonomy) and does not execute a close", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "2" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [ + { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + { name: "repo-b", full_name: "JSONbored/repo-b", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertIssueFromGitHub(env, "JSONbored/repo-a", { number: 20, title: "Farmer issue on repo-a", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/repo-b", { number: 10, title: "Farmer issue on repo-b", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + // autonomy: {} (no acting classes granted) — the plan builds empty, so `planned.length > 0` is false. + await upsertRepositorySettings(env, { repoFullName: "JSONbored/repo-a", autonomy: {} }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-issue-cap-observe-only", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 3rd issue install-wide, observe-only", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + }); + + it("install-wide contributor open-item cap (#2562): with BOTH the global cap and the per-repo issue cap configured, an author within the global cap still trips the per-repo cap unchanged", async () => { + // Global cap of 5 is never approached (only 1 open item on repo-b), but the per-repo contributorOpenIssueCap + // of 2 on repo-a IS tripped by this author's 3rd repo-a issue -- proves the two checks are independent and + // the per-repo path still runs (typeof cap !== "number" false branch) after the global check falls through. + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "5" }); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/repo-a", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/repo-a", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/repo-a", autonomy: { close: "auto", label: "auto" }, contributorOpenIssueCap: 2 }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") return Response.json({ state: "open" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ state: "closed" }); } + if (url.includes("/issues/62/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/62/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/62/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-and-per-repo-issue-cap-both-configured", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "repo-a", full_name: "JSONbored/repo-a", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 3rd repo-a issue, over the per-repo cap only", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(true); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n).toBeGreaterThanOrEqual(1); + }); + it("contributor open-ISSUE cap (#2270): the repo OWNER's own issue is never closed even over the cap", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertInstallation(env, {