From 2e44541e42cae642f3174efd3ea25f7499a06369 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:11:44 -0700 Subject: [PATCH 1/7] feat(settings): add contributorOpenPrCap/contributorOpenIssueCap config (#2270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the config-as-code contract for per-contributor open PR/issue caps: DB migration, RepositorySettings type, .gittensory.yml settings: parsing with precedence over the DB, OpenAPI response fields, and docs. No enforcement behavior yet (a repo with no cap configured sees zero change) — auto-close over the cap lands in a follow-up PR. --- .gittensory.yml.example | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 9527d8c888..47fbb4479c 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -263,10 +263,10 @@ 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(s) above the cap are closed with a clear reason; + # their oldest items up to the cap stay open and reviewable. 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 From 3b3c7af159d7298f5b48e62bdae3448d303b3c36 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:28:27 -0700 Subject: [PATCH 2/7] feat(agent-actions): auto-close a contributor's PR over the open-PR cap (#2270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the enforcement half of #2270: a new closeKind: "contributor_cap" short-circuit in planAgentMaintenanceActions, mirroring the existing contributor-blacklist block exactly (fires ahead of merit/CI/AI analysis, exempt from the close-precision breaker, owner/admin/bot never touched). The webhook-time check counts the author's other open PRs on the repo (already-fetched, live-DB-backed data — no new query) plus the incoming one, ranked by PR number so a burst of near-simultaneous opens still ranks deterministically; only the PR(s) pushing the count over the cap close, oldest ones stay open. Adds the paired contributorCapLabel setting (default "over-contributor-limit") through the full config-as-code chain. Known scope limit: this covers live webhook-time enforcement only. A maintainer lowering the cap after a contributor already has more PRs open than the new cap allows is not retroactively enforced by a sweep in this PR — tracked as follow-up work on #2270, not required for the core ask (closing a NEW PR that pushes someone over the limit). --- .gittensory.yml.example | 3 + apps/gittensory-ui/public/openapi.json | 3 + migrations/0090_contributor_cap_label.sql | 4 + src/db/repositories.ts | 5 + src/db/schema.ts | 1 + src/openapi/schemas.ts | 1 + src/queue/processors.ts | 27 ++++ src/settings/agent-actions.ts | 45 +++++- src/signals/focus-manifest.ts | 3 + src/types.ts | 11 +- test/unit/agent-actions.test.ts | 60 ++++++- test/unit/data-spine.test.ts | 6 + test/unit/focus-manifest.test.ts | 12 ++ test/unit/queue.test.ts | 183 ++++++++++++++++++++++ 14 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 migrations/0090_contributor_cap_label.sql diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 47fbb4479c..f84cfd8eaf 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -270,3 +270,6 @@ settings: # 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/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..c814aa8b2c 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1861,6 +1861,29 @@ 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 } | 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 }; + } + } + const planned = planAgentMaintenanceActions({ conclusion: gate.conclusion, blockerTitles: gate.blockers.map((blocker) => blocker.title), @@ -1885,6 +1908,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). diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 01cefff08d..eda2ad5e7e 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,17 @@ 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. + contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number } | 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 +272,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 +325,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 } = 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, "pull requests")), + 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-actions.test.ts b/test/unit/agent-actions.test.ts index c63128ef52..463d0710aa 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,61 @@ 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 }, + ...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("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 } })))).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/queue.test.ts b/test/unit/queue.test.ts index 93a804393a..b99b4091ea 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -5669,6 +5669,189 @@ 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); + }); + // #1092: prReadyForReview rebases a BEHIND-base PR through the agent executor (gated by update_branch autonomy // + pull_requests:write) before reviewing, then defers — the synchronize on the new head re-runs review. async function seedBehindRepo(env: Env, over: { autonomy?: Record; agentPaused?: boolean; perms?: Record; noInstall?: boolean } = {}) { From d3b32019b37a8de400a87f68f9307a7559f5f5ac Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:05:54 -0700 Subject: [PATCH 3/7] =?UTF-8?q?fix(docs):=20correct=20.gittensory.yml.exam?= =?UTF-8?q?ple=20=E2=80=94=20the=20PR=20cap=20is=20now=20enforced=20(#2479?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate finding on this PR: the example file still said contributorOpenPrCap was inert with enforcement "landing in a follow-up PR" — but this very PR IS that follow-up, so the doc was stale the moment it merged. Splits the two fields' status: contributorOpenPrCap is enforced; contributorOpenIssueCap remains inert until its own follow-up. Also adds a webhook-level (not just planner-level) owner-exemption test, per the gate's non-blocking suggestion. --- .gittensory.yml.example | 10 ++++---- test/unit/queue.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/.gittensory.yml.example b/.gittensory.yml.example index f84cfd8eaf..117c1a6687 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -264,10 +264,12 @@ settings: # 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. Uncomment and set a - # number to opt in — a contributor's newest item(s) above the cap are closed with a clear reason; - # their oldest items up to the cap stay open and reviewable. 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. + # number to opt in — a contributor's newest item above the cap is closed with a clear reason on the + # PR-opened webhook; their oldest items up to the cap stay open and reviewable. + # contributorOpenPrCap IS ENFORCED. contributorOpenIssueCap is currently INERT (stored and parsed, not + # yet enforced) — issue-side enforcement 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. # contributorOpenPrCap: 2 # contributorOpenIssueCap: 5 diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index b99b4091ea..0e1b9d0d4f 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -5852,6 +5852,59 @@ describe("queue processors", () => { 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); + }); + // #1092: prReadyForReview rebases a BEHIND-base PR through the agent executor (gated by update_branch autonomy // + pull_requests:write) before reviewing, then defers — the synchronize on the new head re-runs review. async function seedBehindRepo(env: Env, over: { autonomy?: Record; agentPaused?: boolean; perms?: Record; noInstall?: boolean } = {}) { From 8cc17a477dbb59651e58febc2d57879956d19a11 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:12:06 -0700 Subject: [PATCH 4/7] fix(queue): wake missed siblings on out-of-order contributor-cap delivery (#2270) Webhook delivery order is not guaranteed to match PR creation order, so a sibling PR's webhook can process before a later-created PR exists in the DB and wrongly conclude the author is within the open-PR cap. Nothing else would ever re-evaluate that sibling, permanently bypassing the cap. Now that a later delivery has the complete picture, wake any other still-open over-cap sibling so its own next pass self-corrects. --- src/queue/processors.ts | 58 +++++++++++- test/unit/queue.test.ts | 202 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 1 deletion(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c814aa8b2c..24cba88d4e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1738,7 +1738,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). @@ -1882,6 +1882,17 @@ async function runAgentMaintenancePlanAndExecute( if (overCapNumbers.has(pr.number)) { contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: authorOpenPrNumbers.length, cap: contributorOpenPrCap }; } + // 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({ @@ -2533,6 +2544,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, diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 0e1b9d0d4f..e61a7e485a 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -5905,6 +5905,208 @@ describe("queue processors", () => { 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 + }); + // #1092: prReadyForReview rebases a BEHIND-base PR through the agent executor (gated by update_branch autonomy // + pull_requests:write) before reviewing, then defers — the synchronize on the new head re-runs review. async function seedBehindRepo(env: Env, over: { autonomy?: Record; agentPaused?: boolean; perms?: Record; noInstall?: boolean } = {}) { From 2f8cae6704806046960b3d4a3e3c570f5a3796aa Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:29:14 -0700 Subject: [PATCH 5/7] feat(agent-actions): auto-close a contributor's issue over the open-issue cap (#2270) (#2493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): add contributorOpenPrCap/contributorOpenIssueCap config (#2270) Adds the config-as-code contract for per-contributor open PR/issue caps: DB migration, RepositorySettings type, .gittensory.yml settings: parsing with precedence over the DB, OpenAPI response fields, and docs. No enforcement behavior yet (a repo with no cap configured sees zero change) — auto-close over the cap lands in a follow-up PR. * fix(settings): preserve an explicit yml null clearing a contributor cap (#2467) parseSettingsOverride collapsed an explicit `settings.contributorOpenPrCap: null` in .gittensory.yml to the same outcome as omitting the key, so a maintainer had no way to force a DB-configured cap back to disabled via config-as-code, contradicting the documented yml > DB > null precedence. Distinguish the three states (omitted / explicit null / invalid) before normalizing. Also corrects .gittensory.yml.example, which described these fields as already closing over-cap PRs; enforcement lands in a follow-up PR. * feat(agent-actions): auto-close a contributor's issue over the open-issue cap (#2270) Adds the first eventName === "issues" actuation branch in processGitHubWebhook — issues previously had zero auto-close path. maybeCloseIssueOverContributorCap mirrors the PR-path cap exactly (same oldest-stays/newest-closes ranking by item number, same owner/admin/bot exemption) and reuses planAgentMaintenanceActions's contributor_cap short-circuit to build the label+close plan, so the label/message/closeKind construction is identical between the PR and issue paths. Fixes a bug introduced by that reuse: the close-comment message was hardcoded to say "pull requests" even when closing an issue. contributorCapMatch now carries an itemKind ("pull requests" | "issues") so each caller states its own noun explicitly. New executeIssueMaintenanceActions is deliberately narrower than the PR executor: no freshness/live-CI/pull_requests:write gates (none of those PR concepts apply to a plain issue), and auto_with_approval is denied rather than staged — the pending-action queue is PR-shaped (pullNumber-typed staging + a /pull/{n} deeplink); extending it to issues is a documented follow-up, not silently bypassed here. closeIssue is a new GitHub REST primitive (PATCH /issues/{n}), since closePullRequest hits the Pulls API, which a plain issue number isn't valid against. * fix(agent-actions): close every over-cap sibling issue, not just the incoming one (#2493) Same delivery-order gap as #2479, mirrored for issues: closing only "the incoming issue, if it's over cap" let an older sibling's stale verdict (computed before a newer sibling existed in the DB) stand forever, since nothing else ever re-evaluates an issue. Now closes every number in the over-cap set discovered by the current delivery. Unlike the PR path, issues have no live-head/CI staleness risk to guard against and no issue-side "regate" job type to reuse, so acting directly on the already-fetched snapshot is safe. Also fixes the codecov/patch gap the gate flagged on this PR: adds coverage for the label ?? "" fallback, the no-slash repoFullName guard, and an author-less (ghost) sibling issue. * test(queue): cover the sibling-wake enqueue-failure path (#2270) env.JOBS.send can reject (queue backpressure/outage) when waking a missed over-cap sibling; pin the fail-safe behavior — swallow the error and skip claiming the coalescing key so a later discovery can retry the enqueue. --- src/github/pr-actions.ts | 17 ++ src/queue/processors.ts | 99 +++++++- src/services/agent-action-executor.ts | 79 +++++- src/settings/agent-actions.ts | 8 +- test/unit/agent-action-executor.test.ts | 127 +++++++++- test/unit/agent-actions.test.ts | 12 +- test/unit/github-pr-actions.test.ts | 21 +- test/unit/queue.test.ts | 320 ++++++++++++++++++++++++ 8 files changed, 672 insertions(+), 11 deletions(-) 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/queue/processors.ts b/src/queue/processors.ts index 24cba88d4e..d1aa7f6357 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -26,6 +26,7 @@ import { listSignalSnapshots, listRepoGithubTotalsSnapshotHistory, listOtherOpenPullRequests, + listOpenIssues, listOpenPullRequests, listPullRequests, listPullRequestFiles, @@ -232,6 +233,7 @@ import { } from "../settings/agent-actions"; import { executeAgentMaintenanceActions, + executeIssueMaintenanceActions, pendingClosureLabelApplied, } from "../services/agent-action-executor"; import { processSubmitDraft } from "../services/draft"; @@ -1869,7 +1871,7 @@ async function runAgentMaintenancePlanAndExecute( // 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 } | undefined; + 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(); @@ -1880,7 +1882,7 @@ async function runAgentMaintenancePlanAndExecute( .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 }; + 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 @@ -3573,6 +3575,77 @@ 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 such staleness risk to guard against — a plain issue has no head SHA + * or CI to go stale, and there's no issue-side "regate" job type to reuse — so acting directly on the + * already-fetched snapshot here is safe. + */ +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 authorOpenIssueNumbers = otherOpenIssues + .filter((other) => (other.authorLogin ?? "").toLowerCase() === authorLoginLower && other.number !== issue.number) + .map((other) => other.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, @@ -4149,6 +4222,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 eda2ad5e7e..41ad099963 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -156,7 +156,9 @@ export type AgentActionPlanInput = { // 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. - contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number } | undefined; + // `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; @@ -330,7 +332,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne // independently of the caller (defense-in-depth, matching the blacklist block's own redundant check above). const capContributor = !input.authorIsOwner && !input.authorIsAdmin && !input.authorIsAutomationBot; if (input.contributorCapMatch?.matched === true && capContributor) { - const { authorLogin, openCount, cap } = input.contributorCapMatch; + 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")) { @@ -338,7 +340,7 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne actionClass: "close", requiresApproval: approval("close"), reason: "over the per-contributor open-item cap", - closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, "pull requests")), + closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind)), closeKind: "contributor_cap", }); } 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 463d0710aa..377b32fa46 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -868,7 +868,7 @@ describe("per-contributor open-item cap short-circuit (#2270)", () => { input({ conclusion: "success", autonomy: { label: "auto", close: "auto", approve: "auto", merge: "auto" }, - contributorCapMatch: { matched: true, authorLogin: "farmer99", openCount: 3, cap: 2 }, + contributorCapMatch: { matched: true, authorLogin: "farmer99", openCount: 3, cap: 2, itemKind: "pull requests" }, ...extra, }); @@ -886,6 +886,14 @@ describe("per-contributor open-item cap short-circuit (#2270)", () => { 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"); @@ -903,7 +911,7 @@ describe("per-contributor open-item cap short-circuit (#2270)", () => { }); 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 } })))).not.toContain("close"); + 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", () => { 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 e61a7e485a..0a3404e0d9 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6107,6 +6107,326 @@ describe("queue processors", () => { 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/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("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" }); + 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/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 } = {}) { From 6b5f7286a4f05181df9bba821279e00360272e15 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:54:45 -0700 Subject: [PATCH 6/7] fix(queue): resolve base conflict and 2 gate findings on contributor-cap enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate findings on this PR: - .gittensory.yml.example still documented contributorOpenIssueCap as inert, but the issue-cap enforcement from #2493 already landed on this branch — the doc was stale. Both caps are now documented as enforced. - maybeCloseIssueOverContributorCap counted siblings from listOpenIssues (a DB read), with no live verification. A stale "open" row for a sibling already closed on GitHub could inflate the count and wrongly close a newly opened issue within the real cap. Live-verify each counted sibling before trusting it, mirroring reconcileLiveDuplicateSiblings' fail-open contract (only drop a sibling on a positive "not open" confirmation). Also resolves a base conflict from a rebase onto main (closeKind's "contributor_cap" variant combined with the closeRequiresCiState tri-state added by #2478, both touching the same close-action types). --- .gittensory.yml.example | 9 ++- src/github/backfill.ts | 17 ++++++ src/queue/processors.ts | 36 ++++++++++-- test/unit/queue.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 10 deletions(-) diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 117c1a6687..55f6c8bb1f 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -265,11 +265,10 @@ settings: # 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. 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 webhook; their oldest items up to the cap stay open and reviewable. - # contributorOpenPrCap IS ENFORCED. contributorOpenIssueCap is currently INERT (stored and parsed, not - # yet enforced) — issue-side enforcement 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. + # 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 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/queue/processors.ts b/src/queue/processors.ts index d1aa7f6357..96b85266d3 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -79,6 +79,7 @@ import { fetchLinkedIssueFacts, fetchLiveCiAggregatePreferGraphQl, type LiveCiAggregate, + fetchLiveIssueState, fetchLivePullRequest, fetchLivePullRequestHeadSha, fetchLivePullRequestMergeState, @@ -3592,9 +3593,14 @@ async function loadOpenQueueCounts( * 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 such staleness risk to guard against — a plain issue has no head SHA - * or CI to go stale, and there's no issue-side "regate" job type to reuse — so acting directly on the - * already-fetched snapshot here is safe. + * 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, second pass): 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 (fail-open to the stored state on an unreadable fetch). */ async function maybeCloseIssueOverContributorCap( env: Env, @@ -3613,9 +3619,29 @@ async function maybeCloseIssueOverContributorCap( const otherOpenIssues = await listOpenIssues(env, repoFullName); const authorLoginLower = authorLogin.toLowerCase(); - const authorOpenIssueNumbers = otherOpenIssues + const otherAuthorIssueNumbers = otherOpenIssues .filter((other) => (other.authorLogin ?? "").toLowerCase() === authorLoginLower && other.number !== issue.number) - .map((other) => other.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-open to the stored "open" state on an + // unreadable live fetch (mirrors reconcileLiveDuplicateSiblings above): only drop a sibling on a POSITIVE + // "not open" confirmation, never on a transient fetch failure. + const token = await createInstallationToken(env, installationId).catch(() => undefined); + const liveToken = token ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, installationId, liveToken); + const staleClosed = new Set(); + await Promise.all( + otherAuthorIssueNumbers.map(async (number) => { + const liveState = await fetchLiveIssueState(env, repoFullName, number, liveToken, admissionKey).catch(() => undefined); + if (liveState !== undefined && liveState !== "open") staleClosed.add(number); + }), + ); + const authorOpenIssueNumbers = otherAuthorIssueNumbers + .filter((number) => !staleClosed.has(number)) .concat(issue.number) .sort((a, b) => a - b); const overCapNumbers = new Set(authorOpenIssueNumbers.slice(cap)); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 0a3404e0d9..6c5f556fae 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6209,6 +6209,126 @@ describe("queue processors", () => { 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("a live-check failure for a counted sibling fails OPEN (stays counted, same as the stored state) rather than silently excluding it", async () => { + // Mirrors reconcileLiveDuplicateSiblings' fail-open contract: an unreadable live fetch must never itself + // manufacture extra headroom under the cap. Sibling #60's live check errors (404); it stays counted, so the + // real over-cap disposition (3 issues against a cap of 2) is unchanged and #62 still 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 }); + 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" }); } + 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-check-fails-open", + 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); + }); + + 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, { From c48ee807e63316643c0a7281172922f27d19baf4 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:07:04 -0700 Subject: [PATCH 7/7] fix(queue): fail SAFE, not fail-open, on an unverifiable issue-cap sibling The live-verify guard added for the first gate finding on this PR still counted an unreadable sibling toward the cap by default (mirroring reconcileLiveDuplicateSiblings' fail-open-to-stored contract). The gate flagged that this compounds with a stale "open" DB row: a transient fetch failure could still let a stale-closed sibling inflate the count and wrongly close a newly opened issue within the real cap. Unlike reconcileLiveDuplicateSiblings (which only re-ranks a non-final duplicate-cluster winner), this count gates an irreversible close, so the safe default flips: only a POSITIVE "open" confirmation counts a sibling toward the cap. An unreadable check now excludes it instead of including it. --- src/queue/processors.ts | 32 ++++++++++++++-------- test/unit/queue.test.ts | 59 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 96b85266d3..094b0f4bf2 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -3596,11 +3596,13 @@ async function loadOpenQueueCounts( * 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, second pass): 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 (fail-open to the stored state on an unreadable fetch). + * 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, @@ -3627,21 +3629,29 @@ async function maybeCloseIssueOverContributorCap( // 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-open to the stored "open" state on an - // unreadable live fetch (mirrors reconcileLiveDuplicateSiblings above): only drop a sibling on a POSITIVE - // "not open" confirmation, never on a transient fetch failure. + // 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 staleClosed = new Set(); + const confirmedOpen = new Set(); await Promise.all( otherAuthorIssueNumbers.map(async (number) => { const liveState = await fetchLiveIssueState(env, repoFullName, number, liveToken, admissionKey).catch(() => undefined); - if (liveState !== undefined && liveState !== "open") staleClosed.add(number); + if (liveState === "open") confirmedOpen.add(number); }), ); const authorOpenIssueNumbers = otherAuthorIssueNumbers - .filter((number) => !staleClosed.has(number)) + .filter((number) => confirmedOpen.has(number)) .concat(issue.number) .sort((a, b) => a - b); const overCapNumbers = new Set(authorOpenIssueNumbers.slice(cap)); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 6c5f556fae..2a6b37c983 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -6183,6 +6183,7 @@ describe("queue processors", () => { 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([]); } @@ -6250,10 +6251,13 @@ describe("queue processors", () => { expect(closeAudit?.n ?? 0).toBe(0); }); - it("a live-check failure for a counted sibling fails OPEN (stays counted, same as the stored state) rather than silently excluding it", async () => { - // Mirrors reconcileLiveDuplicateSiblings' fail-open contract: an unreadable live fetch must never itself - // manufacture extra headroom under the cap. Sibling #60's live check errors (404); it stays counted, so the - // real over-cap disposition (3 issues against a cap of 2) is unchanged and #62 still closes. + 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"] }, @@ -6270,19 +6274,60 @@ describe("queue processors", () => { 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-check-fails-open", + 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 3rd issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, + issue: { number: 62, title: "Farmer's genuinely 3rd issue", state: "open", user: { login: "farmer99" }, labels: [], body: "x" }, }, }); @@ -6488,6 +6533,7 @@ describe("queue processors", () => { 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({}); }); @@ -6526,6 +6572,7 @@ describe("queue processors", () => { 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({}); });