Skip to content

Commit 0e85cee

Browse files
authored
feat(agent-actions): add an install-wide contributor open-item cap across repos (#2678)
A self-hosted install that gates multiple repos shares one database, but the per-repo contributorOpenPrCap/contributorOpenIssueCap only ever count open items on the same repo, so an actor spreading low-volume spam/farming PRs across several gated repos in one install never trips any single repo's cap. This adds an optional GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP env var, checked in addition to (not instead of) the existing per-repo caps via a same-database aggregate query over every repo the install tracks -- no cross-instance networking, off by default, reusing the existing contributor_cap closeKind and close-message shape (mirrors global_contributor_blacklist's install-scoped singleton pattern). Closes #2562
1 parent 00d1490 commit 0e85cee

8 files changed

Lines changed: 627 additions & 8 deletions

File tree

src/db/repositories.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3104,6 +3104,28 @@ export async function countOpenPullRequests(env: Env, fullName: string): Promise
31043104
return Number(row?.count ?? 0);
31053105
}
31063106

3107+
/**
3108+
* Install-wide open-item count for one author (#2562, anti-abuse): SUM of this author's open PRs + open
3109+
* issues across EVERY repo tracked in this install's database -- deliberately NOT scoped by repoFullName,
3110+
* unlike countOpenPullRequests/countOpenIssues above. This is what makes the globalContributorOpenItemCap
3111+
* catch an actor spreading low-volume spam across several gated repos in the same self-hosted install: no
3112+
* single repo's own cap trips, but the aggregate does. Same-database aggregate only -- no cross-instance
3113+
* networking, mirroring the install-scoped singleton shape of global_contributor_blacklist. Case-insensitive
3114+
* login match (mirrors loginMatches/findBlacklistEntry elsewhere in this file).
3115+
*/
3116+
export async function countOpenItemsForAuthorAcrossRepos(env: Env, authorLogin: string): Promise<number> {
3117+
const db = getDb(env.DB);
3118+
const [[prRow], [issueRow]] = await Promise.all([
3119+
db.select({ count: sql<number>`count(*)` }).from(pullRequests).where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin))),
3120+
db.select({ count: sql<number>`count(*)` }).from(issues).where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin))),
3121+
]);
3122+
/* v8 ignore next -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */
3123+
const prCount = Number(prRow?.count ?? 0);
3124+
/* v8 ignore next -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */
3125+
const issueCount = Number(issueRow?.count ?? 0);
3126+
return prCount + issueCount;
3127+
}
3128+
31073129
// Anti-farming (#anti-gaming-flood): how many PRs this author has SUBMITTED to this repo since `sinceIso` (ANY
31083130
// state — open/merged/closed), so a flood that merges fast is still caught. createdAt is the row-insert time
31093131
// (≈ when gittensory first saw the PR), a good proxy for submission time on live webhook-driven PRs.

