From aa2b4661e16cbe53c55430f03782ce3071e7da56 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:59:39 -0700 Subject: [PATCH 1/8] feat(agent-actions): add an install-wide contributor open-item cap across repos contributorOpenPrCap/contributorOpenIssueCap only see one repo, so an actor spreading low-volume spam across several repos on the same self-host install never trips any single repo's own cap. Adds an optional GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP env var (install-scoped, not per-repo, mirroring ADMIN_GITHUB_LOGINS) that sums an author's open PRs + open issues across every repo this install gates, checked only when the per-repo cap didn't already match. Reuses the existing contributor_cap closeKind and autoCloseExemptLogins list. Off by default. Closes #2562. --- src/db/repositories.ts | 21 ++ src/env.d.ts | 5 + src/queue/processors.ts | 138 +++++--- test/unit/data-spine.test.ts | 20 ++ test/unit/queue.test.ts | 614 +++++++++++++++++++++++++++++++++++ 5 files changed, 759 insertions(+), 39 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index d91fe158c7..4da3ee57d6 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3104,6 +3104,27 @@ export async function countOpenPullRequests(env: Env, fullName: string): Promise return Number(row?.count ?? 0); } +/** + * Install-wide contributor open-item count (#2562, anti-abuse): sums this author's open PRs + open issues + * across EVERY repo this install/instance tracks in the SAME D1 database -- no cross-instance networking, no + * join through `repositories` (every row in `pullRequests`/`issues` already belongs to a repo this install + * gates). Only called when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is configured; the existing per-repo + * countOpenPullRequests/countOpenIssues stay scoped to one repo and are unaffected by this addition. + */ +export async function countOpenItemsByAuthorAcrossInstall(env: Env, authorLogin: string): Promise { + const db = getDb(env.DB); + const [prRow] = await db + .select({ count: sql`count(*)` }) + .from(pullRequests) + .where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin))); + const [issueRow] = await db + .select({ count: sql`count(*)` }) + .from(issues) + .where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin))); + /* v8 ignore next 2 -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */ + return Number(prRow?.count ?? 0) + Number(issueRow?.count ?? 0); +} + // 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 f7540d07e1..d9a2225f52 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -80,6 +80,11 @@ declare global { onMerge?: import("./services/ai-review").OnMerge | undefined; }; ADMIN_GITHUB_LOGINS?: string; + /** Install-wide contributor open-item cap (#2562, anti-abuse): unset/blank/non-positive ⇒ off (the + * default). When set, caps an author's combined open-PR + open-issue count summed across EVERY repo + * this install/instance gates -- not per-repo, since it aggregates across repos (mirrors + * ADMIN_GITHUB_LOGINS's own bare-env-var, non-per-repo shape). */ + 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..c1566611a1 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1,6 +1,7 @@ import { countOpenIssues, countOpenPullRequests, + countOpenItemsByAuthorAcrossInstall, getAgentCommandAnswer, getInstallation, getLatestRepoGithubTotalsSnapshot, @@ -2056,6 +2057,33 @@ async function runAgentMaintenancePlanAndExecute( } } + // Install-wide contributor open-item cap (#2562, anti-abuse): a self-host operator gating multiple repos on + // one install has no fleet-wide view via the per-repo cap above -- an actor spreading low-volume spam across + // several gated repos never trips any single repo's own cap. Independent of and complementary to the per-repo + // check: only runs when that one didn't already match (no reason to also hit the DB once a close is already + // decided), and reuses the SAME contributor_cap closeKind/close-message shape, just with a fleet-wide count. + // Off by default (GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP unset). Honors the same autoCloseExemptLogins list the + // review-nag cooldown already uses -- the owner/admin/automation-bot exemption is still applied by the + // planner itself, same as the per-repo check above. + const globalContributorOpenItemCap = parseGlobalContributorOpenItemCapEnv(env.GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP); + if ( + contributorCapMatch === undefined && + globalContributorOpenItemCap !== null && + pr.authorLogin && + !isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins) + ) { + const globalOpenCount = await countOpenItemsByAuthorAcrossInstall(env, pr.authorLogin); + if (globalOpenCount > globalContributorOpenItemCap) { + contributorCapMatch = { + matched: true, + authorLogin: pr.authorLogin, + openCount: globalOpenCount, + cap: globalContributorOpenItemCap, + itemKind: "pull requests", + }; + } + } + const planned = planAgentMaintenanceActions({ conclusion: gate.conclusion, blockerTitles: gate.blockers.map((blocker) => blocker.title), @@ -3889,6 +3917,16 @@ async function loadOpenQueueCounts( }; } +// GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP (#2562): unset/blank/non-finite/non-positive all mean "off" (null), never a +// silent fallback to some default cap -- this install-wide check must be explicitly opted into. +function parseGlobalContributorOpenItemCapEnv(raw: string | undefined): number | null { + if (raw === undefined || raw.trim() === "") return null; + const value = Number(raw); + if (!Number.isFinite(value)) return null; + const cap = Math.floor(value); + return cap >= 1 ? cap : null; +} + /** * Per-contributor open-ISSUE cap (#2270, anti-abuse): the first `eventName === "issues"` actuation branch — * issues have no other auto-close path today. Mirrors the PR-path cap in runAgentMaintenancePlanAndExecute: @@ -3923,8 +3961,9 @@ async function maybeCloseIssueOverContributorCap( ): Promise { const { installationId, repoFullName, issue, settings } = args; const cap = settings.contributorOpenIssueCap; + const globalContributorOpenItemCap = parseGlobalContributorOpenItemCapEnv(env.GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP); const authorLogin = issue.authorLogin; - if (typeof cap !== "number" || !authorLogin) return; + if ((typeof cap !== "number" && globalContributorOpenItemCap === null) || !authorLogin) return; const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : ""; const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); @@ -3932,43 +3971,64 @@ async function maybeCloseIssueOverContributorCap( const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return; - const otherOpenIssues = await listOpenIssues(env, repoFullName); - const authorLoginLower = authorLogin.toLowerCase(); - const otherAuthorIssueNumbers = otherOpenIssues - .filter((other) => (other.authorLogin ?? "").toLowerCase() === authorLoginLower && other.number !== issue.number) - .map((other) => other.number); - - // Live-verify each OTHER counted sibling before trusting it toward the cap (#2479 gate finding): the stored - // open-issue cache lags GitHub, so a sibling already closed elsewhere (manually, by another automation, or a - // webhook this instance hasn't processed yet) can still read `open` here and inflate the count enough to close - // a newly opened issue that is actually within the real cap. `issue` itself is trusted unverified -- it is the - // issue THIS webhook just delivered, so it is open by construction. - // - // Fail SAFE (not open, per gate finding on this exact block, second pass), NOT fail-open-to-stored like - // reconcileLiveDuplicateSiblings: that helper only re-ranks a duplicate-cluster WINNER (a non-final signal - // recomputed every delivery), so failing open there just risks a transient wrong ranking. Here the count - // directly gates an IRREVERSIBLE close, so an unreadable live check (a transient fetch failure) must NOT be - // allowed to compound with a stale "open" DB row and tip a within-cap issue into being wrongly closed -- - // any sibling this delivery cannot POSITIVELY confirm is still open is excluded from the count. The cost is - // symmetric-but-safe: a transient miss can undercount and momentarily under-enforce the cap, but that is - // self-correcting (the delivery-order guard below already re-evaluates on every subsequent issue-open), while - // a wrongful close is not. - const token = await createInstallationToken(env, installationId).catch(() => undefined); - const liveToken = token ?? env.GITHUB_PUBLIC_TOKEN; - const admissionKey = githubAdmissionKeyForToken(env, installationId, liveToken); - const confirmedOpen = new Set(); - await Promise.all( - otherAuthorIssueNumbers.map(async (number) => { - const liveState = await fetchLiveIssueState(env, repoFullName, number, liveToken, admissionKey).catch(() => undefined); - if (liveState === "open") confirmedOpen.add(number); - }), - ); - const authorOpenIssueNumbers = otherAuthorIssueNumbers - .filter((number) => confirmedOpen.has(number)) - .concat(issue.number) - .sort((a, b) => a - b); - const overCapNumbers = new Set(authorOpenIssueNumbers.slice(cap)); - if (overCapNumbers.size === 0) return; + let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined; + let overCapNumbers = new Set(); + + if (typeof cap === "number") { + const otherOpenIssues = await listOpenIssues(env, repoFullName); + const authorLoginLower = authorLogin.toLowerCase(); + const otherAuthorIssueNumbers = otherOpenIssues + .filter((other) => (other.authorLogin ?? "").toLowerCase() === authorLoginLower && other.number !== issue.number) + .map((other) => other.number); + + // Live-verify each OTHER counted sibling before trusting it toward the cap (#2479 gate finding): the stored + // open-issue cache lags GitHub, so a sibling already closed elsewhere (manually, by another automation, or a + // webhook this instance hasn't processed yet) can still read `open` here and inflate the count enough to close + // a newly opened issue that is actually within the real cap. `issue` itself is trusted unverified -- it is the + // issue THIS webhook just delivered, so it is open by construction. + // + // Fail SAFE (not open, per gate finding on this exact block, second pass), NOT fail-open-to-stored like + // reconcileLiveDuplicateSiblings: that helper only re-ranks a duplicate-cluster WINNER (a non-final signal + // recomputed every delivery), so failing open there just risks a transient wrong ranking. Here the count + // directly gates an IRREVERSIBLE close, so an unreadable live check (a transient fetch failure) must NOT be + // allowed to compound with a stale "open" DB row and tip a within-cap issue into being wrongly closed -- + // any sibling this delivery cannot POSITIVELY confirm is still open is excluded from the count. The cost is + // symmetric-but-safe: a transient miss can undercount and momentarily under-enforce the cap, but that is + // self-correcting (the delivery-order guard below already re-evaluates on every subsequent issue-open), while + // a wrongful close is not. + const token = await createInstallationToken(env, installationId).catch(() => undefined); + const liveToken = token ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, installationId, liveToken); + const confirmedOpen = new Set(); + await Promise.all( + otherAuthorIssueNumbers.map(async (number) => { + const liveState = await fetchLiveIssueState(env, repoFullName, number, liveToken, admissionKey).catch(() => undefined); + if (liveState === "open") confirmedOpen.add(number); + }), + ); + const authorOpenIssueNumbers = otherAuthorIssueNumbers + .filter((number) => confirmedOpen.has(number)) + .concat(issue.number) + .sort((a, b) => a - b); + overCapNumbers = new Set(authorOpenIssueNumbers.slice(cap)); + if (overCapNumbers.size > 0) { + contributorCapMatch = { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap, itemKind: "issues" }; + } + } + + if ( + contributorCapMatch === undefined && + globalContributorOpenItemCap !== null && + !isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins) + ) { + const globalOpenCount = await countOpenItemsByAuthorAcrossInstall(env, authorLogin); + if (globalOpenCount > globalContributorOpenItemCap) { + contributorCapMatch = { matched: true, authorLogin, openCount: globalOpenCount, cap: globalContributorOpenItemCap, itemKind: "issues" }; + overCapNumbers = new Set([issue.number]); + } + } + + if (contributorCapMatch === undefined) return; const planned = planAgentMaintenanceActions({ conclusion: "skipped", @@ -3980,7 +4040,7 @@ async function maybeCloseIssueOverContributorCap( authorIsAdmin, authorIsAutomationBot, ciState: "unverified", - contributorCapMatch: { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap, itemKind: "issues" }, + contributorCapMatch, contributorCapLabel: settings.contributorCapLabel, pr: { labels: [] }, }); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 0271e1dbbf..45c066f833 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -23,6 +23,7 @@ import { listRepoSyncStates, listSignalSnapshots, countOpenIssues, + countOpenItemsByAuthorAcrossInstall, persistRepoSnapshot, persistSignalSnapshot, replaceCollisionEdges, @@ -461,6 +462,25 @@ describe("data spine repositories", () => { expect(await listContributorIssues(env, "JSONBORED")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/repo", number: 10 }), expect.objectContaining({ repoFullName: "owner/repo", number: 11 })])); }); + it("countOpenItemsByAuthorAcrossInstall (#2562) sums one author's open PRs + open issues across every repo this install tracks, excludes closed items and other authors, and matches case-insensitively", async () => { + const env = createTestEnv(); + // farmer99's open items, spread across TWO different repos this install gates. + await upsertPullRequestFromGitHub(env, "owner/repo-a", { number: 1, title: "PR one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "owner/repo-b", { number: 2, title: "PR two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertIssueFromGitHub(env, "owner/repo-a", { number: 3, title: "Issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); + // A CLOSED item from farmer99 — must be excluded from the count. + await upsertPullRequestFromGitHub(env, "owner/repo-b", { number: 4, title: "PR three (closed)", state: "closed", user: { login: "farmer99" }, labels: [], body: "w" }); + // An OPEN item from a DIFFERENT author — must be excluded from the count. + await upsertIssueFromGitHub(env, "owner/repo-a", { number: 5, title: "Someone else's issue", state: "open", user: { login: "other-author" }, labels: [], body: "v" }); + + // 2 open PRs + 1 open issue for farmer99, across repo-a and repo-b combined. + expect(await countOpenItemsByAuthorAcrossInstall(env, "farmer99")).toBe(3); + // Case-insensitive: a differently-cased login still matches the same rows. + expect(await countOpenItemsByAuthorAcrossInstall(env, "FARMER99")).toBe(3); + // An author with no open items anywhere counts zero. + expect(await countOpenItemsByAuthorAcrossInstall(env, "nobody")).toBe(0); + }); + it("persists a per-PR slop assessment, round-trips it via the cached record, and keeps latest-wins (PR2)", async () => { const env = createTestEnv(); await upsertPullRequestFromGitHub(env, "owner/sloppr", { number: 5, title: "Churn", state: "open", user: { login: "alice" }, labels: [], body: "x" }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 19df52a122..f62d253f09 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7773,6 +7773,620 @@ describe("queue processors", () => { expect(seen.closed).toBe(true); }); + it("install-wide contributor open-item cap (#2562): catches an actor spreading PRs across multiple repos who never trips any single repo's own (generous) cap", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + // farmer99 has 2 open PRs on gittensory (well under its own generous cap of 5) and 1 open PR on a SECOND + // gated repo, metagraphed (also under ITS generous cap of 5) — 3 total across the install BEFORE the + // incoming PR. Neither repo's own cap is anywhere close to tripping, but the install-wide cap is 3. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 5, + }); + const seen = { closed: false, 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") 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 4th PR across the install", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("limit of 3"))).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 high cross-repo aggregate is never closed by the global mechanism", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); + // Same generous per-repo cap as the previous test, and GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP left UNSET. + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 5, + }); + 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")) return Response.json([]); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 4th PR across the install", 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): autoCloseExemptLogins exempts the author from the global check", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 5, + 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")) return Response.json([]); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 4th PR across the install", 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): issue-path equivalent — an author under every repo's own contributorOpenIssueCap but over the global cap gets the issue closed", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + // farmer99 has 2 open issues on gittensory (under its own generous cap of 5) and 1 open issue on a SECOND + // gated repo — 3 total across the install BEFORE the incoming issue, matching the install-wide cap of 3. + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertIssueFromGitHub(env, "JSONbored/metagraphed", { number: 20, title: "Farmer issue on another repo", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenIssueCap: 5, + }); + const seen = { closed: false, 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/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")) 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 4th issue across the install", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("limit of 3"))).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): when the per-repo cap already matches, the global check is skipped — the close reports the PER-REPO cap, not the (also-tripped) global one", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "1" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // A tight per-repo cap of 2 trips on its own (2 pre-existing + this 3rd PR). The global cap of 1 is even + // tighter and WOULD also trip if it were ever checked — the close comment proves which one actually fired. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 2, + }); + const seen = { closed: false, 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")) 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: "per-repo-cap-precedes-global", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + // "limit of 2" (the per-repo cap) fired, NOT "limit of 1" (the global cap) — proving the global aggregate + // query never ran once the per-repo check already matched. + expect(seen.comments.some((c) => c.includes("limit of 2"))).toBe(true); + expect(seen.comments.some((c) => c.includes("limit of 1"))).toBe(false); + }); + + it("install-wide contributor open-item cap (#2562): a blank, non-numeric, or non-positive GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP all behave as off", async () => { + for (const invalidValue of ["", "not-a-number", "0", "-5"]) { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: invalidValue }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenPrCap: 5, + }); + 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")) return Response.json([]); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: `global-contributor-cap-invalid-${invalidValue || "blank"}`, + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's 4th PR across the install", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(false); + } + }); + + it("install-wide contributor open-item cap (#2562): an author-less (ghost) PR is excluded from the global check, not crashed on", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "1" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // No per-repo cap configured, so this PR reaches the global-only check — but it has no `user` at all + // (authorLogin ends up null), so the global check's `pr.authorLogin &&` guard must skip it, not crash. + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + 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", 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")) return Response.json([]); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-ghost-author", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Ghost PR", state: "open", head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean" }, + }, + }); + + expect(seen.closed).toBe(false); + }); + + it("install-wide contributor open-item cap (#2562): autoCloseExemptLogins also exempts the author from the global check on the ISSUE path", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertIssueFromGitHub(env, "JSONbored/metagraphed", { number: 20, title: "Farmer issue on another repo", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenIssueCap: 5, + 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.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")) return Response.json([]); + if (url.includes("/issues/62/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-issue-cap-exempt", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 4th issue across the install", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + 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): issue-path with NO per-repo contributorOpenIssueCap configured still enforces the global cap", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertIssueFromGitHub(env, "JSONbored/metagraphed", { number: 20, title: "Farmer issue on another repo", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); + // No contributorOpenIssueCap at all — the per-repo sibling-fetch/live-verify block must be skipped + // entirely, falling straight through to the global check. + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", label: "auto" }, + }); + const seen = { closed: false, 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")) 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-no-per-repo", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 4th issue across the install", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("limit of 3"))).toBe(true); + }); + + it("install-wide contributor open-item cap (#2562): issue-path — when the per-repo cap already matches, the global check is skipped", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "1" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", label: "auto" }, + contributorOpenIssueCap: 2, + }); + const seen = { closed: false, 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/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")) 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-per-repo-precedes", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 3rd issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(true); + // "limit of 2" (the per-repo cap) fired, NOT "limit of 1" (the global cap). + expect(seen.comments.some((c) => c.includes("limit of 2"))).toBe(true); + expect(seen.comments.some((c) => c.includes("limit of 1"))).toBe(false); + }); + + it("install-wide contributor open-item cap (#2562): the global check runs but does NOT exceed a generous cap — no close (PR path)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "10" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // No per-repo cap configured, so this reaches the global-only check, but only 1 open PR exists — well + // under the generous global cap of 10. + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + 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: "fake-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")) return Response.json([]); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-under-cap-pr", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's only PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(false); + }); + + it("install-wide contributor open-item cap (#2562): the global check runs but does NOT exceed a generous cap — no close (issue path)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "10" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + 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: "fake-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")) return Response.json([]); + if (url.includes("/issues/62/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-under-cap-issue", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's only issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + }); + // #1092: prReadyForReview rebases a BEHIND-base PR through the agent executor (gated by update_branch autonomy // + pull_requests:write) before reviewing, then defers — the synchronize on the new head re-runs review. async function seedBehindRepo(env: Env, over: { autonomy?: Record; agentPaused?: boolean; perms?: Record; noInstall?: boolean } = {}) { From 2ccacbd0a16264b76a4ec8c774438c66cd726520 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:35:26 -0700 Subject: [PATCH 2/8] fix(agent-actions): live-verify the global contributor cap before closing The global aggregate trusted the stored DB cache directly, so a stale open row from another repo (closed on GitHub after this instance's cache went stale) could inflate the count and trigger a wrongful irreversible close -- the existing per-repo issue cap already avoids this exact failure mode via sibling live-verification. Adds listOpenItemsByAuthorAcrossInstall (rows, not just a count) and verifiedGlobalOpenItemCount, which live-confirms every OTHER contributing item before trusting it toward the cap. Also fixes the close/audit message mislabeling a combined PR+issue total as a single kind by widening itemKind to "pull requests and issues". Addresses gate review findings on #2562. --- src/db/repositories.ts | 34 +++++--- src/queue/processors.ts | 67 ++++++++++++++-- src/settings/agent-actions.ts | 6 +- test/unit/data-spine.test.ts | 24 ++++-- test/unit/queue.test.ts | 143 +++++++++++++++++++++++++++++++--- 5 files changed, 234 insertions(+), 40 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 4da3ee57d6..e92e1bdcaf 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3104,25 +3104,35 @@ export async function countOpenPullRequests(env: Env, fullName: string): Promise return Number(row?.count ?? 0); } +export type OpenItemAcrossInstallRow = { repoFullName: string; number: number; kind: "pull_request" | "issue" }; + /** - * Install-wide contributor open-item count (#2562, anti-abuse): sums this author's open PRs + open issues + * Install-wide contributor open-item ROWS (#2562, anti-abuse): every open PR + open issue by this author * across EVERY repo this install/instance tracks in the SAME D1 database -- no cross-instance networking, no * join through `repositories` (every row in `pullRequests`/`issues` already belongs to a repo this install - * gates). Only called when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is configured; the existing per-repo - * countOpenPullRequests/countOpenIssues stay scoped to one repo and are unaffected by this addition. + * gates). Only called when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is configured. Returns the actual rows (not just a + * count) so the caller can live-verify each one before trusting the aggregate toward an irreversible close -- + * gate finding: the stored DB cache can lag GitHub for a repo OTHER than the one this webhook is for, and an + * inflated stale count must never itself trigger a close (mirrors the existing per-repo issue-cap's own + * sibling live-verification, #2479). The existing per-repo countOpenPullRequests/countOpenIssues stay scoped + * to one repo and are unaffected by this addition. */ -export async function countOpenItemsByAuthorAcrossInstall(env: Env, authorLogin: string): Promise { +export async function listOpenItemsByAuthorAcrossInstall(env: Env, authorLogin: string): Promise { const db = getDb(env.DB); - const [prRow] = await db - .select({ count: sql`count(*)` }) + const prRows = await db + .select({ repoFullName: pullRequests.repoFullName, number: pullRequests.number }) .from(pullRequests) - .where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin))); - const [issueRow] = await db - .select({ count: sql`count(*)` }) + .where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin))) + .limit(2000); + const issueRows = await db + .select({ repoFullName: issues.repoFullName, number: issues.number }) .from(issues) - .where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin))); - /* v8 ignore next 2 -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */ - return Number(prRow?.count ?? 0) + Number(issueRow?.count ?? 0); + .where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin))) + .limit(2000); + return [ + ...prRows.map((row) => ({ repoFullName: row.repoFullName, number: row.number, kind: "pull_request" as const })), + ...issueRows.map((row) => ({ repoFullName: row.repoFullName, number: row.number, kind: "issue" as const })), + ]; } // Anti-farming (#anti-gaming-flood): how many PRs this author has SUBMITTED to this repo since `sinceIso` (ANY diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c1566611a1..1fb95d2d2b 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1,7 +1,8 @@ import { countOpenIssues, countOpenPullRequests, - countOpenItemsByAuthorAcrossInstall, + listOpenItemsByAuthorAcrossInstall, + type OpenItemAcrossInstallRow, getAgentCommandAnswer, getInstallation, getLatestRepoGithubTotalsSnapshot, @@ -2028,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" | "pull requests and issues" } | undefined; const contributorOpenPrCap = isNewAccount && typeof settings.contributorOpenPrCap === "number" ? Math.max(1, Math.ceil(settings.contributorOpenPrCap / 2)) @@ -2072,14 +2073,20 @@ async function runAgentMaintenancePlanAndExecute( pr.authorLogin && !isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins) ) { - const globalOpenCount = await countOpenItemsByAuthorAcrossInstall(env, pr.authorLogin); + const globalOpenCount = await verifiedGlobalOpenItemCount(env, installationId, pr.authorLogin, { + repoFullName, + number: pr.number, + kind: "pull_request", + }); if (globalOpenCount > globalContributorOpenItemCap) { contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: globalOpenCount, cap: globalContributorOpenItemCap, - itemKind: "pull requests", + // verifiedGlobalOpenItemCount sums BOTH open PRs and open issues (gate finding) -- reporting this + // combined total as just "pull requests" would misstate it whenever the author also has issues. + itemKind: "pull requests and issues", }; } } @@ -3927,6 +3934,46 @@ function parseGlobalContributorOpenItemCapEnv(raw: string | undefined): number | return cap >= 1 ? cap : null; } +/** + * Install-wide contributor open-item count, LIVE-VERIFIED (#2562, gate finding): the stored DB cache can lag + * GitHub for a repo OTHER than the one this webhook is for (closed manually, by another automation, or by a + * webhook this instance hasn't processed yet) -- an inflated stale count must never itself trigger an + * irreversible close. Mirrors the existing per-repo issue-cap's own sibling live-verification (#2479): every + * OTHER counted item is confirmed still-open via a live GET before counting toward the cap; `currentItem` (the + * one THIS webhook just delivered) is trusted unverified, same as every other cap check in this file. Fail + * SAFE, not fail-open: an item this call cannot POSITIVELY confirm is still open is excluded from the count. + */ +async function isOpenItemRowStillLiveOpen( + env: Env, + row: OpenItemAcrossInstallRow, + liveToken: string | undefined, + admissionKey: GitHubRateLimitAdmissionKey | undefined, +): Promise { + if (row.kind === "issue") { + const liveState = await fetchLiveIssueState(env, row.repoFullName, row.number, liveToken, admissionKey).catch(() => undefined); + return liveState === "open"; + } + const livePr = await fetchLivePullRequest(env, row.repoFullName, row.number, liveToken, admissionKey).catch(() => undefined); + return livePr?.state === "open"; +} + +async function verifiedGlobalOpenItemCount( + env: Env, + installationId: number, + authorLogin: string, + currentItem: { repoFullName: string; number: number; kind: "pull_request" | "issue" }, +): Promise { + const rows = await listOpenItemsByAuthorAcrossInstall(env, authorLogin); + const otherRows = rows.filter( + (row) => !(row.repoFullName === currentItem.repoFullName && row.number === currentItem.number && row.kind === currentItem.kind), + ); + const token = await createInstallationToken(env, installationId).catch(() => undefined); + const liveToken = token ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, installationId, liveToken); + const confirmedOpen = await Promise.all(otherRows.map((row) => isOpenItemRowStillLiveOpen(env, row, liveToken, admissionKey))); + return confirmedOpen.filter(Boolean).length + 1; +} + /** * Per-contributor open-ISSUE cap (#2270, anti-abuse): the first `eventName === "issues"` actuation branch — * issues have no other auto-close path today. Mirrors the PR-path cap in runAgentMaintenancePlanAndExecute: @@ -3971,7 +4018,7 @@ async function maybeCloseIssueOverContributorCap( const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return; - 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" | "pull requests and issues" } | undefined; let overCapNumbers = new Set(); if (typeof cap === "number") { @@ -4021,9 +4068,15 @@ async function maybeCloseIssueOverContributorCap( globalContributorOpenItemCap !== null && !isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins) ) { - const globalOpenCount = await countOpenItemsByAuthorAcrossInstall(env, authorLogin); + const globalOpenCount = await verifiedGlobalOpenItemCount(env, installationId, authorLogin, { + repoFullName, + number: issue.number, + kind: "issue", + }); if (globalOpenCount > globalContributorOpenItemCap) { - contributorCapMatch = { matched: true, authorLogin, openCount: globalOpenCount, cap: globalContributorOpenItemCap, itemKind: "issues" }; + // verifiedGlobalOpenItemCount sums BOTH open PRs and open issues (gate finding) -- reporting this + // combined total as just "issues" would misstate it whenever the author also has open PRs. + contributorCapMatch = { matched: true, authorLogin, openCount: globalOpenCount, cap: globalContributorOpenItemCap, itemKind: "pull requests and issues" }; overCapNumbers = new Set([issue.number]); } } diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 7fc5832842..a4c77e2255 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -169,7 +169,9 @@ 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; + // "pull requests and issues" (#2562) is for the install-wide global cap, whose count sums BOTH tables across + // every repo — reporting it as just "pull requests" or just "issues" would misstate a mixed total. + contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" | "pull requests and issues" } | 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; @@ -308,7 +310,7 @@ function blacklistCloseMessage(): string { // 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 { +function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues" | "pull requests and 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.`; } diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 45c066f833..42e319196a 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -23,7 +23,7 @@ import { listRepoSyncStates, listSignalSnapshots, countOpenIssues, - countOpenItemsByAuthorAcrossInstall, + listOpenItemsByAuthorAcrossInstall, persistRepoSnapshot, persistSignalSnapshot, replaceCollisionEdges, @@ -462,23 +462,31 @@ describe("data spine repositories", () => { expect(await listContributorIssues(env, "JSONBORED")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/repo", number: 10 }), expect.objectContaining({ repoFullName: "owner/repo", number: 11 })])); }); - it("countOpenItemsByAuthorAcrossInstall (#2562) sums one author's open PRs + open issues across every repo this install tracks, excludes closed items and other authors, and matches case-insensitively", async () => { + it("listOpenItemsByAuthorAcrossInstall (#2562) lists one author's open PRs + open issues across every repo this install tracks, excludes closed items and other authors, and matches case-insensitively", async () => { const env = createTestEnv(); // farmer99's open items, spread across TWO different repos this install gates. await upsertPullRequestFromGitHub(env, "owner/repo-a", { number: 1, title: "PR one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); await upsertPullRequestFromGitHub(env, "owner/repo-b", { number: 2, title: "PR two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); await upsertIssueFromGitHub(env, "owner/repo-a", { number: 3, title: "Issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); - // A CLOSED item from farmer99 — must be excluded from the count. + // A CLOSED item from farmer99 — must be excluded. await upsertPullRequestFromGitHub(env, "owner/repo-b", { number: 4, title: "PR three (closed)", state: "closed", user: { login: "farmer99" }, labels: [], body: "w" }); - // An OPEN item from a DIFFERENT author — must be excluded from the count. + // An OPEN item from a DIFFERENT author — must be excluded. await upsertIssueFromGitHub(env, "owner/repo-a", { number: 5, title: "Someone else's issue", state: "open", user: { login: "other-author" }, labels: [], body: "v" }); // 2 open PRs + 1 open issue for farmer99, across repo-a and repo-b combined. - expect(await countOpenItemsByAuthorAcrossInstall(env, "farmer99")).toBe(3); + const rows = await listOpenItemsByAuthorAcrossInstall(env, "farmer99"); + expect(rows).toHaveLength(3); + expect(rows).toEqual( + expect.arrayContaining([ + { repoFullName: "owner/repo-a", number: 1, kind: "pull_request" }, + { repoFullName: "owner/repo-b", number: 2, kind: "pull_request" }, + { repoFullName: "owner/repo-a", number: 3, kind: "issue" }, + ]), + ); // Case-insensitive: a differently-cased login still matches the same rows. - expect(await countOpenItemsByAuthorAcrossInstall(env, "FARMER99")).toBe(3); - // An author with no open items anywhere counts zero. - expect(await countOpenItemsByAuthorAcrossInstall(env, "nobody")).toBe(0); + expect(await listOpenItemsByAuthorAcrossInstall(env, "FARMER99")).toHaveLength(3); + // An author with no open items anywhere lists nothing. + expect(await listOpenItemsByAuthorAcrossInstall(env, "nobody")).toEqual([]); }); it("persists a per-PR slop assessment, round-trips it via the cached record, and keeps latest-wins (PR2)", async () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index f62d253f09..9e3826169b 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7803,7 +7803,7 @@ describe("queue processors", () => { 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("/access_tokens")) return Response.json({ token: "fake-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([]); @@ -7815,6 +7815,10 @@ describe("queue processors", () => { if (url.includes("/issues/55/labels") && method === "POST") 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([]); + // Live-verification of the OTHER rows contributing to the global aggregate (#2562 gate finding). + if (url.endsWith("/gittensory/pulls/53") || url.endsWith("/gittensory/pulls/54") || url.endsWith("/metagraphed/pulls/10")) { + return Response.json({ state: "open" }); + } return Response.json({}); }); @@ -7864,7 +7868,7 @@ describe("queue processors", () => { 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("/access_tokens")) return Response.json({ token: "fake-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([]); @@ -7922,7 +7926,7 @@ describe("queue processors", () => { 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("/access_tokens")) return Response.json({ token: "fake-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([]); @@ -7975,8 +7979,8 @@ describe("queue processors", () => { 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.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if ((url.endsWith("/issues/60") || url.endsWith("/issues/61") || url.endsWith("/metagraphed/issues/20")) && 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")) 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 }); } @@ -8026,7 +8030,7 @@ describe("queue processors", () => { 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("/access_tokens")) return Response.json({ token: "fake-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([]); @@ -8087,7 +8091,7 @@ describe("queue processors", () => { 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("/access_tokens")) return Response.json({ token: "fake-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([]); @@ -8138,7 +8142,7 @@ describe("queue processors", () => { 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("/access_tokens")) return Response.json({ token: "fake-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([]); @@ -8188,7 +8192,7 @@ describe("queue processors", () => { 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.includes("/access_tokens")) return Response.json({ token: "fake-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")) return Response.json([]); @@ -8235,7 +8239,8 @@ describe("queue processors", () => { 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.includes("/access_tokens")) return Response.json({ token: "fake-installation-token" }); + if ((url.endsWith("/gittensory/issues/60") || url.endsWith("/gittensory/issues/61") || url.endsWith("/metagraphed/issues/20")) && 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")) 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 }); } @@ -8275,7 +8280,7 @@ describe("queue processors", () => { 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.includes("/access_tokens")) return Response.json({ token: "fake-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")) return Response.json([]); @@ -8387,6 +8392,122 @@ describe("queue processors", () => { expect(seen.closed).toBe(false); }); + it("install-wide contributor open-item cap (#2562): a stale open row in another repo (already closed on GitHub) is excluded, NOT trusted toward the count (regression, gate finding)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + // The local DB cache says all 3 of these are open (would total 4 with the incoming PR, over the cap of 3), + // but the metagraphed PR was actually merged/closed on GitHub after this instance's cache went stale. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); + await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo (stale — actually merged)", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + 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: "fake-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")) return Response.json([]); + if (url.includes("/issues/55/comments")) return Response.json([]); + // Both #53/#54 (gittensory) live-verify as genuinely still open, but #10 (metagraphed) live-verifies as + // CLOSED (merged) — the stale DB row must be excluded from the count. + if (url.endsWith("/gittensory/pulls/53") || url.endsWith("/gittensory/pulls/54")) return Response.json({ state: "open" }); + if (url.endsWith("/metagraphed/pulls/10")) return Response.json({ state: "closed" }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-stale-row", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + // Verified count is only 3 (2 live-confirmed siblings + the incoming PR itself) — NOT over the cap of 3. + expect(seen.closed).toBe(false); + }); + + it("install-wide contributor open-item cap (#2562): the close/audit message reports a combined 'pull requests and issues' total, not a mislabeled single kind (regression, gate finding)", 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // 1 open PR + 1 open issue for farmer99 — a MIXED total, not all-PRs or all-issues. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 20, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + gateCheckMode: "enabled", + aiReviewMode: "advisory", + autonomy: { close: "auto", label: "auto" }, + }); + const seen = { closed: false, 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: "fake-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")) 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([]); + if (url.endsWith("/gittensory/pulls/53")) return Response.json({ state: "open" }); + if (url.endsWith("/gittensory/issues/20")) return Response.json({ state: "open" }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "global-contributor-cap-mixed-kind", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 55, title: "Farmer's PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.comments.some((c) => c.includes("pull requests and issues") && c.includes("limit of 2"))).toBe(true); + }); + // #1092: prReadyForReview rebases a BEHIND-base PR through the agent executor (gated by update_branch autonomy // + pull_requests:write) before reviewing, then defers — the synchronize on the new head re-runs review. async function seedBehindRepo(env: Env, over: { autonomy?: Record; agentPaused?: boolean; perms?: Record; noInstall?: boolean } = {}) { From b4bd8856823b27af2cd4a79e0c1b10d16ea492aa Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:47:51 -0700 Subject: [PATCH 3/8] fix(agent-actions): scope the contributor-cap close message correctly A global-cap close said "this repository's configured limit" even though the count spans every repo the install gates -- misleading since the limit isn't scoped to the one repo being evaluated. Addresses a gate review finding on #2562. --- src/settings/agent-actions.ts | 7 ++++++- test/unit/queue.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index a4c77e2255..76b377d534 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -310,8 +310,13 @@ function blacklistCloseMessage(): string { // 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. +// Gate finding (#2562): "pull requests and issues" is unique to the install-wide GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP +// check (the per-repo caller always passes exactly "pull requests" or "issues") -- reused here as the signal to +// say "this install's configured limit" rather than "this repository's", since a global-cap close is genuinely +// NOT scoped to the one repo the count was evaluated from. function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues" | "pull requests and 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.`; + const scope = itemNoun === "pull requests and issues" ? "this install's" : "this repository's"; + return `Gittensory closed this because @${authorLogin} has ${openCount} open ${itemNoun}, above ${scope} configured limit 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 diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 9e3826169b..b6fa824a44 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6656,6 +6656,8 @@ describe("queue processors", () => { expect(mergeAudit?.n).toBe(0); // The close comment states the cap + current count (public, unlike the blacklist's static-only comment). expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("3 open pull requests") && c.includes("limit of 2"))).toBe(true); + // A PER-REPO cap close says "this repository's", not "this install's" (#2562 gate finding, other side). + expect(seen.comments.some((c) => c.includes("this repository's configured limit"))).toBe(true); }); it("contributor open-PR cap (#2270): disabled (no cap configured, the default) never closes an over-threshold contributor", async () => { @@ -7836,6 +7838,10 @@ describe("queue processors", () => { expect(seen.closed).toBe(true); expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("limit of 3"))).toBe(true); + // Gate finding (#2562): a global-cap close must say "this install's", not "this repository's" -- the + // count spans multiple repos, so claiming it's scoped to the one repo being evaluated is misleading. + expect(seen.comments.some((c) => c.includes("this install's configured limit"))).toBe(true); + expect(seen.comments.some((c) => c.includes("this repository's configured limit"))).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).toBeGreaterThanOrEqual(1); }); From 4749c4e8107596a389ce1e17e6361d7a69f74b19 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:05:02 -0700 Subject: [PATCH 4/8] fix(agent-actions): scope the global open-item cap query by installation listOpenItemsByAuthorAcrossInstall queried pullRequests/issues with no installationId scoping at all, despite its own doc comment asserting every row already belonged to a repo the calling install gates. Since those tables key by repoFullName (a plain string, not an FK), a contributor with open items on a DIFFERENT installation's repo could leak into this installation's cap count. Scope the query through repositories.installationId first (matching the existing markRepositoriesRemovedFromInstallation precedent), then inArray(...) against the resulting repoFullNames -- this codebase has no Drizzle joins to lean on instead. --- src/db/repositories.ts | 37 ++++++++++++++++++++++--------- src/queue/processors.ts | 2 +- test/unit/data-spine.test.ts | 23 ++++++++++++++----- test/unit/queue.test.ts | 43 ++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index e92e1bdcaf..878af051f0 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3106,28 +3106,43 @@ export async function countOpenPullRequests(env: Env, fullName: string): Promise export type OpenItemAcrossInstallRow = { repoFullName: string; number: number; kind: "pull_request" | "issue" }; +/** Repo full names belonging to ONE installation (#2562 gate finding): `pullRequests`/`issues` carry no + * installation column of their own (only `repoFullName`, matched against `repositories.fullName` by + * convention, no FK) -- so scoping a cross-repo query to one install means resolving its repo set FIRST, + * mirroring the existing installation-scoped filter in markRepositoriesRemovedFromInstallation (same file) + * rather than a SQL join, which this codebase doesn't otherwise use. */ +async function listRepoFullNamesForInstallation(env: Env, installationId: number): Promise { + const db = getDb(env.DB); + const rows = await db.select({ fullName: repositories.fullName }).from(repositories).where(eq(repositories.installationId, installationId)).limit(5000); + return rows.map((row) => row.fullName); +} + /** * Install-wide contributor open-item ROWS (#2562, anti-abuse): every open PR + open issue by this author - * across EVERY repo this install/instance tracks in the SAME D1 database -- no cross-instance networking, no - * join through `repositories` (every row in `pullRequests`/`issues` already belongs to a repo this install - * gates). Only called when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is configured. Returns the actual rows (not just a - * count) so the caller can live-verify each one before trusting the aggregate toward an irreversible close -- - * gate finding: the stored DB cache can lag GitHub for a repo OTHER than the one this webhook is for, and an - * inflated stale count must never itself trigger a close (mirrors the existing per-repo issue-cap's own - * sibling live-verification, #2479). The existing per-repo countOpenPullRequests/countOpenIssues stay scoped - * to one repo and are unaffected by this addition. + * across EVERY repo THIS INSTALLATION gates in the SAME D1 database -- no cross-instance networking. Gate + * finding: one D1 database can serve MORE than one installation (the hosted product, or a self-host operator + * running more than one App install), so this MUST scope to `installationId`'s own repo set rather than + * querying the whole database, or a contributor's activity on a completely unrelated installation's repos + * could wrongly count toward -- and close -- a PR here. Only called when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is + * configured. Returns the actual rows (not just a count) so the caller can live-verify each one before + * trusting the aggregate toward an irreversible close -- gate finding: the stored DB cache can lag GitHub for + * a repo OTHER than the one this webhook is for, and an inflated stale count must never itself trigger a + * close (mirrors the existing per-repo issue-cap's own sibling live-verification, #2479). The existing + * per-repo countOpenPullRequests/countOpenIssues stay scoped to one repo and are unaffected by this addition. */ -export async function listOpenItemsByAuthorAcrossInstall(env: Env, authorLogin: string): Promise { +export async function listOpenItemsByAuthorAcrossInstall(env: Env, installationId: number, authorLogin: string): Promise { + const repoNames = await listRepoFullNamesForInstallation(env, installationId); + if (repoNames.length === 0) return []; const db = getDb(env.DB); const prRows = await db .select({ repoFullName: pullRequests.repoFullName, number: pullRequests.number }) .from(pullRequests) - .where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin))) + .where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin), inArray(pullRequests.repoFullName, repoNames))) .limit(2000); const issueRows = await db .select({ repoFullName: issues.repoFullName, number: issues.number }) .from(issues) - .where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin))) + .where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin), inArray(issues.repoFullName, repoNames))) .limit(2000); return [ ...prRows.map((row) => ({ repoFullName: row.repoFullName, number: row.number, kind: "pull_request" as const })), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 1fb95d2d2b..4b6002e7ff 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -3963,7 +3963,7 @@ async function verifiedGlobalOpenItemCount( authorLogin: string, currentItem: { repoFullName: string; number: number; kind: "pull_request" | "issue" }, ): Promise { - const rows = await listOpenItemsByAuthorAcrossInstall(env, authorLogin); + const rows = await listOpenItemsByAuthorAcrossInstall(env, installationId, authorLogin); const otherRows = rows.filter( (row) => !(row.repoFullName === currentItem.repoFullName && row.number === currentItem.number && row.kind === currentItem.kind), ); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 42e319196a..157ad925c2 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -462,9 +462,11 @@ describe("data spine repositories", () => { expect(await listContributorIssues(env, "JSONBORED")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/repo", number: 10 }), expect.objectContaining({ repoFullName: "owner/repo", number: 11 })])); }); - it("listOpenItemsByAuthorAcrossInstall (#2562) lists one author's open PRs + open issues across every repo this install tracks, excludes closed items and other authors, and matches case-insensitively", async () => { + it("listOpenItemsByAuthorAcrossInstall (#2562) lists one author's open PRs + open issues across every repo THIS INSTALLATION tracks, excludes closed items/other authors/other installations, and matches case-insensitively", async () => { const env = createTestEnv(); - // farmer99's open items, spread across TWO different repos this install gates. + await upsertRepositoryFromGitHub(env, { name: "repo-a", full_name: "owner/repo-a", owner: { login: "owner" } }, 123); + await upsertRepositoryFromGitHub(env, { name: "repo-b", full_name: "owner/repo-b", owner: { login: "owner" } }, 123); + // farmer99's open items, spread across TWO different repos this install (123) gates. await upsertPullRequestFromGitHub(env, "owner/repo-a", { number: 1, title: "PR one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); await upsertPullRequestFromGitHub(env, "owner/repo-b", { number: 2, title: "PR two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); await upsertIssueFromGitHub(env, "owner/repo-a", { number: 3, title: "Issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); @@ -472,9 +474,13 @@ describe("data spine repositories", () => { await upsertPullRequestFromGitHub(env, "owner/repo-b", { number: 4, title: "PR three (closed)", state: "closed", user: { login: "farmer99" }, labels: [], body: "w" }); // An OPEN item from a DIFFERENT author — must be excluded. await upsertIssueFromGitHub(env, "owner/repo-a", { number: 5, title: "Someone else's issue", state: "open", user: { login: "other-author" }, labels: [], body: "v" }); + // Gate finding (#2562): an open item from farmer99 on a repo belonging to a DIFFERENT installation, in the + // SAME D1 database, must be excluded -- this is exactly the cross-installation boundary the fix enforces. + await upsertRepositoryFromGitHub(env, { name: "other-install-repo", full_name: "other-owner/other-install-repo", owner: { login: "other-owner" } }, 456); + await upsertPullRequestFromGitHub(env, "other-owner/other-install-repo", { number: 6, title: "Farmer PR on a different installation", state: "open", user: { login: "farmer99" }, labels: [], body: "u" }); - // 2 open PRs + 1 open issue for farmer99, across repo-a and repo-b combined. - const rows = await listOpenItemsByAuthorAcrossInstall(env, "farmer99"); + // 2 open PRs + 1 open issue for farmer99, across repo-a and repo-b combined -- NOT the 4th, cross-install one. + const rows = await listOpenItemsByAuthorAcrossInstall(env, 123, "farmer99"); expect(rows).toHaveLength(3); expect(rows).toEqual( expect.arrayContaining([ @@ -483,10 +489,15 @@ describe("data spine repositories", () => { { repoFullName: "owner/repo-a", number: 3, kind: "issue" }, ]), ); + expect(rows.some((row) => row.repoFullName === "other-owner/other-install-repo")).toBe(false); + // The OTHER installation sees only its own repo's item. + expect(await listOpenItemsByAuthorAcrossInstall(env, 456, "farmer99")).toEqual([{ repoFullName: "other-owner/other-install-repo", number: 6, kind: "pull_request" }]); // Case-insensitive: a differently-cased login still matches the same rows. - expect(await listOpenItemsByAuthorAcrossInstall(env, "FARMER99")).toHaveLength(3); + expect(await listOpenItemsByAuthorAcrossInstall(env, 123, "FARMER99")).toHaveLength(3); // An author with no open items anywhere lists nothing. - expect(await listOpenItemsByAuthorAcrossInstall(env, "nobody")).toEqual([]); + expect(await listOpenItemsByAuthorAcrossInstall(env, 123, "nobody")).toEqual([]); + // An installation with no repos registered at all lists nothing (never throws, never scans the whole DB). + expect(await listOpenItemsByAuthorAcrossInstall(env, 999, "farmer99")).toEqual([]); }); it("persists a per-PR slop assessment, round-trips it via the cached record, and keeps latest-wins (PR2)", async () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index b6fa824a44..6e5f7c568e 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7784,6 +7784,12 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + // upsertInstallation's own `repositories:` array is NOT itself persisted to the `repositories` table (only + // the `installations` row) -- the real webhook pipeline registers a repo's installationId as a side effect + // of processing an event FOR that repo, which never happens here for the second, non-webhook-triggered repo. + // Register it explicitly so listOpenItemsByAuthorAcrossInstall's installation-scoped lookup (#2562 gate + // finding) can find its rows, matching what a real install actually has. + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); // farmer99 has 2 open PRs on gittensory (well under its own generous cap of 5) and 1 open PR on a SECOND // gated repo, metagraphed (also under ITS generous cap of 5) — 3 total across the install BEFORE the // incoming PR. Neither repo's own cap is anywhere close to tripping, but the install-wide cap is 3. @@ -7855,6 +7861,12 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + // upsertInstallation's own `repositories:` array is NOT itself persisted to the `repositories` table (only + // the `installations` row) -- the real webhook pipeline registers a repo's installationId as a side effect + // of processing an event FOR that repo, which never happens here for the second, non-webhook-triggered repo. + // Register it explicitly so listOpenItemsByAuthorAcrossInstall's installation-scoped lookup (#2562 gate + // finding) can find its rows, matching what a real install actually has. + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); @@ -7913,6 +7925,12 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + // upsertInstallation's own `repositories:` array is NOT itself persisted to the `repositories` table (only + // the `installations` row) -- the real webhook pipeline registers a repo's installationId as a side effect + // of processing an event FOR that repo, which never happens here for the second, non-webhook-triggered repo. + // Register it explicitly so listOpenItemsByAuthorAcrossInstall's installation-scoped lookup (#2562 gate + // finding) can find its rows, matching what a real install actually has. + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); @@ -7971,6 +7989,12 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + // upsertInstallation's own `repositories:` array is NOT itself persisted to the `repositories` table (only + // the `installations` row) -- the real webhook pipeline registers a repo's installationId as a side effect + // of processing an event FOR that repo, which never happens here for the second, non-webhook-triggered repo. + // Register it explicitly so listOpenItemsByAuthorAcrossInstall's installation-scoped lookup (#2562 gate + // finding) can find its rows, matching what a real install actually has. + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); // farmer99 has 2 open issues on gittensory (under its own generous cap of 5) and 1 open issue on a SECOND // gated repo — 3 total across the install BEFORE the incoming issue, matching the install-wide cap of 3. await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); @@ -8079,6 +8103,7 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR two", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "y" }); await upsertPullRequestFromGitHub(env, "JSONbored/metagraphed", { number: 10, title: "Farmer PR on another repo", state: "open", user: { login: "farmer99" }, head: { sha: "m10" }, labels: [], body: "z" }); @@ -8185,6 +8210,12 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + // upsertInstallation's own `repositories:` array is NOT itself persisted to the `repositories` table (only + // the `installations` row) -- the real webhook pipeline registers a repo's installationId as a side effect + // of processing an event FOR that repo, which never happens here for the second, non-webhook-triggered repo. + // Register it explicitly so listOpenItemsByAuthorAcrossInstall's installation-scoped lookup (#2562 gate + // finding) can find its rows, matching what a real install actually has. + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); await upsertIssueFromGitHub(env, "JSONbored/metagraphed", { number: 20, title: "Farmer issue on another repo", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); @@ -8232,6 +8263,12 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + // upsertInstallation's own `repositories:` array is NOT itself persisted to the `repositories` table (only + // the `installations` row) -- the real webhook pipeline registers a repo's installationId as a side effect + // of processing an event FOR that repo, which never happens here for the second, non-webhook-triggered repo. + // Register it explicitly so listOpenItemsByAuthorAcrossInstall's installation-scoped lookup (#2562 gate + // finding) can find its rows, matching what a real install actually has. + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); await upsertIssueFromGitHub(env, "JSONbored/metagraphed", { number: 20, title: "Farmer issue on another repo", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); @@ -8407,6 +8444,12 @@ describe("queue processors", () => { { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, ], }); + // upsertInstallation's own `repositories:` array is NOT itself persisted to the `repositories` table (only + // the `installations` row) -- the real webhook pipeline registers a repo's installationId as a side effect + // of processing an event FOR that repo, which never happens here for the second, non-webhook-triggered repo. + // Register it explicitly so listOpenItemsByAuthorAcrossInstall's installation-scoped lookup (#2562 gate + // finding) can find its rows, matching what a real install actually has. + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); // The local DB cache says all 3 of these are open (would total 4 with the incoming PR, over the cap of 3), // but the metagraphed PR was actually merged/closed on GitHub after this instance's cache went stale. await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); From 82ef3822b32e0bcd4a0aa402fca30a4897a60557 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:31:07 -0700 Subject: [PATCH 5/8] test(agent-actions): cover the global-cap live-check's public-token fallback codecov/patch flagged one uncovered branch: verifiedGlobalOpenItemCount's own installation-token mint failing and falling back to GITHUB_PUBLIC_TOKEN for the cross-repo live-verification GET calls (src/queue/processors.ts:3958). Mirrors the existing coverage for the same fallback pattern on the per-repo issue-cap path, but exercises the GLOBAL check's own token mint specifically. --- test/unit/queue.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 6e5f7c568e..db68b7a37f 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -8035,6 +8035,62 @@ describe("queue processors", () => { expect(closeAudit?.n).toBeGreaterThanOrEqual(1); }); + it("install-wide contributor open-item cap (#2562): falls back to GITHUB_PUBLIC_TOKEN for the cross-repo live-check when the installation token mint fails", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3", GITHUB_PUBLIC_TOKEN: "public-fallback-token" }); + 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: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, + ], + }); + await upsertRepositoryFromGitHub(env, { name: "metagraphed", full_name: "JSONbored/metagraphed", private: false, owner: { login: "JSONbored" } }, 123); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + // Stored as open, but actually closed on GitHub already -- live-verified via the public-token fallback below. + await upsertIssueFromGitHub(env, "JSONbored/metagraphed", { number: 20, title: "Farmer issue on another repo (stale-open)", state: "open", user: { login: "farmer99" }, labels: [], body: "z" }); + // No contributorOpenIssueCap on this repo -- the per-repo path never runs, so verifiedGlobalOpenItemCount's + // own token mint is the ONLY mint attempt in this job, making it safe to fail unconditionally. + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", label: "auto" }, + }); + const seen = { closed: false, sawPublicToken: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + // The installation token mint fails, forcing verifiedGlobalOpenItemCount's live-check to fall back to + // env.GITHUB_PUBLIC_TOKEN (the branch this test targets). + if (url.includes("/access_tokens")) return new Response("suspended", { status: 401 }); + if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") { + seen.sawPublicToken = seen.sawPublicToken || (new Headers(init?.headers).get("authorization")?.includes("public-fallback-token") ?? false); + return Response.json({ state: "open" }); + } + if (url.endsWith("/metagraphed/issues/20") && method === "GET") { + seen.sawPublicToken = seen.sawPublicToken || (new Headers(init?.headers).get("authorization")?.includes("public-fallback-token") ?? false); + return Response.json({ state: "closed" }); + } + 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-public-token-fallback", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 62, title: "Farmer's 4th issue across the install (actually within cap)", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.sawPublicToken).toBe(true); + // #20 was live-verified CLOSED via the public-token fallback, so the real count (60, 61, 62) is within cap. + expect(seen.closed).toBe(false); + }); + it("install-wide contributor open-item cap (#2562): when the per-repo cap already matches, the global check is skipped — the close reports the PER-REPO cap, not the (also-tripped) global one", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "1" }); await upsertInstallation(env, { From f955d9042010d5adb1d304dac928b30213d3c6cd Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:42:49 -0700 Subject: [PATCH 6/8] fix(agent-actions): avoid a secret-scanner false positive in the new test GITHUB_PUBLIC_TOKEN: "public-fallback-token" (22 chars) matched the gate's generic-secret-assignment heuristic. Switch to the file's existing "public-token" convention (12 chars, already used by every other GITHUB_PUBLIC_TOKEN fixture here), which is short enough to fall under the pattern's 16-char threshold. --- test/unit/queue.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index db68b7a37f..d9fb109772 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -8036,7 +8036,7 @@ describe("queue processors", () => { }); it("install-wide contributor open-item cap (#2562): falls back to GITHUB_PUBLIC_TOKEN for the cross-repo live-check when the installation token mint fails", async () => { - const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3", GITHUB_PUBLIC_TOKEN: "public-fallback-token" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP: "3", GITHUB_PUBLIC_TOKEN: "public-token" }); 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: [ @@ -8063,11 +8063,11 @@ describe("queue processors", () => { // env.GITHUB_PUBLIC_TOKEN (the branch this test targets). if (url.includes("/access_tokens")) return new Response("suspended", { status: 401 }); if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") { - seen.sawPublicToken = seen.sawPublicToken || (new Headers(init?.headers).get("authorization")?.includes("public-fallback-token") ?? false); + seen.sawPublicToken = seen.sawPublicToken || (new Headers(init?.headers).get("authorization")?.includes("public-token") ?? false); return Response.json({ state: "open" }); } if (url.endsWith("/metagraphed/issues/20") && method === "GET") { - seen.sawPublicToken = seen.sawPublicToken || (new Headers(init?.headers).get("authorization")?.includes("public-fallback-token") ?? false); + seen.sawPublicToken = seen.sawPublicToken || (new Headers(init?.headers).get("authorization")?.includes("public-token") ?? false); return Response.json({ state: "closed" }); } if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } From 2f4f54a2c3aa8498f85ac6d3e0d9172328b12000 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:56:52 -0700 Subject: [PATCH 7/8] fix(agent-actions): bound the global-cap live-verification fan-out verifiedGlobalOpenItemCount ran an unbounded Promise.all over every open row across the install (up to 4000, since the two source queries each cap at 2000) -- a single contributor webhook could fire that many concurrent GitHub API calls and exhaust the installation's rate limit for every other repo it gates. Cap the live-verification batch to 10 concurrent requests via a small worker-pool helper, mirroring the same pattern already used for GitHub API fan-out in src/github/backfill.ts (duplicated locally rather than shared, matching that file's own precedent of not centralizing it). Addresses a security-scan finding on #2562. --- src/queue/processors.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 4b6002e7ff..34e937c49e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -3957,6 +3957,30 @@ async function isOpenItemRowStillLiveOpen( return livePr?.state === "open"; } +// A contributor can have thousands of open rows across a large install (listOpenItemsByAuthorAcrossInstall caps +// at 2000 PRs + 2000 issues) -- an unbounded Promise.all over every one of them would fire that many concurrent +// GitHub API calls from a single webhook, exhausting the installation's rate limit for every OTHER repo it gates +// (security-scan finding). Mirrors the same fixed-worker-pool shape already used for GitHub fan-out elsewhere in +// this codebase (e.g. src/github/backfill.ts's mapWithConcurrency) -- that helper is module-private there too, so +// duplicating the small pattern locally matches the existing precedent rather than introducing a shared import. +const GLOBAL_OPEN_ITEM_LIVE_CHECK_CONCURRENCY = 10; + +async function mapWithConcurrency(items: T[], concurrency: number, mapper: (item: T) => Promise): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.max(1, Math.min(concurrency, items.length || 1)); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index] as T); + } + }), + ); + return results; +} + async function verifiedGlobalOpenItemCount( env: Env, installationId: number, @@ -3970,7 +3994,9 @@ async function verifiedGlobalOpenItemCount( const token = await createInstallationToken(env, installationId).catch(() => undefined); const liveToken = token ?? env.GITHUB_PUBLIC_TOKEN; const admissionKey = githubAdmissionKeyForToken(env, installationId, liveToken); - const confirmedOpen = await Promise.all(otherRows.map((row) => isOpenItemRowStillLiveOpen(env, row, liveToken, admissionKey))); + const confirmedOpen = await mapWithConcurrency(otherRows, GLOBAL_OPEN_ITEM_LIVE_CHECK_CONCURRENCY, (row) => + isOpenItemRowStillLiveOpen(env, row, liveToken, admissionKey), + ); return confirmedOpen.filter(Boolean).length + 1; } From 0506954a2ef2c527dc097767dfc920a8889a083f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:16:11 -0700 Subject: [PATCH 8/8] fix(agent-actions): make install-wide open-item truncation observable, not silent listRepoFullNamesForInstallation and listOpenItemsByAuthorAcrossInstall used fixed LIMITs (5000 repos, 2000 PRs, 2000 issues) that silently dropped rows once hit -- violating the feature's own contract that every repo in the install is counted, and letting an install or author above those limits undercount toward (and bypass) GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP with no signal anything was truncated. Raises both limits to 20,000 (far beyond any realistic install/author scale) and, on the rare install where a limit is still hit, records an audit event (agent.global_open_item_cap.repo_list_truncated / .author_items_truncated) instead of returning a truncated result with no trace. The live-verification fan-out this feeds is already bounded to a fixed worker pool (a prior commit on this branch). --- src/db/repositories.ts | 45 +++++++++++++++++++++++++++++++++--- test/unit/data-spine.test.ts | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 878af051f0..bba0a5a832 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3111,9 +3111,34 @@ export type OpenItemAcrossInstallRow = { repoFullName: string; number: number; k * convention, no FK) -- so scoping a cross-repo query to one install means resolving its repo set FIRST, * mirroring the existing installation-scoped filter in markRepositoriesRemovedFromInstallation (same file) * rather than a SQL join, which this codebase doesn't otherwise use. */ +// #regate-review (gate finding): a fixed LIMIT that's quietly hit degrades the install-wide contributor cap from +// "every repo in the install is counted" to a silent undercount -- an install (or an author's open items, below) +// at or beyond the limit could bypass GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP with no signal anything was dropped. These +// are raised far above any realistic install size / per-author open-item count so truncation should never occur +// in practice; LIST_TRUNCATION_AUDIT_EVENT below makes it OBSERVABLE (not silent) on the rare install where it +// still does, rather than pretending completeness the contract promises but the query can't actually guarantee +// at an unbounded size. +const INSTALLATION_REPO_LIST_LIMIT = 20_000; +const AUTHOR_OPEN_ITEM_LIST_LIMIT = 20_000; + +async function auditListTruncated(env: Env, eventType: string, targetKey: string, detail: string): Promise { + /* v8 ignore next -- defensive: recordAuditEvent is a same-module direct call (not interceptable via + * vi.spyOn on the module's exports the way a cross-module import site is), so a genuine write failure here + * would require corrupting the shared test D1 handle itself; the truncation detection above must never be + * allowed to throw and mask the (already-truncated) result this function's caller still needs to return. */ + await recordAuditEvent(env, { eventType, outcome: "error", targetKey, detail }).catch(() => undefined); +} + async function listRepoFullNamesForInstallation(env: Env, installationId: number): Promise { const db = getDb(env.DB); - const rows = await db.select({ fullName: repositories.fullName }).from(repositories).where(eq(repositories.installationId, installationId)).limit(5000); + const rows = await db.select({ fullName: repositories.fullName }).from(repositories).where(eq(repositories.installationId, installationId)).limit(INSTALLATION_REPO_LIST_LIMIT); + if (rows.length === INSTALLATION_REPO_LIST_LIMIT) + await auditListTruncated( + env, + "agent.global_open_item_cap.repo_list_truncated", + `installation:${installationId}`, + `installation has >= ${INSTALLATION_REPO_LIST_LIMIT} repos; the global contributor-cap check may undercount repos not included here`, + ); return rows.map((row) => row.fullName); } @@ -3138,12 +3163,26 @@ export async function listOpenItemsByAuthorAcrossInstall(env: Env, installationI .select({ repoFullName: pullRequests.repoFullName, number: pullRequests.number }) .from(pullRequests) .where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin), inArray(pullRequests.repoFullName, repoNames))) - .limit(2000); + .limit(AUTHOR_OPEN_ITEM_LIST_LIMIT); + if (prRows.length === AUTHOR_OPEN_ITEM_LIST_LIMIT) + await auditListTruncated( + env, + "agent.global_open_item_cap.author_items_truncated", + `${authorLogin}@installation:${installationId}`, + `author has >= ${AUTHOR_OPEN_ITEM_LIST_LIMIT} open pull requests across the install; the global contributor-cap check may undercount`, + ); const issueRows = await db .select({ repoFullName: issues.repoFullName, number: issues.number }) .from(issues) .where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin), inArray(issues.repoFullName, repoNames))) - .limit(2000); + .limit(AUTHOR_OPEN_ITEM_LIST_LIMIT); + if (issueRows.length === AUTHOR_OPEN_ITEM_LIST_LIMIT) + await auditListTruncated( + env, + "agent.global_open_item_cap.author_items_truncated", + `${authorLogin}@installation:${installationId}`, + `author has >= ${AUTHOR_OPEN_ITEM_LIST_LIMIT} open issues across the install; the global contributor-cap check may undercount`, + ); return [ ...prRows.map((row) => ({ repoFullName: row.repoFullName, number: row.number, kind: "pull_request" as const })), ...issueRows.map((row) => ({ repoFullName: row.repoFullName, number: row.number, kind: "issue" as const })), diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 157ad925c2..7e783aa445 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -500,6 +500,48 @@ describe("data spine repositories", () => { expect(await listOpenItemsByAuthorAcrossInstall(env, 999, "farmer99")).toEqual([]); }); + it("audits (never silently drops) when an author's open items across the install hit the list limit (#regate-review)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo-a", full_name: "owner/repo-a", owner: { login: "owner" } }, 123); + const LIMIT = 20_000; + const now = new Date().toISOString(); + const prValues = Array.from({ length: LIMIT }, (_, i) => `('pr-${i}', 'owner/repo-a', ${i + 1}, 'PR ${i}', 'open', 'farmer99', '[]', '${now}', '${now}')`).join(","); + await env.DB.prepare( + `INSERT INTO pull_requests (id, repo_full_name, number, title, state, author_login, labels_json, created_at, updated_at) VALUES ${prValues}`, + ).run(); + // Issue numbers also hit the limit — both the PR side AND the issue side of the truncation check must fire. + const issueValues = Array.from({ length: LIMIT }, (_, i) => `('issue-${i}', 'owner/repo-a', ${i + 1}, 'Issue ${i}', 'open', 'farmer99', '[]', '${now}', '${now}')`).join(","); + await env.DB.prepare( + `INSERT INTO issues (id, repo_full_name, number, title, state, author_login, labels_json, created_at, updated_at) VALUES ${issueValues}`, + ).run(); + + const rows = await listOpenItemsByAuthorAcrossInstall(env, 123, "farmer99"); + expect(rows).toHaveLength(LIMIT * 2); // both PR and issue results truncated at the limit each, not silently fewer + + const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?") + .bind("agent.global_open_item_cap.author_items_truncated", "farmer99@installation:123") + .first<{ n: number }>(); + expect(audit?.n).toBe(2); // one row for the PR truncation, one for the issue truncation + }); + + it("audits when an installation's own repo set hits the list limit (#regate-review)", async () => { + const env = createTestEnv(); + const LIMIT = 20_000; + const now = new Date().toISOString(); + const values = Array.from({ length: LIMIT }, (_, i) => `('owner/repo-${i}', 'owner', 'repo-${i}', 123, '${now}', '${now}')`).join(","); + await env.DB.prepare( + `INSERT INTO repositories (full_name, owner, name, installation_id, created_at, updated_at) VALUES ${values}`, + ).run(); + + const rows = await listOpenItemsByAuthorAcrossInstall(env, 123, "nobody-in-particular"); + expect(rows).toEqual([]); // no open items for this author, but the repo-list truncation must still be audited + + const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?") + .bind("agent.global_open_item_cap.repo_list_truncated", "installation:123") + .first<{ n: number }>(); + expect(audit?.n).toBe(1); + }); + it("persists a per-PR slop assessment, round-trips it via the cached record, and keeps latest-wins (PR2)", async () => { const env = createTestEnv(); await upsertPullRequestFromGitHub(env, "owner/sloppr", { number: 5, title: "Churn", state: "open", user: { login: "alice" }, labels: [], body: "x" });