|
1 | 1 | import { |
2 | 2 | countOpenIssues, |
| 3 | + countOpenItemsForAuthorAcrossRepos, |
3 | 4 | countOpenPullRequests, |
4 | 5 | getAgentCommandAnswer, |
5 | 6 | getInstallation, |
@@ -245,6 +246,7 @@ import { |
245 | 246 | type PlannedAgentAction, |
246 | 247 | } from "../settings/agent-actions"; |
247 | 248 | import { isAutoCloseExempt } from "../settings/auto-close-exempt"; |
| 249 | +import { resolveGlobalContributorOpenItemCap } from "../settings/global-contributor-cap"; |
248 | 250 | import { detectMigrationCollisions, extractMigrationNumber, KNOWN_MIGRATION_DUPLICATES } from "../db/migration-collisions"; |
249 | 251 | import { listMigrationFilenamesAtRef } from "../github/migration-tree"; |
250 | 252 | import { |
@@ -2034,7 +2036,7 @@ async function runAgentMaintenancePlanAndExecute( |
2034 | 2036 | // default) ⇒ this block is a no-op. A below-account-age-threshold author (#2561) gets a TIGHTER effective |
2035 | 2037 | // cap (half, rounded up, minimum 1) — visibility/friction, still never a close on account age by itself |
2036 | 2038 | // (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; |
2038 | 2040 | const contributorOpenPrCap = |
2039 | 2041 | isNewAccount && typeof settings.contributorOpenPrCap === "number" |
2040 | 2042 | ? Math.max(1, Math.ceil(settings.contributorOpenPrCap / 2)) |
@@ -2063,6 +2065,22 @@ async function runAgentMaintenancePlanAndExecute( |
2063 | 2065 | } |
2064 | 2066 | } |
2065 | 2067 |
|
| 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 | + |
2066 | 2084 | const planned = planAgentMaintenanceActions({ |
2067 | 2085 | conclusion: gate.conclusion, |
2068 | 2086 | blockerTitles: gate.blockers.map((blocker) => blocker.title), |
@@ -3948,14 +3966,50 @@ async function maybeCloseIssueOverContributorCap( |
3948 | 3966 | const { installationId, repoFullName, issue, settings } = args; |
3949 | 3967 | const cap = settings.contributorOpenIssueCap; |
3950 | 3968 | 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; |
3952 | 3973 |
|
3953 | 3974 | const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : ""; |
3954 | 3975 | const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); |
3955 | 3976 | const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase()); |
3956 | 3977 | const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); |
3957 | 3978 | if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return; |
3958 | 3979 |
|
| 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 | + |
3959 | 4013 | const otherOpenIssues = await listOpenIssues(env, repoFullName); |
3960 | 4014 | const authorLoginLower = authorLogin.toLowerCase(); |
3961 | 4015 | const otherAuthorIssueNumbers = otherOpenIssues |
|
0 commit comments