src/env.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ declare global {
8080
onMerge?: import("./services/ai-review").OnMerge | undefined;
8181
};
8282
ADMIN_GITHUB_LOGINS?: string;
83+
/** Install-wide contributor open-item cap (#2562, anti-abuse): the max PRs+issues a single non-owner/
84+
* admin/bot contributor may have open ACROSS EVERY repo this install gates, combined. Purely an
85+
* install-scoped aggregate over this same database (no cross-instance networking) -- catches an actor
86+
* spreading low-volume spam/farming PRs across several gated repos in one self-hosted install, which no
87+
* single repo's own contributorOpenPrCap/contributorOpenIssueCap can see. Unset/invalid (the default) = no
88+
* cap, byte-identical to today. Checked IN ADDITION TO (not instead of) the existing per-repo caps, in the
89+
* same contributor_cap short-circuit (src/settings/agent-actions.ts). A positive integer string (e.g. "20");
90+
* see src/settings/global-contributor-cap.ts for parsing. */
91+
GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string;
8392
GITHUB_WEBHOOK_SECRET: string;
8493
GITHUB_WEBHOOK_MAX_BODY_BYTES?: string;
8594
/** Webhook secret for the central Gittensory Orb GitHub App (#1255) — distinct from the review app's

src/queue/processors.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
countOpenIssues,
3+
countOpenItemsForAuthorAcrossRepos,
34
countOpenPullRequests,
45
getAgentCommandAnswer,
56
getInstallation,
@@ -245,6 +246,7 @@ import {
245246
type PlannedAgentAction,
246247
} from "../settings/agent-actions";
247248
import { isAutoCloseExempt } from "../settings/auto-close-exempt";
249+
import { resolveGlobalContributorOpenItemCap } from "../settings/global-contributor-cap";
248250
import { detectMigrationCollisions, extractMigrationNumber, KNOWN_MIGRATION_DUPLICATES } from "../db/migration-collisions";
249251
import { listMigrationFilenamesAtRef } from "../github/migration-tree";
250252
import {
@@ -2034,7 +2036,7 @@ async function runAgentMaintenancePlanAndExecute(
20342036
// default) ⇒ this block is a no-op. A below-account-age-threshold author (#2561) gets a TIGHTER effective
20352037
// cap (half, rounded up, minimum 1) — visibility/friction, still never a close on account age by itself
20362038
// (the close, if any, is still tagged/reasoned as the ordinary contributor-cap close).
2037-
let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined;
2039+
let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues"; scope?: "repository" | "install" | undefined } | undefined;
20382040
const contributorOpenPrCap =
20392041
isNewAccount && typeof settings.contributorOpenPrCap === "number"
20402042
? Math.max(1, Math.ceil(settings.contributorOpenPrCap / 2))
@@ -2063,6 +2065,22 @@ async function runAgentMaintenancePlanAndExecute(
20632065
}
20642066
}
20652067

2068+
// Install-wide contributor open-item cap (#2562, anti-abuse): IN ADDITION TO the per-repo cap above, not
2069+
// instead of it -- only evaluated when the per-repo cap didn't already match (short-circuit: no need for a
2070+
// second cross-repo DB read once this PR is already being closed). Off by default (resolveGlobalContributorOpenItemCap
2071+
// returns null when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is unset/invalid) ⇒ zero extra queries, zero behavior
2072+
// change for an install that hasn't opted in. Reuses the shared autoCloseExemptLogins list (#2463) so a
2073+
// maintainer-named login is exempt here exactly like the per-repo caps and review-nag cooldown.
2074+
if (contributorCapMatch === undefined && pr.authorLogin && !isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) {
2075+
const globalCap = resolveGlobalContributorOpenItemCap(env);
2076+
if (globalCap !== null) {
2077+
const installOpenCount = await countOpenItemsForAuthorAcrossRepos(env, pr.authorLogin);
2078+
if (installOpenCount > globalCap) {
2079+
contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: installOpenCount, cap: globalCap, itemKind: "pull requests", scope: "install" };
2080+
}
2081+
}
2082+
}
2083+
20662084
const planned = planAgentMaintenanceActions({
20672085
conclusion: gate.conclusion,
20682086
blockerTitles: gate.blockers.map((blocker) => blocker.title),
@@ -3948,14 +3966,50 @@ async function maybeCloseIssueOverContributorCap(
39483966
const { installationId, repoFullName, issue, settings } = args;
39493967
const cap = settings.contributorOpenIssueCap;
39503968
const authorLogin = issue.authorLogin;
3951-
if (typeof cap !== "number" || !authorLogin) return;
3969+
// Install-wide cap (#2562) is checked IN ADDITION TO the per-repo cap, so this function must still run when
3970+
// ONLY the global cap is configured (the per-repo cap stays optional/off, its usual default).
3971+
const globalCap = resolveGlobalContributorOpenItemCap(env);
3972+
if ((typeof cap !== "number" && globalCap === null) || !authorLogin) return;
39523973

39533974
const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : "";
39543975
const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase();
39553976
const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase());
39563977
const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin);
39573978
if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return;
39583979

3980+
// Install-wide check first (#2562): reuses the shared autoCloseExemptLogins list, same as the PR path. A
3981+
// match here closes THIS issue directly (unlike the per-repo cap below, there is no cross-repo sibling set to
3982+
// union/live-verify -- the aggregate count already covers every repo, so a single over-cap read is enough).
3983+
if (globalCap !== null && !isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins)) {
3984+
const installOpenCount = await countOpenItemsForAuthorAcrossRepos(env, authorLogin);
3985+
if (installOpenCount > globalCap) {
3986+
const planned = planAgentMaintenanceActions({
3987+
conclusion: "skipped",
3988+
blockerTitles: [],
3989+
autonomy: settings.autonomy,
3990+
changedPaths: [],
3991+
hardGuardrailGlobs: [],
3992+
authorIsOwner,
3993+
authorIsAdmin,
3994+
authorIsAutomationBot,
3995+
ciState: "unverified",
3996+
contributorCapMatch: { matched: true, authorLogin, openCount: installOpenCount, cap: globalCap, itemKind: "issues", scope: "install" },
3997+
contributorCapLabel: settings.contributorCapLabel,
3998+
pr: { labels: [] },
3999+
});
4000+
if (planned.length > 0) {
4001+
await executeIssueMaintenanceActions(
4002+
env,
4003+
{ installationId, repoFullName, issueNumber: issue.number, autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun },
4004+
planned,
4005+
);
4006+
}
4007+
return;
4008+
}
4009+
}
4010+
4011+
if (typeof cap !== "number") return;
4012+
39594013
const otherOpenIssues = await listOpenIssues(env, repoFullName);
39604014
const authorLoginLower = authorLogin.toLowerCase();
39614015
const otherAuthorIssueNumbers = otherOpenIssues

src/settings/agent-actions.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,11 @@ export type AgentActionPlanInput = {
169169
// so — unlike the blacklist's private-reason close — they ARE interpolated into the public close comment.
170170
// `itemKind` selects the close-comment noun ("pull requests" for the PR-path caller, "issues" for the
171171
// issue-path caller, #2270) — REQUIRED (not defaulted) so a caller can't silently mislabel the other kind.
172-
contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined;
172+
// `scope` (#2562) selects the close-comment's cap description: "repository" (default when absent, back-compat
173+
// for every existing per-repo caller) says "this repository's configured limit"; "install" says "across every
174+
// repository this install gates, combined" for the install-wide globalContributorOpenItemCap. Same closeKind
175+
// ("contributor_cap") and label either way — this is a description-only distinction, not a new disposition.
176+
contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues"; scope?: "repository" | "install" | undefined } | undefined;
173177
// The repo-configured label applied to an over-cap author's PR/issue (#2270), resolved from `.gittensory.yml`.
174178
// Absent ⇒ the default (`DEFAULT_CONTRIBUTOR_CAP_LABEL` = "over-contributor-limit").
175179
contributorCapLabel?: string | undefined;
@@ -307,9 +311,13 @@ function blacklistCloseMessage(): string {
307311
// The close comment for exceeding the per-contributor open-item cap (#2270). Unlike blacklistCloseMessage, this
308312
// DOES interpolate authorLogin/openCount/cap — none of that is private (the author's own login and their own
309313
// open-item count on a public repo are already public/derivable from GitHub itself), and stating the exact
310-
// numbers is the point: a deterministic, contributor-visible cap, not a silent quality-based hold.
311-
function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues"): string {
312-
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.`;
314+
// numbers is the point: a deterministic, contributor-visible cap, not a silent quality-based hold. `scope`
315+
// (#2562) picks the cap description: "repository" (default, back-compat for every existing per-repo caller) vs.
316+
// "install" for the install-wide globalContributorOpenItemCap — same message shape, closeKind, and label either
317+
// way, just an accurate noun phrase for where the count was aggregated.
318+
function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues", scope?: "repository" | "install" | undefined): string {
319+
const scopeDescription = scope === "install" ? "this install's configured limit (across every repository it gates, combined)" : "this repository's configured limit";
320+
return `Gittensory closed this because @${authorLogin} has ${openCount} open ${itemNoun}, above ${scopeDescription} of ${cap}. Close or merge an existing one to open a new one. This is an automated maintenance action.`;
313321
}
314322

315323
// The close comment for review-nag cooldown (#2463). DOES interpolate authorLogin/pingCount/maxPings — none of
@@ -369,15 +377,15 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
369377
// independently of the caller (defense-in-depth, matching the blacklist block's own redundant check above).
370378
const capContributor = !input.authorIsOwner && !input.authorIsAdmin && !input.authorIsAutomationBot;
371379
if (input.contributorCapMatch?.matched === true && capContributor) {
372-
const { authorLogin, openCount, cap, itemKind } = input.contributorCapMatch;
380+
const { authorLogin, openCount, cap, itemKind, scope } = input.contributorCapMatch;
373381
const label = input.contributorCapLabel ?? DEFAULT_CONTRIBUTOR_CAP_LABEL;
374382
if (acting("label")) actions.push({ actionClass: "label", requiresApproval: approval("label"), reason: "over the per-contributor open-item cap", label, labelOp: "add" });
375383
if (acting("close")) {
376384
actions.push({
377385
actionClass: "close",
378386
requiresApproval: approval("close"),
379387
reason: "over the per-contributor open-item cap",
380-
closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind)),
388+
closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind, scope)),
381389
closeKind: "contributor_cap",
382390
});
383391
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Install-wide contributor open-item cap (#2562, anti-abuse): a self-hosted install that gates multiple repos
2+
// shares ONE database, but the per-repo contributorOpenPrCap/contributorOpenIssueCap (repository-settings.ts)
3+
// only ever counts open items on the SAME repo -- an actor spreading low-volume spam/farming PRs across several
4+
// gated repos in that install never trips any single repo's cap. This is cross-REPO-within-one-install only (no
5+
// federation, no cross-instance privacy design): a same-database aggregate against every repo this install
6+
// already tracks. Deliberately an env var (not a per-repo `.gittensory.yml`/DB field like the caps above) --
7+
// this setting aggregates ACROSS repos, so it cannot be "this repo's" setting; it belongs to the install as a
8+
// whole, mirroring how global_contributor_blacklist is a tenant-free singleton rather than a per-repo column.
9+
// Off by default (unset/invalid ⇒ null ⇒ no cap): zero behavior change for a single-repo install or one that
10+
// hasn't opted in.
11+
const GLOBAL_ENV_KEY = "GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP";
12+
13+
/** Parse+validate the install-wide open-item cap from env. Same non-clamping, non-rounding shape as the
14+
* per-repo caps' normalizeOpenItemCap (db/repositories.ts): a discrete count of open items, not a score, so a
15+
* fractional/non-positive/non-numeric value is a malformed cap and is dropped to `null` (no cap) rather than
16+
* coerced into a nonsensical threshold. Never throws. */
17+
export function resolveGlobalContributorOpenItemCap(env: { GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string | undefined }): number | null {
18+
const raw = env[GLOBAL_ENV_KEY];
19+
if (typeof raw !== "string" || raw.trim() === "") return null;
20+
const parsed = Number(raw);
21+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return null;
22+
return parsed;
23+
}

