Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3104,6 +3104,28 @@ export async function countOpenPullRequests(env: Env, fullName: string): Promise
return Number(row?.count ?? 0);
}

/**
* Install-wide open-item count for one author (#2562, anti-abuse): SUM of this author's open PRs + open
* issues across EVERY repo tracked in this install's database -- deliberately NOT scoped by repoFullName,
* unlike countOpenPullRequests/countOpenIssues above. This is what makes the globalContributorOpenItemCap
* catch an actor spreading low-volume spam across several gated repos in the same self-hosted install: no
* single repo's own cap trips, but the aggregate does. Same-database aggregate only -- no cross-instance
* networking, mirroring the install-scoped singleton shape of global_contributor_blacklist. Case-insensitive
* login match (mirrors loginMatches/findBlacklistEntry elsewhere in this file).
*/
export async function countOpenItemsForAuthorAcrossRepos(env: Env, authorLogin: string): Promise<number> {
const db = getDb(env.DB);
const [[prRow], [issueRow]] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(pullRequests).where(and(eq(pullRequests.state, "open"), loginMatches(pullRequests.authorLogin, authorLogin))),
db.select({ count: sql<number>`count(*)` }).from(issues).where(and(eq(issues.state, "open"), loginMatches(issues.authorLogin, authorLogin))),
]);
/* v8 ignore next -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */
const prCount = Number(prRow?.count ?? 0);
/* v8 ignore next -- SQL aggregate count always returns one row; fallback protects D1 driver anomalies. */
const issueCount = Number(issueRow?.count ?? 0);
return prCount + issueCount;
}

