diff --git a/src/github/pr-actions.ts b/src/github/pr-actions.ts index 75ca956ebc..1a0beb32ae 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 f2e3f30c45..6e7697c1f2 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 @@ -3565,6 +3567,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, @@ -4141,6 +4214,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 86fd39259a..b78afbca5a 100644 --- a/src/services/agent-action-executor.ts +++ b/src/services/agent-action-executor.ts @@ -5,7 +5,7 @@ import { createInstallationToken, githubErrorStatus } from "../github/app"; 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"; @@ -197,6 +197,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 62412b3a15..4248b020af 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -151,7 +151,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; @@ -315,7 +317,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")) { @@ -323,7 +325,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 604aec1192..f1ad7c3ff5 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"; @@ -529,6 +539,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 3a29dc3941..300afa06e8 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -844,7 +844,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, }); @@ -862,6 +862,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"); @@ -879,7 +887,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 6664dde0da..4151edba6b 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 f218872020..bb3bba9e3f 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 } = {}) {