test/unit/agent-actions.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,23 @@ describe("per-contributor open-item cap short-circuit (#2270)", () => {
949949
expect(plan[1]?.closeComment).not.toContain("pull requests");
950950
});
951951

952+
it("scope 'install' (#2562) describes the cap as install-wide, not this-repository's — same closeKind/label shape", () => {
953+
const plan = planAgentMaintenanceActions(
954+
overCap({ contributorCapMatch: { matched: true, authorLogin: "farmer99", openCount: 5, cap: 4, itemKind: "pull requests", scope: "install" } }),
955+
);
956+
expect(plan[1]).toMatchObject({ actionClass: "close", closeKind: "contributor_cap" });
957+
expect(plan[1]?.closeComment).toContain("@farmer99");
958+
expect(plan[1]?.closeComment).toContain("5 open pull requests");
959+
expect(plan[1]?.closeComment).toContain("across every repository it gates, combined) of 4");
960+
expect(plan[1]?.closeComment).not.toContain("this repository's configured limit");
961+
});
962+
963+
it("scope 'repository' (default, absent) keeps the original this-repository close-comment wording — back-compat", () => {
964+
const plan = planAgentMaintenanceActions(overCap()); // overCap's base contributorCapMatch omits `scope`
965+
expect(plan[1]?.closeComment).toContain("this repository's configured limit");
966+
expect(plan[1]?.closeComment).not.toContain("across every repository it gates");
967+
});
968+
952969
it("uses the repo-configured contributorCapLabel, defaulting to 'over-contributor-limit' when unset", () => {
953970
expect(planAgentMaintenanceActions(overCap({ contributorCapLabel: "spam-cap" }))[0]).toMatchObject({ label: "spam-cap" });
954971
expect(DEFAULT_CONTRIBUTOR_CAP_LABEL).toBe("over-contributor-limit");

0 commit comments

Comments
 (0)