// Anti-farming (#anti-gaming-flood): how many PRs this author has SUBMITTED to this repo since `sinceIso` (ANY
// state — open/merged/closed), so a flood that merges fast is still caught. createdAt is the row-insert time
// (≈ when gittensory first saw the PR), a good proxy for submission time on live webhook-driven PRs.
Expand Down
9 changes: 9 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ declare global {
onMerge?: import("./services/ai-review").OnMerge | undefined;
};
ADMIN_GITHUB_LOGINS?: string;
/** Install-wide contributor open-item cap (#2562, anti-abuse): the max PRs+issues a single non-owner/
* admin/bot contributor may have open ACROSS EVERY repo this install gates, combined. Purely an
* install-scoped aggregate over this same database (no cross-instance networking) -- catches an actor
* spreading low-volume spam/farming PRs across several gated repos in one self-hosted install, which no
* single repo's own contributorOpenPrCap/contributorOpenIssueCap can see. Unset/invalid (the default) = no
* cap, byte-identical to today. Checked IN ADDITION TO (not instead of) the existing per-repo caps, in the
* same contributor_cap short-circuit (src/settings/agent-actions.ts). A positive integer string (e.g. "20");
* see src/settings/global-contributor-cap.ts for parsing. */
GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string;
GITHUB_WEBHOOK_SECRET: string;
GITHUB_WEBHOOK_MAX_BODY_BYTES?: string;
/** Webhook secret for the central Gittensory Orb GitHub App (#1255) — distinct from the review app's
Expand Down
58 changes: 56 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
countOpenIssues,
countOpenItemsForAuthorAcrossRepos,
countOpenPullRequests,
getAgentCommandAnswer,
getInstallation,
Expand Down Expand Up @@ -245,6 +246,7 @@ import {
type PlannedAgentAction,
} from "../settings/agent-actions";
import { isAutoCloseExempt } from "../settings/auto-close-exempt";
import { resolveGlobalContributorOpenItemCap } from "../settings/global-contributor-cap";
import { detectMigrationCollisions, extractMigrationNumber, KNOWN_MIGRATION_DUPLICATES } from "../db/migration-collisions";
import { listMigrationFilenamesAtRef } from "../github/migration-tree";
import {
Expand Down Expand Up @@ -2027,7 +2029,7 @@ async function runAgentMaintenancePlanAndExecute(
// default) ⇒ this block is a no-op. A below-account-age-threshold author (#2561) gets a TIGHTER effective
// cap (half, rounded up, minimum 1) — visibility/friction, still never a close on account age by itself
// (the close, if any, is still tagged/reasoned as the ordinary contributor-cap close).
let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined;
let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues"; scope?: "repository" | "install" | undefined } | undefined;
const contributorOpenPrCap =
isNewAccount && typeof settings.contributorOpenPrCap === "number"
? Math.max(1, Math.ceil(settings.contributorOpenPrCap / 2))
Expand Down Expand Up @@ -2056,6 +2058,22 @@ async function runAgentMaintenancePlanAndExecute(
}
}

// Install-wide contributor open-item cap (#2562, anti-abuse): IN ADDITION TO the per-repo cap above, not
// instead of it -- only evaluated when the per-repo cap didn't already match (short-circuit: no need for a
// second cross-repo DB read once this PR is already being closed). Off by default (resolveGlobalContributorOpenItemCap
// returns null when GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP is unset/invalid) ⇒ zero extra queries, zero behavior
// change for an install that hasn't opted in. Reuses the shared autoCloseExemptLogins list (#2463) so a
// maintainer-named login is exempt here exactly like the per-repo caps and review-nag cooldown.
if (contributorCapMatch === undefined && pr.authorLogin && !isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) {
const globalCap = resolveGlobalContributorOpenItemCap(env);
if (globalCap !== null) {
const installOpenCount = await countOpenItemsForAuthorAcrossRepos(env, pr.authorLogin);
if (installOpenCount > globalCap) {
contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: installOpenCount, cap: globalCap, itemKind: "pull requests", scope: "install" };
}
}
}

const planned = planAgentMaintenanceActions({
conclusion: gate.conclusion,
blockerTitles: gate.blockers.map((blocker) => blocker.title),
Expand Down Expand Up @@ -3924,14 +3942,50 @@ async function maybeCloseIssueOverContributorCap(
const { installationId, repoFullName, issue, settings } = args;
const cap = settings.contributorOpenIssueCap;
const authorLogin = issue.authorLogin;
if (typeof cap !== "number" || !authorLogin) return;
// Install-wide cap (#2562) is checked IN ADDITION TO the per-repo cap, so this function must still run when
// ONLY the global cap is configured (the per-repo cap stays optional/off, its usual default).
const globalCap = resolveGlobalContributorOpenItemCap(env);
if ((typeof cap !== "number" && globalCap === null) || !authorLogin) return;

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

// Install-wide check first (#2562): reuses the shared autoCloseExemptLogins list, same as the PR path. A
// match here closes THIS issue directly (unlike the per-repo cap below, there is no cross-repo sibling set to
// union/live-verify -- the aggregate count already covers every repo, so a single over-cap read is enough).
if (globalCap !== null && !isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins)) {
const installOpenCount = await countOpenItemsForAuthorAcrossRepos(env, authorLogin);
if (installOpenCount > globalCap) {
const planned = planAgentMaintenanceActions({
conclusion: "skipped",
blockerTitles: [],
autonomy: settings.autonomy,
changedPaths: [],
hardGuardrailGlobs: [],
authorIsOwner,
authorIsAdmin,
authorIsAutomationBot,
ciState: "unverified",
contributorCapMatch: { matched: true, authorLogin, openCount: installOpenCount, cap: globalCap, itemKind: "issues", scope: "install" },
contributorCapLabel: settings.contributorCapLabel,
pr: { labels: [] },
});
if (planned.length > 0) {
await executeIssueMaintenanceActions(
env,
{ installationId, repoFullName, issueNumber: issue.number, autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun },
planned,
);
}
return;
}
}

if (typeof cap !== "number") return;

const otherOpenIssues = await listOpenIssues(env, repoFullName);
const authorLoginLower = authorLogin.toLowerCase();
const otherAuthorIssueNumbers = otherOpenIssues
Expand Down
20 changes: 14 additions & 6 deletions src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ export type AgentActionPlanInput = {
// so — unlike the blacklist's private-reason close — they ARE interpolated into the public close comment.
// `itemKind` selects the close-comment noun ("pull requests" for the PR-path caller, "issues" for the
// issue-path caller, #2270) — REQUIRED (not defaulted) so a caller can't silently mislabel the other kind.
contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined;
// `scope` (#2562) selects the close-comment's cap description: "repository" (default when absent, back-compat
// for every existing per-repo caller) says "this repository's configured limit"; "install" says "across every
// repository this install gates, combined" for the install-wide globalContributorOpenItemCap. Same closeKind
// ("contributor_cap") and label either way — this is a description-only distinction, not a new disposition.
contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues"; scope?: "repository" | "install" | undefined } | undefined;
// The repo-configured label applied to an over-cap author's PR/issue (#2270), resolved from `.gittensory.yml`.
// Absent ⇒ the default (`DEFAULT_CONTRIBUTOR_CAP_LABEL` = "over-contributor-limit").
contributorCapLabel?: string | undefined;
Expand Down Expand Up @@ -307,9 +311,13 @@ function blacklistCloseMessage(): string {
// The close comment for exceeding the per-contributor open-item cap (#2270). Unlike blacklistCloseMessage, this
// DOES interpolate authorLogin/openCount/cap — none of that is private (the author's own login and their own
// open-item count on a public repo are already public/derivable from GitHub itself), and stating the exact
// numbers is the point: a deterministic, contributor-visible cap, not a silent quality-based hold.
function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues"): string {
return `Gittensory closed this because @${authorLogin} has ${openCount} open ${itemNoun}, above this repository's configured limit of ${cap}. Close or merge an existing one to open a new one. This is an automated maintenance action.`;
// numbers is the point: a deterministic, contributor-visible cap, not a silent quality-based hold. `scope`
// (#2562) picks the cap description: "repository" (default, back-compat for every existing per-repo caller) vs.
// "install" for the install-wide globalContributorOpenItemCap — same message shape, closeKind, and label either
// way, just an accurate noun phrase for where the count was aggregated.
function contributorCapCloseMessage(authorLogin: string, openCount: number, cap: number, itemNoun: "pull requests" | "issues", scope?: "repository" | "install" | undefined): string {
const scopeDescription = scope === "install" ? "this install's configured limit (across every repository it gates, combined)" : "this repository's configured limit";
return `Gittensory closed this because @${authorLogin} has ${openCount} open ${itemNoun}, above ${scopeDescription} of ${cap}. Close or merge an existing one to open a new one. This is an automated maintenance action.`;
}

// The close comment for review-nag cooldown (#2463). DOES interpolate authorLogin/pingCount/maxPings — none of
Expand Down Expand Up @@ -369,15 +377,15 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// independently of the caller (defense-in-depth, matching the blacklist block's own redundant check above).
const capContributor = !input.authorIsOwner && !input.authorIsAdmin && !input.authorIsAutomationBot;
if (input.contributorCapMatch?.matched === true && capContributor) {
const { authorLogin, openCount, cap, itemKind } = input.contributorCapMatch;
const { authorLogin, openCount, cap, itemKind, scope } = input.contributorCapMatch;
const label = input.contributorCapLabel ?? DEFAULT_CONTRIBUTOR_CAP_LABEL;
if (acting("label")) actions.push({ actionClass: "label", requiresApproval: approval("label"), reason: "over the per-contributor open-item cap", label, labelOp: "add" });
if (acting("close")) {
actions.push({
actionClass: "close",
requiresApproval: approval("close"),
reason: "over the per-contributor open-item cap",
closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind)),
closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind, scope)),
closeKind: "contributor_cap",
});
}
Expand Down
23 changes: 23 additions & 0 deletions src/settings/global-contributor-cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Install-wide contributor open-item cap (#2562, anti-abuse): a self-hosted install that gates multiple repos
// shares ONE database, but the per-repo contributorOpenPrCap/contributorOpenIssueCap (repository-settings.ts)
// only ever counts open items on the SAME repo -- an actor spreading low-volume spam/farming PRs across several
// gated repos in that install never trips any single repo's cap. This is cross-REPO-within-one-install only (no
// federation, no cross-instance privacy design): a same-database aggregate against every repo this install
// already tracks. Deliberately an env var (not a per-repo `.gittensory.yml`/DB field like the caps above) --
// this setting aggregates ACROSS repos, so it cannot be "this repo's" setting; it belongs to the install as a
// whole, mirroring how global_contributor_blacklist is a tenant-free singleton rather than a per-repo column.
// Off by default (unset/invalid ⇒ null ⇒ no cap): zero behavior change for a single-repo install or one that
// hasn't opted in.
const GLOBAL_ENV_KEY = "GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP";

/** Parse+validate the install-wide open-item cap from env. Same non-clamping, non-rounding shape as the
* per-repo caps' normalizeOpenItemCap (db/repositories.ts): a discrete count of open items, not a score, so a
* fractional/non-positive/non-numeric value is a malformed cap and is dropped to `null` (no cap) rather than
* coerced into a nonsensical threshold. Never throws. */
export function resolveGlobalContributorOpenItemCap(env: { GLOBAL_CONTRIBUTOR_OPEN_ITEM_CAP?: string | undefined }): number | null {
const raw = env[GLOBAL_ENV_KEY];
if (typeof raw !== "string" || raw.trim() === "") return null;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return null;
return parsed;
}
17 changes: 17 additions & 0 deletions test/unit/agent-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,23 @@ describe("per-contributor open-item cap short-circuit (#2270)", () => {
expect(plan[1]?.closeComment).not.toContain("pull requests");
});

