diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 9527d8c888..55f6c8bb1f 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -263,10 +263,14 @@ settings: # gate-override: [maintainer, collaborator] # Per-contributor open-PR/open-issue caps (#2270, anti-abuse): the max PRs/issues a single - # non-owner/non-admin/non-bot contributor may have open on this repo at once. These fields are - # currently INERT (stored and parsed, not yet enforced) — enforcement (closing the newest item(s) - # above the cap with a clear reason) lands in a follow-up PR; see #2270 for status. Positive whole - # number, or omit/null for no cap. Default: null (disabled) for both. Set explicitly to `null` (not - # just omitted) to force-clear a cap that a dashboard/DB write previously set. + # non-owner/non-admin/non-bot contributor may have open on this repo at once. Uncomment and set a + # number to opt in — a contributor's newest item above the cap is closed with a clear reason on the + # PR-opened/issue-opened webhook; their oldest items up to the cap stay open and reviewable. + # Both contributorOpenPrCap and contributorOpenIssueCap ARE ENFORCED. Positive whole number, or + # omit/null for no cap. Default: null (disabled) for both. Set explicitly to `null` (not just omitted) + # to force-clear a cap that a dashboard/DB write previously set. # contributorOpenPrCap: 2 # contributorOpenIssueCap: 5 + + # Label applied to a PR/issue closed for exceeding a cap above. String. Default: over-contributor-limit. + # contributorCapLabel: over-contributor-limit diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index b519efcee4..ac5f5c430b 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8601,6 +8601,9 @@ "nullable": true, "minimum": 0, "exclusiveMinimum": true + }, + "contributorCapLabel": { + "type": "string" } }, "required": [ diff --git a/migrations/0090_contributor_cap_label.sql b/migrations/0090_contributor_cap_label.sql new file mode 100644 index 0000000000..e778c9bd15 --- /dev/null +++ b/migrations/0090_contributor_cap_label.sql @@ -0,0 +1,4 @@ +-- Label applied to a PR/issue closed for exceeding a per-contributor open-item cap (#2270, anti-abuse). +-- Configurable so the disposition works regardless of the label a repo sets; defaults to "over-contributor-limit" +-- so existing rows are byte-identical to the enforcement's built-in fallback. +ALTER TABLE repository_settings ADD COLUMN contributor_cap_label TEXT NOT NULL DEFAULT 'over-contributor-limit'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index a53dc2b73a..fd33977a1b 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -503,6 +503,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY }, contributorOpenPrCap: null, contributorOpenIssueCap: null, + contributorCapLabel: "over-contributor-limit", }; } return { @@ -549,6 +550,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson), contributorOpenPrCap: normalizeOpenItemCap(row.contributorOpenPrCap), contributorOpenIssueCap: normalizeOpenItemCap(row.contributorOpenIssueCap), + contributorCapLabel: row.contributorCapLabel, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -627,6 +629,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 668f38148f..288205d35b 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -2590,6 +2590,23 @@ export async function fetchLivePullRequestState( return result?.data.state ?? undefined; } +/** The issue's LIVE state ("open" / "closed") via REST `GET /issues/{n}`. Mirrors {@link fetchLivePullRequestState} + * for issues: the stored open-issue cache lags GitHub, so a sibling closed on GitHub (or elsewhere) can still + * read `open` locally. The per-contributor open-issue cap (#2479 gate finding) confirms each counted sibling's + * live state before treating a newly opened issue as over cap, so a stale row never inflates the count and + * wrongly closes an issue that is within the real cap. Best-effort: returns undefined on any error so the + * caller fails open to the stored state (same fail-open contract as the PR-side helper). */ +export async function fetchLiveIssueState( + env: Env, + repoFullName: string, + issueNumber: number, + token: string | undefined, + admissionKey?: GitHubRateLimitAdmissionKey, +): Promise { + const result = await githubJsonWithHeaders<{ state?: string | null }>(env, repoFullName, `/issues/${issueNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined); + return result?.data.state ?? undefined; +} + /** The PR's LIVE head commit SHA via REST `GET /pulls/{n}`. The stored `pr.headSha` lags GitHub when a commit * lands between a webhook and its processing; the gate-override command (#16 / audit) re-fetches the live head * so the neutral check-run targets the commit a maintainer is actually looking at, not a phantom old SHA. diff --git a/src/github/pr-actions.ts b/src/github/pr-actions.ts index 1d88e1d3ab..1411552d2f 100644 --- a/src/github/pr-actions.ts +++ b/src/github/pr-actions.ts @@ -203,6 +203,23 @@ export async function closePullRequest(env: Env, installationId: number, repoFul }); } +/** Close a plain issue (sets state=closed). #2270's first issue-side actuation: unlike closePullRequest, this + * hits the generic Issues API (`PATCH /issues/{issue_number}`), not the Pulls API — a plain issue number is not + * a valid `pull_number`, so closePullRequest cannot be reused here. */ +export async function closeIssue(env: Env, installationId: number, repoFullName: string, issueNumber: number): Promise<{ state: string }> { + const { owner, repo } = splitRepo(repoFullName); + return withInstallationTokenRetry(env, installationId, async (token) => { + const octokit = makeInstallationOctokit(env, token, "live", githubRateLimitAdmissionKeyForInstallation(installationId)); + const response = await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", { + owner, + repo, + issue_number: issueNumber, + state: "closed", + }); + return { state: (response.data as { state: string }).state }; + }); +} + /** The last-closer lookup result. `coveredAllPages` is false when the bounded newest-events window did NOT reach * back to page 1 (a very long timeline), so a `login: null` may mean "no close found" OR "a close exists beyond * the inspected window". The reopen guard uses this to fail CLOSED rather than allow a window-evasion bypass. diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index ebb45414eb..880a75af31 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -632,6 +632,7 @@ export const RepositorySettingsSchema = z agentDryRun: z.boolean().optional(), contributorOpenPrCap: z.number().int().positive().nullable().optional(), contributorOpenIssueCap: z.number().int().positive().nullable().optional(), + contributorCapLabel: z.string().optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 00c0eb4d5d..094b0f4bf2 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -26,6 +26,7 @@ import { listSignalSnapshots, listRepoGithubTotalsSnapshotHistory, listOtherOpenPullRequests, + listOpenIssues, listOpenPullRequests, listPullRequests, listPullRequestFiles, @@ -78,6 +79,7 @@ import { fetchLinkedIssueFacts, fetchLiveCiAggregatePreferGraphQl, type LiveCiAggregate, + fetchLiveIssueState, fetchLivePullRequest, fetchLivePullRequestHeadSha, fetchLivePullRequestMergeState, @@ -232,6 +234,7 @@ import { } from "../settings/agent-actions"; import { executeAgentMaintenanceActions, + executeIssueMaintenanceActions, pendingClosureLabelApplied, } from "../services/agent-action-executor"; import { processSubmitDraft } from "../services/draft"; @@ -1738,7 +1741,7 @@ async function runAgentMaintenancePlanAndExecute( liveFacts: LiveGithubFacts; }, ): Promise { - const { installationId, repoFullName, pr, settings, otherOpenPullRequests, gate } = args; + const { installationId, repoFullName, pr, settings, otherOpenPullRequests, deliveryId, gate } = args; // Convergence safety: feed the planner the PR's changed paths + the repo's hard-guardrail globs so guarded // paths force manual review, and flag owner-authored PRs so they are never auto-closed (standing rule). @@ -1861,6 +1864,40 @@ async function runAgentMaintenancePlanAndExecute( settings.contributorBlacklist, ); + // Per-contributor open-PR cap (#2270, anti-abuse): count this author's OTHER currently-open PRs on this repo + // (otherOpenPullRequests already excludes the current PR — see reconcileLiveDuplicateSiblings) plus this one, + // ranked by PR NUMBER (GitHub's own creation order, not webhook-arrival order) so a burst of near-simultaneous + // opens still ranks deterministically. Only matches when THIS PR's number is among the ones over the cap — an + // older sibling that was already under the cap when it was opened stays open. The owner/admin/automation-bot + // exemption is applied by the planner itself (defense-in-depth, mirroring how blacklistEntry above is resolved + // unconditionally and exempted only inside planAgentMaintenanceActions). Disabled (null/undefined cap, the + // default) ⇒ this block is a no-op. + let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined; + const contributorOpenPrCap = settings.contributorOpenPrCap; + if (typeof contributorOpenPrCap === "number" && pr.authorLogin) { + const authorLoginLower = pr.authorLogin.toLowerCase(); + const authorOpenPrNumbers = otherOpenPullRequests + .filter((other) => (other.authorLogin ?? "").toLowerCase() === authorLoginLower) + .map((other) => other.number) + .concat(pr.number) + .sort((a, b) => a - b); + const overCapNumbers = new Set(authorOpenPrNumbers.slice(contributorOpenPrCap)); + if (overCapNumbers.has(pr.number)) { + contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: authorOpenPrNumbers.length, cap: contributorOpenPrCap, itemKind: "pull requests" }; + } + // Webhook-delivery-order guard (#2479 gate finding): delivery order is not guaranteed to match PR creation + // order, so a sibling PR's own webhook can process before THIS PR exists in the DB and wrongly conclude the + // author is within the cap — nothing else would ever re-evaluate it, permanently bypassing the cap for that + // sibling. Now that THIS delivery has the complete picture, wake any OTHER still-open sibling that's also + // in the over-cap set so its own next pass re-evaluates against the complete set and self-corrects. + const otherOverCapSiblingNumbers = otherOpenPullRequests + .filter((other) => (other.authorLogin ?? "").toLowerCase() === authorLoginLower && overCapNumbers.has(other.number)) + .map((other) => other.number); + if (otherOverCapSiblingNumbers.length > 0) { + await wakeOverCapSiblingPullRequests(env, deliveryId, installationId, repoFullName, otherOverCapSiblingNumbers); + } + } + const planned = planAgentMaintenanceActions({ conclusion: gate.conclusion, blockerTitles: gate.blockers.map((blocker) => blocker.title), @@ -1885,6 +1922,10 @@ async function runAgentMaintenancePlanAndExecute( : {}), // Always threaded (the DB layer populates it, default "slop"); the planner applies its own fallback. blacklistLabel: settings.blacklistLabel, + ...(contributorCapMatch !== undefined ? { contributorCapMatch } : {}), + // Always threaded (the DB layer populates it, default "over-contributor-limit"); the planner applies its + // own fallback. + contributorCapLabel: settings.contributorCapLabel, ...(linkedIssueHardRule !== undefined ? { linkedIssueHardRule } : {}), // Flag-then-close double-check: thread the loaded verify config so the planner FLAGS first then closes on // re-verification (default ON). Only passed when a rule is on (the planner reads it only for a violation). @@ -2506,6 +2547,51 @@ async function scheduleTrailingIssueLinkedReReview( await putTransientKey(env, key, "1", CI_COALESCE_WINDOW_SECONDS); } +/** Best-effort wake for sibling PRs discovered to be over the per-contributor cap by a LATER delivery (#2270, + * #2479 gate finding): webhook delivery order isn't guaranteed to match PR creation order, so a sibling's own + * webhook can fire before this one exists in the DB and wrongly conclude the author is within the cap — with + * nothing else to ever re-evaluate it, that verdict would otherwise stand forever. Reuses the existing + * agent-regate-pr sweep-unit job (already rate-limit-aware and retried) — the SAME "wake and fully + * re-evaluate" entry point the linked-issue-wake feature (#2259) uses for an identical class of problem, so + * the sibling gets its own live-head/CI-freshness re-check before anything acts on it, not a shortcut based + * on this delivery's now-possibly-stale snapshot. Coalesced per sibling PR (mirrors + * scheduleTrailingIssueLinkedReReview's check-then-claim-after-success shape) so a burst of N over-cap + * siblings each discovering the same others doesn't fan out into an O(N^2) job storm. */ +async function wakeOverCapSiblingPullRequests( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + siblingPrNumbers: number[], +): Promise { + await Promise.all( + siblingPrNumbers.map(async (prNumber) => { + const key = `contributor-cap-wake:${repoFullName.toLowerCase()}#${prNumber}`; + if (await getTransientKey(env, key)) return; + try { + await env.JOBS.send({ + type: "agent-regate-pr", + deliveryId, + repoFullName, + prNumber, + installationId, + }); + } catch (error) { + console.log( + JSON.stringify({ + ev: "contributor_cap_wake_enqueue_failed", + repoFullName, + pull: prNumber, + message: errorMessage(error).slice(0, 120), + }), + ); + return; // do NOT claim — a later discovery should retry the enqueue + } + await putTransientKey(env, key, "1", CI_COALESCE_WINDOW_SECONDS); + }), + ); +} + async function ciHeadShaResolutionCoalesced( env: Env, repoFullName: string, @@ -3490,6 +3576,112 @@ async function loadOpenQueueCounts( }; } +/** + * 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: + * counts the author's currently-open issues on this repo (including this one), ranked by issue NUMBER + * (GitHub's own creation order, not webhook-arrival order). Reuses planAgentMaintenanceActions to build the + * SAME label+close plan the PR path uses (identical closeKind/label/close-comment construction): passing + * `conclusion: "skipped"` and no `blacklistMatch` guarantees the function returns at the contributor_cap + * short-circuit (the very next check in the planner) before ever touching any PR/CI-specific field, so + * building a plan for an issue this way is safe. + * + * Webhook-delivery-order guard (#2479 gate finding, mirrored here for issues): delivery order is not + * guaranteed to match issue creation order, so an OLDER sibling's own webhook can process before a NEWER + * sibling exists in the DB and wrongly conclude the author is within the cap — closing only "the incoming + * issue, if it's over cap" would let that stale verdict stand forever, since nothing else ever re-evaluates + * it. Closes EVERY number in the over-cap set discovered by THIS delivery, not just the incoming issue, so + * whichever delivery happens to see the complete picture corrects any sibling a prior delivery missed. Unlike + * the PR path (which enqueues a `agent-regate-pr` wake job so each sibling gets its own live-head/CI-freshness + * re-check before acting), issues have no head SHA or CI to go stale, and there's no issue-side "regate" job + * type to reuse — so that PARTICULAR staleness risk does not apply here. + * + * Stale-closed-sibling guard (#2479 gate finding): a DIFFERENT staleness risk DOES apply — `listOpenIssues` + * reads the local DB cache, which can still say `open` for a sibling already closed on GitHub (manually, by + * another automation, or by a webhook this instance hasn't processed yet). An inflated count from such a stale + * row could wrongly put a newly opened issue over the REAL cap. Guarded by live-verifying each counted sibling + * below before trusting it -- and unlike a non-final ranking signal, an inconclusive live check here is treated + * as NOT open (excluded from the count), never left as an unverified "counts toward the cap" default, because + * this count gates an irreversible close (#2479 gate finding, second pass). + */ +async function maybeCloseIssueOverContributorCap( + env: Env, + args: { installationId: number; repoFullName: string; issue: IssueRecord; settings: RepositorySettings }, +): Promise { + const { installationId, repoFullName, issue, settings } = args; + const cap = settings.contributorOpenIssueCap; + const authorLogin = issue.authorLogin; + if (typeof cap !== "number" || !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; + + 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; + + const planned = planAgentMaintenanceActions({ + conclusion: "skipped", + blockerTitles: [], + autonomy: settings.autonomy, + changedPaths: [], + hardGuardrailGlobs: [], + authorIsOwner, + authorIsAdmin, + authorIsAutomationBot, + ciState: "unverified", + contributorCapMatch: { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap, itemKind: "issues" }, + contributorCapLabel: settings.contributorCapLabel, + pr: { labels: [] }, + }); + if (planned.length === 0) return; + + for (const overCapNumber of overCapNumbers) { + await executeIssueMaintenanceActions( + env, + { installationId, repoFullName, issueNumber: overCapNumber, autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }, + planned, + ); + } +} + async function processGitHubWebhook( env: Env, deliveryId: string, @@ -4066,6 +4258,28 @@ async function processGitHubWebhook( ); } await persistAdvisory(env, advisory); + // Per-contributor open-issue cap (#2270, anti-abuse): the first issue-side auto-close path. Best-effort — + // a failure here must never affect the advisory/notification handling above or the webhook overall. + if (payload.action === "opened" && installationId) { + await maybeCloseIssueOverContributorCap(env, { + installationId, + repoFullName: payload.repository.full_name, + issue, + settings: issueSettings, + }).catch((error) => { + /* v8 ignore next -- best-effort: an issue-cap enforcement failure is logged, never surfaced to the webhook. */ + console.error( + JSON.stringify({ + level: "warn", + event: "contributor_issue_cap_failed", + deliveryId, + repository: payload.repository?.full_name, + issueNumber: issue.number, + error: errorMessage(error), + }), + ); + }); + } // #699 path B: a newly opened grabbable, high-multiplier issue notifies the miners watching this repo // (fanned out through the same #535 pipeline below). if (payload.action === "opened") diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts index 322f7b9242..50dc84f2a5 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -5,7 +5,7 @@ import { createInstallationToken, githubErrorStatus, isGitHubRateLimitedError } import { fetchLiveCiAggregate, refreshInstallationHealthForInstallation } from "../github/backfill"; import { githubRateLimitAdmissionKeyForToken } from "../github/client"; import { ensurePullRequestLabel, removePullRequestLabel } from "../github/labels"; -import { closePullRequest, createIssueComment, createPullRequestReview, dismissLatestBotApproval, mergePullRequest, updatePullRequestBranch } from "../github/pr-actions"; +import { closeIssue, closePullRequest, createIssueComment, createPullRequestReview, dismissLatestBotApproval, mergePullRequest, updatePullRequestBranch } from "../github/pr-actions"; import { fetchPullRequestFreshness, pullRequestFreshnessDetail } from "../github/pr-freshness"; import { isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; import { buildAgentActionAudit, isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; @@ -215,6 +215,83 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE return outcomes; } +export type IssueActionExecutionContext = { + installationId: number; + repoFullName: string; + issueNumber: number; + autonomy: AutonomyPolicy | null | undefined; + agentPaused?: boolean | undefined; + agentDryRun?: boolean | undefined; +}; + +/** + * Execute (or dry-run) a planned label/close action set on an ISSUE — #2270's first issue-side actuation + * (`planAgentMaintenanceActions`'s `contributor_cap` short-circuit is currently the only source of an + * issue-targeted plan). Deliberately NARROWER than {@link executeAgentMaintenanceActions}: + * - Only `label` (add) and `close` are handled — the only classes the contributor_cap short-circuit ever + * produces. Any other class is denied defensively rather than mis-executed against an issue. + * - No freshness/live-CI-re-verification/pull_requests:write gate: none of those PR concepts apply to a + * plain issue (no head SHA, no CI, and a close needs `issues: write`, a different permission than the PR + * executor's write-readiness check covers). + * - `requiresApproval` (`auto_with_approval`) is DENIED, not staged: the pending-action queue is PR-shaped + * (pullNumber-typed staging + a `/pull/{n}` notification deeplink); extending it to issues is out of scope + * here. Denying — rather than silently executing or silently skipping the approval gate — keeps the + * configured autonomy honest: an operator who set `auto_with_approval` never gets an un-approved action. + */ +export async function executeIssueMaintenanceActions(env: Env, ctx: IssueActionExecutionContext, planned: PlannedAgentAction[]): Promise { + const outcomes: AgentActionOutcome[] = []; + const targetKey = `${ctx.repoFullName}#${ctx.issueNumber}`; + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); + + for (const action of planned) { + const autonomyLevel = resolveAutonomy(ctx.autonomy, action.actionClass); + const audit = (outcome: AgentActionOutcome["outcome"], detail: string) => { + const auditOutcome = outcome === "dry_run" ? "completed" : outcome; + outcomes.push({ actionClass: action.actionClass, outcome, detail }); + return recordAuditEvent( + env, + buildAgentActionAudit({ actionClass: action.actionClass, autonomyLevel, mode, outcome: auditOutcome, repoFullName: ctx.repoFullName, targetKey, actor: AGENT_ACTOR, reason: detail }), + ); + }; + + if (mode === "paused") { + await audit("denied", "agent actions paused"); + continue; + } + if (!isActingAutonomyLevel(autonomyLevel)) { + await audit("denied", `autonomy for ${action.actionClass} is ${autonomyLevel} — action not currently enabled`); + continue; + } + if (mode === "dry_run") { + await audit("dry_run", `dry-run: would ${action.actionClass} — ${action.reason}`); + continue; + } + if (action.requiresApproval) { + await audit("denied", `awaiting maintainer approval — issue-side staging is not yet supported (${action.reason})`); + continue; + } + if (action.actionClass !== "label" && action.actionClass !== "close") { + /* v8 ignore next -- defensive: planAgentMaintenanceActions's contributor_cap short-circuit (this + * executor's only caller today) never produces any class besides label/close. */ + await audit("denied", `unsupported action class for an issue: ${action.actionClass}`); + continue; + } + try { + if (action.actionClass === "label") { + await ensurePullRequestLabel(env, ctx.installationId, ctx.repoFullName, ctx.issueNumber, action.label ?? "", { createMissingLabel: true }); + } else { + if (action.closeComment) await createIssueComment(env, ctx.installationId, ctx.repoFullName, ctx.issueNumber, action.closeComment); + await closeIssue(env, ctx.installationId, ctx.repoFullName, ctx.issueNumber); + } + await audit("completed", action.reason); + } catch (error) { + await audit("error", errorMessage(error)); + } + } + + return outcomes; +} + // RC3: persist the outcome of a FAILED merge so it is never retried blindly forever. A non-transient failure // (403/405 perms, 409 required-check-absent, merge conflict) is terminal immediately; an otherwise-unclassified // failure (e.g. base moved during the merge — a benign TOCTOU race) is retried up to MERGE_RETRY_CAP and then diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 01cefff08d..41ad099963 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -23,6 +23,10 @@ export const AGENT_LABEL_CHANGES = "gittensory:changes-requested"; // configurable per-repo via `.gittensory.yml` (`settings.blacklistLabel`); the planner uses the resolved label // and falls back to this default, so the disposition works regardless of the label a repo sets. export const DEFAULT_BLACKLIST_LABEL = "slop"; +// Default label applied to a PR/issue closed for exceeding the per-contributor open-item cap (#2270). Same +// configurable-with-fallback shape as DEFAULT_BLACKLIST_LABEL — a repo can override it via +// `.gittensory.yml` (`settings.contributorCapLabel`); this is only the fallback when unset. +export const DEFAULT_CONTRIBUTOR_CAP_LABEL = "over-contributor-limit"; // A PR that PASSES the gate but touches a hard-guardrail path is NOT ready to auto-merge — it is withheld // for a human (the merge/approve/close dispositions are suppressed below). Labeling it `ready-to-merge` // would be misleading (the label promises an auto-merge that never happens), so a guarded passing PR gets @@ -61,7 +65,7 @@ export type PlannedAgentAction = { // duplicate / slop / CI). The breaker downgrades ONLY "heuristic" closes; the deterministic close is EXEMPT // (silently holding a close whose comment already promised closure would be incoherent). Absent on non-close // actions; treated as a heuristic close only when explicitly tagged "heuristic". - closeKind?: "linked-issue-hard-rule" | "blacklist" | "heuristic"; + closeKind?: "linked-issue-hard-rule" | "blacklist" | "contributor_cap" | "heuristic"; // For a CI-driven heuristic close, the CI state that must still hold at actuation time. Other heuristic // closes (gate verdict, duplicate/slop, conflict) do not depend on red CI and must not be blocked by green CI. // ALWAYS set for a heuristic close (never omitted) -- see the field's doc comment on AgentPendingActionParams @@ -145,6 +149,19 @@ export type AgentActionPlanInput = { // The repo-configured label applied to a blacklisted author's PR (#1425), resolved from `.gittensory.yml`. // Absent ⇒ the default (`DEFAULT_BLACKLIST_LABEL` = "slop"), so the disposition works regardless of the label set. blacklistLabel?: string | undefined; + // Per-contributor open-PR/open-issue cap (#2270, anti-abuse): when the incoming PR pushes its author over the + // repo's configured `contributorOpenPrCap`, the disposition SHORT-CIRCUITS to a deterministic label + close + // ahead of ALL merit/CI/AI analysis — same zero-hallucination shape as blacklistMatch, so its close is tagged + // `closeKind: "contributor_cap"` (immune to the close-precision breaker like blacklist/linked-issue-hard-rule). + // Fires for a CONTRIBUTOR only (owner/admin/automation bots are NEVER auto-closed by this). `openCount` and + // `cap` are PUBLIC (the author's own open-item count on a public repo, and the repo's own configured limit), + // 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; + // 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; // Flag-then-close double-check for the linked-issue hard rule (#linked-issue-verify-before-close). When // `verifyBeforeClose` is true (the default), a violation FLAGS the PR (pending-closure label + warning comment) // on first detection and only CLOSES on a LATER evaluation when the violation STILL holds AND the PR already @@ -257,6 +274,14 @@ function blacklistCloseMessage(): string { return "Gittensory is closing this pull request on the maintainer's behalf. This account is blocked from contributing to this repository, so the change was not reviewed on its merits. This is an automated maintenance action."; } +// 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.`; +} + /** * Plan the maintainer auto-maintain actions for one PR. Returns a COHERENT set (never both approve and * request-changes; never both merge and close), each entry already filtered to an acting autonomy class. @@ -302,6 +327,26 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne return actions; } + // Per-contributor open-item cap (#2270): same zero-hallucination short-circuit shape as the blacklist above — + // fires ahead of ALL merit/CI/AI analysis, for a CONTRIBUTOR only. Re-checks the owner/admin/bot exemption + // 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 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)), + closeKind: "contributor_cap", + }); + } + return actions; + } + // Only a SKIPPED gate (genuinely not evaluated) drives no action. A NEUTRAL gate (first-time-contributor // grace, or eval-not-ready while state is still syncing) is gate-NON-BLOCKING: it flows to the disposition so // the PR is merged (clean+green) or HELD with a label — never left silently undecided. (#harm-stop neutral-silent-stuck) diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 3f8347fab2..63d8566c65 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -127,6 +127,7 @@ export type FocusManifestSettings = Partial< | "blacklistLabel" | "contributorOpenPrCap" | "contributorOpenIssueCap" + | "contributorCapLabel" > >; @@ -815,6 +816,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) const contributorOpenIssueCap = normalizeOptionalPositiveInteger(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings); if (contributorOpenIssueCap !== null) out.contributorOpenIssueCap = contributorOpenIssueCap; } + const contributorCapLabel = normalizeOptionalString(r.contributorCapLabel, "settings.contributorCapLabel", warnings); + if (contributorCapLabel !== null) out.contributorCapLabel = contributorCapLabel; return out; } diff --git a/src/types.ts b/src/types.ts index 62c5f4685f..35393b7f53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -621,6 +621,11 @@ export type RepositorySettings = { /** Per-contributor open-issue cap (#2270, anti-abuse): same shape and precedence as {@link contributorOpenPrCap}, * applied to open issues instead of open PRs. `null`/absent (default) = no cap. */ contributorOpenIssueCap?: number | null | undefined; + /** The label applied to a PR/issue closed for exceeding a per-contributor open-item cap (#2270). Same + * configurable-with-fallback shape as {@link blacklistLabel}; defaults to `"over-contributor-limit"` so the + * disposition works regardless of the label a repo sets. Always populated by the DB layer; optional so + * existing settings fixtures/callers need not be touched. */ + contributorCapLabel?: string | undefined; /** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default * `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers * need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */ @@ -693,8 +698,10 @@ export type AgentPendingActionParams = { closeComment?: string; // Which kind of close this is (see PlannedAgentAction.closeKind), persisted so it round-trips through staging: // the close-precision circuit-breaker still scopes itself correctly when a staged close is later accepted - // (#2127). - closeKind?: "linked-issue-hard-rule" | "blacklist" | "heuristic"; + // (#2127), and the actuation-time live-CI re-check (#2364) — which only applies to a heuristic close — still + // fires correctly once the row is replayed through pendingActionToPlanned, rather than silently skipping for + // a lost discriminator. + closeKind?: "linked-issue-hard-rule" | "blacklist" | "contributor_cap" | "heuristic"; // For a CI-driven heuristic close, persist the CI state that must still hold when the staged action replays // (#2364). This is separate from closeKind because heuristic closes also cover non-CI adverse signals. // ALWAYS set (to "failed" or "not_required") for a freshly planned heuristic close (#2478) -- never omitted -- diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts index 6d72b7844d..ecefb3d9a7 100644 --- a/test/unit/agent-action-executor.test.ts +++ b/test/unit/agent-action-executor.test.ts @@ -4,6 +4,7 @@ vi.mock("../../src/github/pr-actions", () => ({ createPullRequestReview: vi.fn(async () => ({ id: 1 })), mergePullRequest: vi.fn(async () => ({ merged: true, sha: "merged-sha" })), closePullRequest: vi.fn(async () => ({ state: "closed" })), + closeIssue: vi.fn(async () => ({ state: "closed" })), createIssueComment: vi.fn(async () => ({ id: 2 })), updatePullRequestBranch: vi.fn(async () => undefined), dismissLatestBotApproval: vi.fn(async () => ({ dismissed: true })), @@ -35,12 +36,21 @@ vi.mock("../../src/github/backfill", async (importOriginal) => ({ refreshInstallationHealthForInstallation: vi.fn(async () => null), })); -import { closePullRequest, createIssueComment, createPullRequestReview, dismissLatestBotApproval, mergePullRequest, updatePullRequestBranch } from "../../src/github/pr-actions"; +import { closeIssue, closePullRequest, createIssueComment, createPullRequestReview, dismissLatestBotApproval, mergePullRequest, updatePullRequestBranch } from "../../src/github/pr-actions"; import { ensurePullRequestLabel, removePullRequestLabel } from "../../src/github/labels"; import { fetchPullRequestFreshness } from "../../src/github/pr-freshness"; import { createInstallationToken } from "../../src/github/app"; import { fetchLiveCiAggregate, refreshInstallationHealthForInstallation } from "../../src/github/backfill"; -import { actionParams, executeAgentMaintenanceActions, pendingActionToPlanned, pendingClosureLabelApplied, type AgentActionExecutionContext, type AgentActionOutcome } from "../../src/services/agent-action-executor"; +import { + actionParams, + executeAgentMaintenanceActions, + executeIssueMaintenanceActions, + pendingActionToPlanned, + pendingClosureLabelApplied, + type AgentActionExecutionContext, + type AgentActionOutcome, + type IssueActionExecutionContext, +} from "../../src/services/agent-action-executor"; import type { PlannedAgentAction } from "../../src/settings/agent-actions"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; import { isGlobalAgentFrozen, setGlobalAgentFrozen, upsertPullRequestFromGitHub } from "../../src/db/repositories"; @@ -595,6 +605,119 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => { }); }); +function issueCtx(over: Partial = {}): IssueActionExecutionContext { + return { + installationId: 123, + repoFullName: "owner/repo", + issueNumber: 42, + autonomy: { label: "auto", close: "auto" }, + agentPaused: false, + agentDryRun: false, + ...over, + }; +} + +const issueLabel: PlannedAgentAction = { actionClass: "label", requiresApproval: false, reason: "over the per-contributor open-item cap", label: "over-contributor-limit", labelOp: "add" }; +const issueClose: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "over the per-contributor open-item cap", closeComment: "closing this issue", closeKind: "contributor_cap" }; + +describe("executeIssueMaintenanceActions (#2270 issue-side actuation)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("LIVE: labels + closes an issue via the issues-endpoint primitives and audits completed", async () => { + const env = createTestEnv({}); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx(), [issueLabel, issueClose]); + expect(outcomes.map((o) => o.outcome)).toEqual(["completed", "completed"]); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 42, "over-contributor-limit", { createMissingLabel: true }); + expect(createIssueComment).toHaveBeenCalledWith(env, 123, "owner/repo", 42, "closing this issue"); + expect(closeIssue).toHaveBeenCalledWith(env, 123, "owner/repo", 42); + expect(closePullRequest).not.toHaveBeenCalled(); // the PR endpoint must never be hit for an issue number + expect((await auditFor(env, "close"))?.outcome).toBe("completed"); + }); + + it("a close with no closeComment posts no comment (defensive — the contributor_cap planner always sets one)", async () => { + const env = createTestEnv({}); + const { closeComment: _closeComment, ...closeWithoutComment } = issueClose; + await executeIssueMaintenanceActions(env, issueCtx(), [closeWithoutComment]); + expect(createIssueComment).not.toHaveBeenCalled(); + expect(closeIssue).toHaveBeenCalledTimes(1); + }); + + it("a label action with no label name falls back to an empty string (defensive — the contributor_cap planner always sets one)", async () => { + const env = createTestEnv({}); + const { label: _label, ...labelWithoutName } = issueLabel; + await executeIssueMaintenanceActions(env, issueCtx(), [labelWithoutName]); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 42, "", { createMissingLabel: true }); + }); + + it("PAUSED (per-repo): mutates nothing and audits denied", async () => { + const env = createTestEnv({}); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx({ agentPaused: true }), [issueLabel, issueClose]); + expect(outcomes.every((o) => o.outcome === "denied")).toBe(true); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + expect(closeIssue).not.toHaveBeenCalled(); + expect(JSON.parse((await auditFor(env, "close"))?.metadata_json ?? "{}")).toMatchObject({ mode: "paused" }); + }); + + it("GLOBAL kill-switch halts everything regardless of per-repo config", async () => { + const env = createTestEnv({ AGENT_ACTIONS_PAUSED: "true" }); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx({ agentPaused: false }), [issueClose]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(closeIssue).not.toHaveBeenCalled(); + }); + + it("denies when current per-action autonomy is no longer acting", async () => { + const env = createTestEnv({}); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx({ autonomy: { label: "observe", close: "observe" } }), [issueLabel, issueClose]); + expect(outcomes.map((o) => o.outcome)).toEqual(["denied", "denied"]); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + expect(closeIssue).not.toHaveBeenCalled(); + }); + + it("DRY-RUN: records the intent without any GitHub call, audited with mode=dry_run", async () => { + const env = createTestEnv({}); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx({ agentDryRun: true }), [issueLabel, issueClose]); + expect(outcomes.map((o) => o.outcome)).toEqual(["dry_run", "dry_run"]); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + expect(closeIssue).not.toHaveBeenCalled(); + const audit = await auditFor(env, "close"); + expect(audit?.outcome).toBe("completed"); + expect(JSON.parse(audit?.metadata_json ?? "{}")).toMatchObject({ mode: "dry_run" }); + }); + + it("auto_with_approval is DENIED, not staged — issue-side staging is not implemented (#2270 known scope limit)", async () => { + const env = createTestEnv({}); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx({ autonomy: { label: "auto_with_approval", close: "auto_with_approval" } }), [ + { ...issueLabel, requiresApproval: true }, + { ...issueClose, requiresApproval: true }, + ]); + expect(outcomes.map((o) => o.outcome)).toEqual(["denied", "denied"]); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + expect(closeIssue).not.toHaveBeenCalled(); + const detail = await env.DB.prepare("select detail from audit_events where event_type = 'agent.action.close' order by created_at desc limit 1").first<{ detail: string }>(); + expect(detail?.detail).toMatch(/issue-side staging is not yet supported/); + }); + + it("denies an unsupported action class defensively (this executor only handles label/close)", async () => { + const env = createTestEnv({}); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx({ autonomy: { merge: "auto" } }), [ + { actionClass: "merge", requiresApproval: false, reason: "n/a", mergeMethod: "squash" }, + ]); + expect(outcomes[0]?.outcome).toBe("denied"); + const detail = await env.DB.prepare("select detail from audit_events where event_type = 'agent.action.merge' order by created_at desc limit 1").first<{ detail: string }>(); + expect(detail?.detail).toMatch(/unsupported action class for an issue/); + }); + + it("records a failed mutation as error rather than swallowing it", async () => { + const env = createTestEnv({}); + vi.mocked(closeIssue).mockRejectedValueOnce(new Error("github 500")); + const outcomes = await executeIssueMaintenanceActions(env, issueCtx(), [issueClose]); + expect(outcomes[0]?.outcome).toBe("error"); + expect((await auditFor(env, "close"))?.outcome).toBe("error"); + }); +}); + describe("pendingClosureLabelApplied (#1136 Pass-2 trigger)", () => { const labelAdd: PlannedAgentAction = { actionClass: "label", requiresApproval: false, reason: "flag", label: AGENT_LABEL_PENDING_CLOSURE, labelOp: "add" }; const approve2: PlannedAgentAction = { actionClass: "approve", requiresApproval: false, reason: "ok" }; diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index c63128ef52..377b32fa46 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { AGENT_LABEL_CHANGES, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, DEFAULT_BLACKLIST_LABEL, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions"; +import { AGENT_LABEL_CHANGES, AGENT_LABEL_NEEDS_REVIEW, AGENT_LABEL_READY, DEFAULT_BLACKLIST_LABEL, DEFAULT_CONTRIBUTOR_CAP_LABEL, downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, planAgentMaintenanceActions, type AgentActionPlanInput, type PlannedAgentAction } from "../../src/settings/agent-actions"; import { AGENT_LABEL_PENDING_CLOSURE } from "../../src/review/linked-issue-hard-rules"; import type { GateCheckConclusion } from "../../src/rules/advisory"; @@ -862,3 +862,69 @@ describe("contributor blacklist short-circuit (#1425)", () => { expect(plan[1]?.closeComment).toContain("blocked from contributing"); }); }); + +describe("per-contributor open-item cap short-circuit (#2270)", () => { + const overCap = (extra: Partial = {}) => + input({ + conclusion: "success", + autonomy: { label: "auto", close: "auto", approve: "auto", merge: "auto" }, + contributorCapMatch: { matched: true, authorLogin: "farmer99", openCount: 3, cap: 2, itemKind: "pull requests" }, + ...extra, + }); + + it("labels + closes an over-cap contributor's PR, winning over a passing gate (no merit review / merge)", () => { + const plan = planAgentMaintenanceActions(overCap()); + expect(classes(plan)).toEqual(["label", "close"]); // short-circuit: no approve/merge despite a SUCCESS gate + expect(plan[0]).toMatchObject({ actionClass: "label", label: DEFAULT_CONTRIBUTOR_CAP_LABEL, labelOp: "add" }); + expect(plan[1]).toMatchObject({ actionClass: "close", closeKind: "contributor_cap" }); + }); + + it("interpolates the (public) login/count/cap into the close comment — unlike blacklist's static-only comment", () => { + const plan = planAgentMaintenanceActions(overCap()); + expect(plan[1]?.closeComment).toContain("@farmer99"); + expect(plan[1]?.closeComment).toContain("3 open pull requests"); + expect(plan[1]?.closeComment).toContain("limit of 2"); + }); + + it("itemKind selects the close-comment noun — 'issues' for the issue-path caller, not hardcoded to PRs (regression, gate finding on #2467/#2479)", () => { + const plan = planAgentMaintenanceActions( + overCap({ contributorCapMatch: { matched: true, authorLogin: "farmer99", openCount: 3, cap: 2, itemKind: "issues" } }), + ); + expect(plan[1]?.closeComment).toContain("3 open issues"); + expect(plan[1]?.closeComment).not.toContain("pull requests"); + }); + + 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"); + expect(planAgentMaintenanceActions(overCap())[0]).toMatchObject({ label: "over-contributor-limit" }); + }); + + it("fires AHEAD of CI — closes even while CI is still pending (not the pending early-return)", () => { + expect(classes(planAgentMaintenanceActions(overCap({ ciState: "pending" })))).toEqual(["label", "close"]); + }); + + it("NEVER fires for the owner, an admin login, or an automation bot (standing rule) — the PR falls through to normal disposition", () => { + expect(classes(planAgentMaintenanceActions(overCap({ authorIsOwner: true })))).not.toContain("close"); + expect(classes(planAgentMaintenanceActions(overCap({ authorIsAdmin: true })))).not.toContain("close"); + expect(classes(planAgentMaintenanceActions(overCap({ authorIsAutomationBot: true })))).not.toContain("close"); + }); + + it("no-ops when the author is not matched (normal disposition runs)", () => { + expect(classes(planAgentMaintenanceActions(overCap({ contributorCapMatch: { matched: false, authorLogin: "farmer99", openCount: 1, cap: 2, itemKind: "pull requests" } })))).not.toContain("close"); + }); + + it("respects autonomy: observe plans nothing (still short-circuits); label-only labels but does not close", () => { + expect(planAgentMaintenanceActions(overCap({ autonomy: {} }))).toEqual([]); + expect(classes(planAgentMaintenanceActions(overCap({ autonomy: { label: "auto" } })))).toEqual(["label"]); + }); + + it("is independent of the blacklist short-circuit — a matched blacklist entry still wins when both are present", () => { + // Blacklist is checked first in the planner; a PR that is BOTH blacklisted and over-cap gets the + // blacklist's closeKind, not contributor_cap (order matters only when both conditions are true). + const plan = planAgentMaintenanceActions( + overCap({ blacklistMatch: { matched: true, reason: "plagiarism" } }), + ); + expect(plan[1]).toMatchObject({ closeKind: "blacklist" }); + }); +}); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index f1270a6ee2..8f997ab45b 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -318,6 +318,12 @@ describe("data spine repositories", () => { expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull(); await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: Number.NaN as never }); expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull(); + // contributorCapLabel (#2270) round-trips and defaults to "over-contributor-limit". + expect((await getRepositorySettings(env, "missing/repo")).contributorCapLabel).toBe("over-contributor-limit"); + await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorCapLabel: "spam-cap" }); + expect((await getRepositorySettings(env, "owner/caprepo")).contributorCapLabel).toBe("spam-cap"); + await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorCapLabel: "renamed-cap" }); + expect((await getRepositorySettings(env, "owner/caprepo")).contributorCapLabel).toBe("renamed-cap"); // update persists expect(updated.slopAiAdvisory).toBe(false); expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); expect(await getPullRequest(env, "owner/repo", 404)).toBeNull(); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 2e9ebab513..6672bbc824 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -1356,6 +1356,18 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(eff.contributorOpenIssueCap).toBeNull(); }); + it("parses + resolves contributorCapLabel from the settings: block, overlaying the DB (#2270)", () => { + const manifest = parseFocusManifest({ settings: { contributorCapLabel: "spam-cap" } }); + expect(manifest.settings.contributorCapLabel).toBe("spam-cap"); + const eff = resolveEffectiveSettings({ contributorCapLabel: "db-label" } as unknown as RepositorySettings, manifest); + expect(eff.contributorCapLabel).toBe("spam-cap"); // yml overlays DB + const noOverride = resolveEffectiveSettings({ contributorCapLabel: "db-label" } as unknown as RepositorySettings, parseFocusManifest({})); + expect(noOverride.contributorCapLabel).toBe("db-label"); // omitted in yml ⇒ DB survives + const blank = parseFocusManifest({ settings: { contributorCapLabel: " " } }); + expect(blank.settings.contributorCapLabel).toBeUndefined(); + expect(blank.warnings.some((w) => /settings\.contributorCapLabel/.test(w))).toBe(true); + }); + it("resolves contributor blacklist by unioning the shared/global list with effective per-repo settings", () => { const manifest = parseFocusManifest({ settings: { contributorBlacklist: [{ login: "repo-only", reason: "manifest" }, { login: "Global-Repo", reason: "manifest-overrides-global" }] } }); const eff = resolveEffectiveSettings( diff --git a/test/unit/github-pr-actions.test.ts b/test/unit/github-pr-actions.test.ts index 7ac200e29e..ef60cf924e 100644 --- a/test/unit/github-pr-actions.test.ts +++ b/test/unit/github-pr-actions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; -import { closePullRequest, createIssueComment, createPullRequestReview, createPullRequestReviewComments, dismissLatestBotApproval, getLastCloserLogin, getLastReopenerLogin, mergePullRequest, updatePullRequestBranch } from "../../src/github/pr-actions"; +import { closeIssue, closePullRequest, createIssueComment, createPullRequestReview, createPullRequestReviewComments, dismissLatestBotApproval, getLastCloserLogin, getLastReopenerLogin, mergePullRequest, updatePullRequestBranch } from "../../src/github/pr-actions"; import { clearInstallationTokenCacheForTest } from "../../src/github/app"; import { createTestEnv } from "../helpers/d1"; @@ -146,6 +146,25 @@ describe("GitHub PR action primitives (#778)", () => { expect(calls[0]?.url).toMatch(/\/repos\/owner\/repo\/pulls\/7$/); }); + it("closes an ISSUE via the issues endpoint, not the pulls endpoint (#2270)", async () => { + const calls: Array<{ method: string; url: string; body: Record }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + return Response.json({ state: "closed" }); + }); + const result = await closeIssue(envWithKey(), 123, "owner/repo", 42); + expect(result).toEqual({ state: "closed" }); + expect(calls[0]).toMatchObject({ method: "PATCH", body: { state: "closed" } }); + // Issues endpoint, NOT /pulls/42 — a plain issue number is not a valid pull_number. + expect(calls[0]?.url).toMatch(/\/repos\/owner\/repo\/issues\/42$/); + }); + + it("closeIssue validates the repo name before any GitHub call", async () => { + await expect(closeIssue(createTestEnv(), 1, "invalid", 4)).rejects.toThrow(/Invalid repository full name/); + }); + it("evicts a rejected installation token and retries once with a freshly-minted token on a 401 (#2263)", async () => { clearInstallationTokenCacheForTest(); let tokenMints = 0; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 93a804393a..2a6b37c983 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -5669,6 +5669,931 @@ describe("queue processors", () => { expect(seen.comments.some((c) => c.includes("blocked from contributing"))).toBe(true); }); + it("contributor open-PR cap (#2270): a contributor's 3rd open PR (over a cap of 2) is labeled + closed deterministically with no merit merge", async () => { + let aiCalls = 0; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => { aiCalls += 1; return { response: JSON.stringify({ assessment: "n/a", blockers: [], nits: [], suggestions: [] }) }; } } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "100000", + }); + 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" } }], + }); + // Two PRE-EXISTING open PRs from the same author, seeded directly (as if opened moments earlier). + 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, + // The label is the configurable `.gittensory.yml` value below — nothing is hard-coded. + }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { contributorCapLabel: "spam-cap" } }, "repo_file"); + const seen = { closed: false, labels: [] as string[], comments: [] as string[] }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/55/comments") && method === "POST") { seen.comments.push(String(JSON.parse(String(init?.body ?? "{}")).body ?? "")); return Response.json({ id: 1 }, { status: 201 }); } + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "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 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + // Deterministic gate: closed + labeled (with the configured label), and the AI was NEVER called. + expect(aiCalls).toBe(0); + expect(seen.closed).toBe(true); + expect(seen.labels).toContain("spam-cap"); + 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); + // No merit merge despite a clean+green+approved PR (the cap short-circuits ahead of merit). + const mergeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.merge'").first<{ n: number }>(); + 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); + }); + + it("contributor open-PR cap (#2270): disabled (no cap configured, the default) never closes an over-threshold contributor", 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" } }], + }); + 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" }); + // No contributorOpenPrCap set — the default, disabled state. + 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", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-disabled", + 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" }, + }, + }); + + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + expect(seen.closed).toBe(false); + }); + + it("contributor open-PR cap (#2270): a contributor's 2nd PR AT (not over) a cap of 2 is not closed", 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" } }], + }); + // Only ONE pre-existing open PR from this author — the incoming PR is their 2nd, exactly at the cap. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Farmer PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f53" }, labels: [], body: "x" }); + 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 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-at-limit", + 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 2nd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + expect(seen.closed).toBe(false); + }); + + it("contributor open-PR cap (#2270): the repo OWNER's own PR is never closed even over the cap (live processor path, not just the planner)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + 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" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 53, title: "Owner PR one", state: "open", user: { login: "JSONbored" }, head: { sha: "o53" }, labels: [], body: "x" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Owner PR two", state: "open", user: { login: "JSONbored" }, head: { sha: "o54" }, 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 }; + 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: "JSONbored" }, head: { sha: "o55" }, mergeable_state: "clean" }); + if (url.includes("/commits/o55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/o55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-cap-owner", + 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: "Owner's 3rd PR", state: "open", user: { login: "JSONbored" }, head: { sha: "o55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(seen.closed).toBe(false); + }); + + it("contributor open-PR cap (#2270): an author-less (ghost) open PR among the repo's others is excluded from the count and the sibling-wake scan, not crashed on", 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" } }], + }); + // A ghost PR with no `user` at all (authorLogin ends up null) — must not match farmer99's count, and must + // not crash the sibling-wake scan, which runs the identical (authorLogin ?? "") fallback. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 50, title: "Ghost PR", state: "open", head: { sha: "ghost50" }, labels: [], body: "z" }); + 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 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/55/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+const ok = true;" }]); + if (url.includes("/pulls/55/reviews")) return Response.json([]); + if (url.includes("/pulls/55/commits")) return Response.json([]); + if (url.endsWith("/pulls/55") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ number: 55, state: "closed" }); } + if (url.endsWith("/pulls/55")) return Response.json({ number: 55, state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, mergeable_state: "clean" }); + if (url.includes("/commits/f55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/f55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/55/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/55/labels") && method === "POST") return Response.json([]); + if (url.includes("/issues/55/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes("/issues/55/comments")) return Response.json([]); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "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: "Farmer's 3rd PR", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + // Ghost PR's null authorLogin never matches "farmer99" — the count is still exactly 3 (farmer99's own). + expect(seen.closed).toBe(true); + }); + + it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => { + // PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/ + // retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over), + // so it correctly stays open — but a naive "only ever check myself" implementation would leave it open + // FOREVER, since nothing else ever re-evaluates PR56 again. This pins the fix: once PR55's delivery later + // sees the COMPLETE set {54, 55, 56}, it must wake PR56 (not just decide for itself) so PR56 gets a fresh, + // fully-gated re-evaluation and self-corrects. + 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" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR zero", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "w" }); + 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 closedNumbers = new Set(); + const fanned: import("../../src/types").JobMessage[] = []; + const realSend = env.JOBS.send.bind(env.JOBS); + env.JOBS.send = (async (message: import("../../src/types").JobMessage, options?: QueueSendOptions) => { + if (message.type === "agent-regate-pr") fanned.push(message); + return realSend(message, options); + }) as typeof env.JOBS.send; + 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" }); + for (const [n, sha] of [[55, "f55"], [56, "f56"]] as const) { + if (url.includes(`/pulls/${n}/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/${n}/reviews`)) return Response.json([]); + if (url.includes(`/pulls/${n}/commits`)) return Response.json([]); + if (url.endsWith(`/pulls/${n}`) && method === "PATCH") { closedNumbers.add(n); return Response.json({ number: n, state: "closed" }); } + if (url.endsWith(`/pulls/${n}`)) return Response.json({ number: n, state: closedNumbers.has(n) ? "closed" : "open", user: { login: "farmer99" }, head: { sha }, mergeable_state: "clean" }); + if (url.includes(`/commits/${sha}/check-runs`)) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes(`/commits/${sha}/status`)) return Response.json({ state: "success", statuses: [] }); + if (url.includes(`/issues/${n}/labels`) && method === "GET") return Response.json([]); + if (url.includes(`/issues/${n}/labels`) && method === "POST") return Response.json([]); + if (url.includes(`/issues/${n}/comments`) && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.includes(`/issues/${n}/comments`)) return Response.json([]); + } + return Response.json({}); + }); + + // PR56 arrives FIRST — PR55 does not exist yet, so PR56 sees only {54, 56}: at the cap, not over. + await processJob(env, { + type: "github-webhook", + deliveryId: "burst-pr56-first", + 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: 56, title: "Farmer PR two (out of order)", state: "open", user: { login: "farmer99" }, head: { sha: "f56" }, labels: [], body: "y", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + expect(closedNumbers.has(56)).toBe(false); // correctly not closed YET — the set looked complete at the time + + // PR55 arrives SECOND — now the complete set {54, 55, 56} is visible. PR55 itself ranks within the cap + // (oldest 2 of 3), so it stays open — but PR56 is now discoverably over-cap and must be woken. + await processJob(env, { + type: "github-webhook", + deliveryId: "burst-pr55-second", + 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 PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + expect(closedNumbers.has(55)).toBe(false); // PR55 itself is within the cap + expect(fanned.some((job) => job.type === "agent-regate-pr" && job.prNumber === 56)).toBe(true); // sibling woken + + // Drain the woken job — PR56's OWN fresh re-evaluation now sees the complete set and self-corrects. + env.JOBS.send = realSend; + for (const job of fanned) await processJob(env, job); + expect(closedNumbers.has(56)).toBe(true); + }); + + it("contributor open-PR cap (#2270): a re-delivered sibling-wake is coalesced — the second discovery does not re-enqueue", 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" } }], + }); + // Pre-seed the coalescing key for PR56 exactly as wakeOverCapSiblingPullRequests itself would after a + // first, already-successful enqueue — proving the SECOND discovery within the window skips re-enqueueing. + await env.SELFHOST_TRANSIENT_CACHE?.set("contributor-cap-wake:jsonbored/gittensory#56", "1", 60); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR zero", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "w" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 56, title: "Farmer PR two (already over cap)", state: "open", user: { login: "farmer99" }, head: { sha: "f56" }, 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 fanned: import("../../src/types").JobMessage[] = []; + const realSend = env.JOBS.send.bind(env.JOBS); + env.JOBS.send = (async (message: import("../../src/types").JobMessage, options?: QueueSendOptions) => { + if (message.type === "agent-regate-pr") fanned.push(message); + return realSend(message, options); + }) as typeof env.JOBS.send; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + 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")) 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({}); + }); + + // PR55 arrives and independently discovers PR56 is over cap — but the wake was already claimed. + await processJob(env, { + type: "github-webhook", + deliveryId: "wake-coalesce-second", + 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 PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }); + + expect(fanned).toEqual([]); // coalesced — no duplicate wake enqueued + }); + + it("contributor open-PR cap (#2270): swallows a failed sibling-wake enqueue and does not claim the coalescing key (regression)", async () => { + // If env.JOBS.send() throws (queue backpressure/outage), the wake must be a best-effort fire-and-forget: + // log and move on WITHOUT claiming the coalescing key, so a later discovery can still retry the enqueue. + 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" } }], + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 54, title: "Farmer PR zero", state: "open", user: { login: "farmer99" }, head: { sha: "f54" }, labels: [], body: "w" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 56, title: "Farmer PR two (already over cap)", state: "open", user: { login: "farmer99" }, head: { sha: "f56" }, 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, + }); + env.JOBS.send = (async () => { + throw new Error("queue send boom"); + }) as typeof env.JOBS.send; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + 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")) 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({}); + }); + + // PR55 arrives, discovers PR56 is over cap, and the wake enqueue itself fails — must not throw. + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "wake-enqueue-fails", + 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 PR one", state: "open", user: { login: "farmer99" }, head: { sha: "f55" }, labels: [], body: "x", mergeable_state: "clean", reviewDecision: "APPROVED" }, + }, + }), + ).resolves.not.toThrow(); + + // The coalescing key was NOT claimed (enqueue failed), so a later discovery can still retry. + expect(await env.SELFHOST_TRANSIENT_CACHE?.get("contributor-cap-wake:jsonbored/gittensory#56")).toBeNull(); + }); + + it("contributor open-ISSUE cap (#2270): a contributor's 3rd open issue (over a cap of 2) is labeled + closed deterministically", 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", 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, + }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { contributorCapLabel: "spam-cap" } }, "repo_file"); + const seen = { closed: false, labels: [] as string[], comments: [] as string[] }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") return Response.json({ state: "open" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed"; return Response.json({ state: "closed" }); } + if (url.includes("/issues/62/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/62/labels") && method === "POST") { seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[])); return Response.json([]); } + if (url.includes("/issues/62/comments") && method === "POST") { seen.comments.push(String(JSON.parse(String(init?.body ?? "{}")).body ?? "")); return Response.json({ id: 1 }, { status: 201 }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "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 3rd issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(true); + expect(seen.labels).toContain("spam-cap"); + expect(seen.comments.some((c) => c.includes("@farmer99") && c.includes("3 open issues") && c.includes("limit of 2"))).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("REGRESSION (#2479 gate finding): a stale-open DB row for an already-closed sibling does NOT inflate the count and wrongly close a newly opened issue within the real cap", async () => { + // Issue #60 is stored `open` locally but is ACTUALLY closed on GitHub (live GET returns closed) -- e.g. a + // webhook this instance hasn't processed yet, or a manual close elsewhere. Without live-verifying it, the + // stale count would be 3 (60, 61, 62) against a cap of 2, wrongly closing #62. Live-verified, the real count + // is 2 (61, 62), within cap. + 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", 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 (stale-open)", 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 }; + 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") && method === "GET") return Response.json({ state: "closed" }); + if (url.endsWith("/issues/61") && method === "GET") return Response.json({ state: "open" }); + 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: "contributor-issue-cap-stale-closed-sibling", + 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 issue, within the real cap", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + }); + + it("REGRESSION (#2479 gate finding, second pass): a live-check failure for a counted sibling fails SAFE (excluded from the count) rather than counting it toward an irreversible close", async () => { + // Unlike reconcileLiveDuplicateSiblings' fail-open-to-stored contract (safe there because it only re-ranks a + // non-final duplicate-cluster winner recomputed every delivery), this count directly gates an IRREVERSIBLE + // close. An unreadable live fetch for sibling #60 (404) must NOT let it keep counting toward the cap -- + // otherwise a transient fetch failure stacked on a stale "open" DB row would wrongly close a newly opened + // issue that is actually within the real cap. #60 unverifiable + #61 confirmed open + #62 incoming = 2, + // within the cap of 2, so #62 must NOT close. + 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", 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 }; + 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") && method === "GET") return new Response("not found", { status: 404 }); + if (url.endsWith("/issues/61") && method === "GET") return Response.json({ state: "open" }); + 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: "contributor-issue-cap-live-check-fails-safe", + 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 issue, within the real cap", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + }); + + it("a live-verified-open sibling still counts toward the cap and closes the incoming issue when genuinely over", async () => { + // Positive-confirmation path: both siblings live-verify as open, so the real count (60, 61, 62 = 3) against + // a cap of 2 is genuine, and #62 correctly closes. + 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", 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 }); + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { contributorCapLabel: "spam-cap" } }, "repo_file"); + 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") && method === "GET") return Response.json({ state: "open" }); + if (url.endsWith("/issues/61") && method === "GET") return Response.json({ state: "open" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + if (url.includes("/issues/62/labels") && method === "GET") return Response.json([]); + if (url.includes("/issues/62/labels") || url.includes("/issues/62/comments")) return Response.json([], { status: 201 }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-issue-cap-live-verified-genuine", + 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 genuinely 3rd issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(true); + }); + + it("falls back to GITHUB_PUBLIC_TOKEN for the sibling live-check when the installation token mint fails", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), 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" } }], + }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one (stale-open)", 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, sawPublicToken: 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 new Response("suspended", { status: 401 }); + if (url.endsWith("/issues/60") && method === "GET") { + seen.sawPublicToken = new Headers(init?.headers).get("authorization")?.includes("public-fallback-token") ?? false; + return Response.json({ state: "closed" }); + } + if (url.endsWith("/issues/61") && method === "GET") return Response.json({ state: "open" }); + 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: "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 issue, within the real cap", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.sawPublicToken).toBe(true); + // #60 was live-verified closed via the public-token fallback, so the real count (61, 62) is within cap. + expect(seen.closed).toBe(false); + }); + + it("contributor open-ISSUE cap (#2270): disabled (no cap configured, the default) never closes an over-threshold contributor's issue", 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", 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" }); + // No contributorOpenIssueCap set — the default, disabled state. + 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: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-issue-cap-disabled", + 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(false); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + }); + + it("contributor open-ISSUE cap (#2270): the repo OWNER's own issue is never closed even over the cap", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertInstallation(env, { + 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: "Owner issue one", state: "open", user: { login: "JSONbored" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Owner issue two", state: "open", user: { login: "JSONbored" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { close: "auto", label: "auto" }, contributorOpenIssueCap: 2 }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-issue-cap-owner", + 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: "Owner's 3rd issue", state: "open", user: { login: "JSONbored" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + }); + + it("contributor open-ISSUE cap (#2270): a contributor's 2nd issue AT (not over) a cap of 2 is not closed", 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", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // Only ONE pre-existing open issue from this author — the incoming issue is their 2nd, exactly at the cap. + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { close: "auto", label: "auto" }, contributorOpenIssueCap: 2 }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-issue-cap-at-limit", + 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 2nd issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }); + + expect(seen.closed).toBe(false); + }); + + it("contributor open-ISSUE cap (#2270): an over-cap issue is not closed when both label and close autonomy are observe-only", 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", 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" }); + // No acting autonomy for label/close — deny-by-default (autonomy: {}) means planAgentMaintenanceActions + // plans nothing at all, so this exercises the "planned.length === 0" early return distinctly from the + // disabled-cap case above (here the cap DOES match; there is simply nothing to execute). + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: {}, contributorOpenIssueCap: 2 }); + const seen = { closed: false }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/62") && method === "PATCH") { seen.closed = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-issue-cap-no-autonomy", + 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(false); + const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); + expect(closeAudit?.n ?? 0).toBe(0); + }); + + it("contributor open-ISSUE cap (#2270): a slash-free repoFullName is safely planned (repoOwner computation guard) even though the GitHub call itself can never succeed against that name", async () => { + // A real webhook always carries "owner/repo"; this pins the DEFENSIVE repoFullName.includes("/") ? ... : "" + // fallback (mirroring the PR path's own such guard) against a malformed value WITHOUT crashing the cap + // computation. The actual close attempt legitimately errors — splitRepo() (shared by every GitHub-action + // primitive) rejects any repoFullName that isn't "owner/repo" — and that error is caught and audited, not + // thrown into the webhook handler; a successful close against a slash-free name is not physically possible + // via the real GitHub REST API, so asserting an audited error (not a crash) is the correct expectation. + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "noslash", full_name: "noslash", private: false, owner: { login: "" } }, 123); + await upsertInstallation(env, { + installation: { id: 123, account: { login: "", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "noslash", full_name: "noslash", private: false, owner: { login: "" } }], + }); + await upsertIssueFromGitHub(env, "noslash", { number: 60, title: "Farmer issue one", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }); + await upsertIssueFromGitHub(env, "noslash", { number: 61, title: "Farmer issue two", state: "open", user: { login: "farmer99" }, labels: [], body: "y" }); + await upsertRepositorySettings(env, { repoFullName: "noslash", autonomy: { close: "auto", label: "auto" }, contributorOpenIssueCap: 2 }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/60") || url.endsWith("/issues/61")) return Response.json({ state: "open" }); + return Response.json({}); + }); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "contributor-issue-cap-noslash", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "", id: 1, type: "User" } }, + repository: { name: "noslash", full_name: "noslash", private: false, owner: { login: "" } }, + issue: { number: 62, title: "Farmer's 3rd issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + }, + }), + ).resolves.not.toThrow(); + + const closeAudit = await env.DB.prepare("select outcome, detail from audit_events where event_type = 'agent.action.close' order by created_at desc limit 1").first<{ outcome: string; detail: string }>(); + expect(closeAudit?.outcome).toBe("error"); + expect(closeAudit?.detail).toMatch(/Invalid repository full name/); + }); + + it("contributor open-ISSUE cap (#2270): an author-less (ghost) open issue among the repo's others is excluded from the count, not crashed on", 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", issues: "write" }, events: ["issues"] }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + // A ghost issue with no `user` at all (authorLogin ends up null) — must not match farmer99's count nor throw. + await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 59, title: "Ghost issue", state: "open", labels: [], body: "z" }); + 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 }; + 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 = true; return Response.json({ state: "closed" }); } + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "contributor-issue-cap-ghost-author", + 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" }, + }, + }); + + // Ghost issue's null authorLogin never matches "farmer99" — the count is still exactly 3 (farmer99's own), + // so the cap-of-2 close fires; a broken nullish fallback would either crash or double-count the ghost. + expect(seen.closed).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 } = {}) {