it("scope 'install' (#2562) describes the cap as install-wide, not this-repository's — same closeKind/label shape", () => {
const plan = planAgentMaintenanceActions(
overCap({ contributorCapMatch: { matched: true, authorLogin: "farmer99", openCount: 5, cap: 4, itemKind: "pull requests", scope: "install" } }),
);
expect(plan[1]).toMatchObject({ actionClass: "close", closeKind: "contributor_cap" });
expect(plan[1]?.closeComment).toContain("@farmer99");
expect(plan[1]?.closeComment).toContain("5 open pull requests");
expect(plan[1]?.closeComment).toContain("across every repository it gates, combined) of 4");
expect(plan[1]?.closeComment).not.toContain("this repository's configured limit");
});

it("scope 'repository' (default, absent) keeps the original this-repository close-comment wording — back-compat", () => {
const plan = planAgentMaintenanceActions(overCap()); // overCap's base contributorCapMatch omits `scope`
expect(plan[1]?.closeComment).toContain("this repository's configured limit");
expect(plan[1]?.closeComment).not.toContain("across every repository it gates");
});

it("uses the repo-configured contributorCapLabel, defaulting to 'over-contributor-limit' when unset", () => {
expect(planAgentMaintenanceActions(overCap({ contributorCapLabel: "spam-cap" }))[0]).toMatchObject({ label: "spam-cap" });
expect(DEFAULT_CONTRIBUTOR_CAP_LABEL).toBe("over-contributor-limit");
Expand Down
Loading
Loading