diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 8b0295fbbf..9f3b2fabbe 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -2045,6 +2045,9 @@ export function derivePublicCommentMergeFacts(args: { unifiedFiles: Awaited>; repoFullName: string; prLabels: readonly string[]; + // Optional so a self-hoster's PROTECTED_AUTOCLOSE_AUTHORS_EXTRA allowlist extension reaches neverClosed + // (#8645). Callers that already hold `env` MUST pass it; omitted only keeps the built-in bot set. + env?: Env; }): PublicCommentMergeFacts { const mergeStateLabel = args.liveMergeState ?? args.mergeableState ?? undefined; // fail-safe to the stored value const ciState: MergeReadiness["ciState"] = @@ -2069,7 +2072,8 @@ export function derivePublicCommentMergeFacts(args: { const repoOwner = args.repoFullName.includes("/") ? args.repoFullName.slice(0, args.repoFullName.indexOf("/")) : ""; const authorLogin = args.authorLogin ?? ""; const neverClosed = - (authorLogin.length > 0 && authorLogin.toLowerCase() === repoOwner.toLowerCase()) || isProtectedAutomationAuthor(args.authorLogin); + (authorLogin.length > 0 && authorLogin.toLowerCase() === repoOwner.toLowerCase()) || + isProtectedAutomationAuthor(args.authorLogin, args.env); return { ciState, mergeStateLabel, mergeReadiness, heldForReview, neverClosed }; } @@ -2721,7 +2725,7 @@ async function maybeCloseForContributorCapOnOpen( const authorIsOwner = pr.authorLogin.toLowerCase() === repoOwner.toLowerCase(); // #4889: per-repo admin mode swaps the global-allowlist grant for the live per-repo permission. const authorIsAdmin = await isPerTenantAdmin(env, installationId, repoFullName, pr.authorLogin); - const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin); + const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin, env); if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return false; // #ignore-authors-parity: a manifest ignore_authors match (e.g. "release-please*") means the bot treats // this author as entirely invisible -- maybePublishPrPublicSurface's own reviewEligibility check (deep @@ -2975,7 +2979,7 @@ async function runAgentMaintenancePlanAndExecute( authorLogin.length > 0 && // #4889: per-repo admin mode swaps the global-allowlist grant for the live per-repo permission. (await isPerTenantAdmin(env, installationId, repoFullName, authorLogin)); - const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin); + const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin, env); // Linked-issue HARD-RULE close (#linked-issue-hard-rules): when the repo enabled any rule, a body that links // MORE closing references than we can safely verify (overflow) is itself a violation; otherwise evaluate the @@ -3550,7 +3554,7 @@ export async function reReviewStoredPullRequest( // this resync exists to recover), fail open into the full review/gate instead of trusting the immutable author. if ( automationBotSkipEnabled && - isTrustedAutomationBotAuthor(pr.authorLogin) && + isTrustedAutomationBotAuthor(pr.authorLogin, env) && live?.head?.sha === storedHeadShaBeforeResync ) return false; @@ -5476,7 +5480,7 @@ async function maybeCloseIssueOverContributorCap( const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); // #4889: per-repo admin mode swaps the global-allowlist grant for the live per-repo permission. const authorIsAdmin = await isPerTenantAdmin(env, args.installationId, repoFullName, authorLogin); - const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); + const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin, env); if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return; // Account-age throttle (#2561): mirror the PR-path cap tightening — a below-threshold author gets half @@ -6231,7 +6235,7 @@ async function handlePullRequestWebhookEvent( // bot PR's branch still gets full review of their own commits. if ( resolveSkipAutomationBotPullRequests(isSkipAutomationBotPullRequestsEnabledGlobally(env), settings.skipAutomationBotAuthors) && - isTrustedAutomationBotWebhookActor(payload.sender, pr.authorLogin) + isTrustedAutomationBotWebhookActor(payload.sender, pr.authorLogin, env) ) { await recordAuditEvent(env, { eventType: "github_app.automation_bot_pr_skipped", @@ -6365,7 +6369,7 @@ async function handlePullRequestWebhookEvent( pr.headSha && pr.state === "open" && isAgentConfigured(settings.autonomy) && - !isProtectedAutomationAuthor(pr.authorLogin) + !isProtectedAutomationAuthor(pr.authorLogin, env) ) { // Deliberately UNCAUGHT here: closeDraftDodgeAttemptIfBlocked catches every operation that should // fail safely, but leaves the write-permission-readiness getInstallation read (#2134) uncaught on @@ -6732,7 +6736,7 @@ async function handleIssueWebhookEvent( const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase(); // #4889: per-repo admin mode swaps the global-allowlist grant for the live per-repo permission. const authorIsAdmin = await isPerTenantAdmin(env, installationId, payload.repository.full_name, authorLogin); - const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin); + const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin, env); const accountAgeThresholdDays = issueSettings.accountAgeThresholdDays; if ( !authorIsOwner && @@ -9727,7 +9731,7 @@ async function maybePublishPrPublicSurface( (author.toLowerCase() === repoOwnerLoginFromFullName(repoFullName).toLowerCase() || // #4889: per-repo admin mode swaps the global-allowlist grant for the live per-repo permission. (await isPerTenantAdmin(env, installationId, repoFullName, author)) || - isProtectedAutomationAuthor(author)); + isProtectedAutomationAuthor(author, env)); const isFrozenForManualReview = webhook.forceAiReview !== true && !authorIsExemptFromFreeze && @@ -11154,6 +11158,7 @@ async function maybePublishPrPublicSurface( unifiedFiles, repoFullName, prLabels: pr.labels, + env, }); // The public comment must match the authoritative Gate check-run conclusion. const commentGate = commentGateEvaluation; diff --git a/src/queue/review-evasion.ts b/src/queue/review-evasion.ts index 7d9017413f..001d73c161 100644 --- a/src/queue/review-evasion.ts +++ b/src/queue/review-evasion.ts @@ -609,7 +609,7 @@ async function closeReviewEvasionSelfCloseIfReviewed( // Only the PR's OWN author closing their OWN PR is a self-close-evasion candidate -- a third party (e.g. a // maintainer) closing someone else's PR is an ordinary maintainer action, not evasion. if (!closer || !authorLogin || closer !== authorLogin) return; - if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (isProtectedAutomationAuthor(pr.authorLogin, env)) return; if (isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) return; if (!pr.headSha) return; const headSha = pr.headSha; // captured so the allowStaleIf closure below keeps the narrowed non-null type @@ -828,7 +828,7 @@ async function closeReviewEvasionDraftConversionIfReviewed( // not evasion, and must never be enforced against the AUTHOR who didn't do it (mirrors the self-close // sibling's identical actor check). if (!converter || !authorLogin || converter !== authorLogin) return; - if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (isProtectedAutomationAuthor(pr.authorLogin, env)) return; if (isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) return; if (!pr.headSha) return; const headSha = pr.headSha; @@ -994,7 +994,7 @@ async function closeRepeatedDraftCyclingIfDetected( const converter = (payload.sender?.login ?? "").toLowerCase(); /* v8 ignore next -- unreachable given the call site's own author-only increment guarantee; see comment above. */ if (!converter || !authorLogin || converter !== authorLogin) return; - if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (isProtectedAutomationAuthor(pr.authorLogin, env)) return; // Honor the maintainer's trusted-contributor allowlist, same as the two sibling review-evasion guards // (closeReviewEvasionSelfCloseIfReviewed / closeReviewEvasionDraftConversionIfReviewed) already do (#6165). if (isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) return; @@ -1155,7 +1155,7 @@ async function closeDraftPrIfPolicyEnabled( const actorLogin = (payload.sender?.login ?? "").toLowerCase(); const authorLogin = (pr.authorLogin ?? "").toLowerCase(); if (!actorLogin || !authorLogin || actorLogin !== authorLogin) return; - if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (isProtectedAutomationAuthor(pr.authorLogin, env)) return; if (isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) return; if (!pr.headSha) return; const headSha = pr.headSha; @@ -1289,7 +1289,7 @@ async function closeSynchronizeAmendmentIfPolicyEnabled( const actorLogin = (payload.sender?.login ?? "").toLowerCase(); const authorLogin = (pr.authorLogin ?? "").toLowerCase(); if (!actorLogin || !authorLogin || actorLogin !== authorLogin) return; - if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (isProtectedAutomationAuthor(pr.authorLogin, env)) return; if (isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) return; if (!pr.headSha) return; const headSha = pr.headSha; diff --git a/src/services/agent-approval-queue.ts b/src/services/agent-approval-queue.ts index 39c26fd941..57612b5f19 100644 --- a/src/services/agent-approval-queue.ts +++ b/src/services/agent-approval-queue.ts @@ -141,7 +141,7 @@ export async function decidePendingAgentAction(env: Env, input: { id: string; de const repoOwner = pending.repoFullName.includes("/") ? pending.repoFullName.slice(0, pending.repoFullName.indexOf("/")) : ""; const authorLogin = pr.authorLogin ?? ""; const authorIsOwner = authorLogin.length > 0 && authorLogin.toLowerCase() === repoOwner.toLowerCase(); - const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin); + const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin, env); const closeEligible = (!authorIsOwner && !authorIsAutomationBot) || (authorIsOwner && settings.closeOwnerAuthors === true); let stillJustified = closeEligible; if (closeEligible) { @@ -358,7 +358,7 @@ export async function decidePendingAgentAction(env: Env, input: { id: string; de const repoOwner = pending.repoFullName.includes("/") ? pending.repoFullName.slice(0, pending.repoFullName.indexOf("/")) : ""; const authorLogin = pr.authorLogin ?? ""; const authorIsOwner = authorLogin.length > 0 && authorLogin.toLowerCase() === repoOwner.toLowerCase(); - const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin); + const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin, env); const closeEligible = (!authorIsOwner && !authorIsAutomationBot) || (authorIsOwner && settings.closeOwnerAuthors === true); if (closeEligible) { const linkedIssueRulesConfig = await loadLinkedIssueHardRules(env, pending.repoFullName); diff --git a/src/settings/automation-bot-skip.ts b/src/settings/automation-bot-skip.ts index 6f5a95646c..3f3fedcd0b 100644 --- a/src/settings/automation-bot-skip.ts +++ b/src/settings/automation-bot-skip.ts @@ -53,11 +53,12 @@ export function resolveSkipAutomationBotPullRequests(globalDefault: boolean, mod export function isTrustedAutomationBotWebhookActor( sender: { login?: string | null | undefined; type?: string | null | undefined } | null | undefined, prAuthorLogin: string | null | undefined, + env?: Env, ): boolean { return ( sender?.type === "Bot" && - isProtectedAutomationAuthor(sender.login) && - isProtectedAutomationAuthor(prAuthorLogin) + isProtectedAutomationAuthor(sender.login, env) && + isProtectedAutomationAuthor(prAuthorLogin, env) ); } @@ -67,6 +68,6 @@ export function isTrustedAutomationBotWebhookActor( * live PR head still matches the stored head) before using this result to bypass review. Original PR authorship * alone does not prove that later commits on the branch were still produced by the trusted bot. */ -export function isTrustedAutomationBotAuthor(prAuthorLogin: string | null | undefined): boolean { - return isProtectedAutomationAuthor(prAuthorLogin); +export function isTrustedAutomationBotAuthor(prAuthorLogin: string | null | undefined, env?: Env): boolean { + return isProtectedAutomationAuthor(prAuthorLogin, env); } diff --git a/test/unit/processors-public-comment-merge-facts.test.ts b/test/unit/processors-public-comment-merge-facts.test.ts index fda8730c35..bf8c933e4e 100644 --- a/test/unit/processors-public-comment-merge-facts.test.ts +++ b/test/unit/processors-public-comment-merge-facts.test.ts @@ -174,6 +174,12 @@ describe("derivePublicCommentMergeFacts() — neverClosed (#8/#9, #4607)", () => expect(facts({ authorLogin: "github-actions[bot]" }).neverClosed).toBe(true); }); + it("is true for a PROTECTED_AUTOCLOSE_AUTHORS_EXTRA bot only when env is passed (#8645)", () => { + const env = { PROTECTED_AUTOCLOSE_AUTHORS_EXTRA: "mergify[bot]" } as Env; + expect(facts({ authorLogin: "mergify[bot]" }).neverClosed).toBe(false); + expect(facts({ authorLogin: "mergify[bot]", env }).neverClosed).toBe(true); + }); + it("is false for an ordinary contributor", () => { expect(facts({ authorLogin: "contributor" }).neverClosed).toBe(false); }); diff --git a/test/unit/protected-automation-author-env-wiring.test.ts b/test/unit/protected-automation-author-env-wiring.test.ts new file mode 100644 index 0000000000..36f9edf7b9 --- /dev/null +++ b/test/unit/protected-automation-author-env-wiring.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { upsertInstallation, upsertPullRequestFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { derivePublicCommentMergeFacts, processJob } from "../../src/queue/processors"; +import { + maybeCloseDraftPr, + maybeCloseRepeatedDraftCycling, + maybeCloseReviewEvasionDraftConversion, + maybeCloseReviewEvasionSelfClose, + maybeCloseSynchronizeAmendment, +} from "../../src/queue/review-evasion"; +import { + isTrustedAutomationBotAuthor, + isTrustedAutomationBotWebhookActor, +} from "../../src/settings/automation-bot-skip"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import type { GitHubWebhookPayload, PullRequestFileRecord, PullRequestRecord, RepositorySettings } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; +import { generatePrivateKeyPem } from "../helpers/github-app-key"; + +// #8645: PROTECTED_AUTOCLOSE_AUTHORS_EXTRA only worked in unit tests of isProtectedAutomationAuthor itself — +// every production caller dropped the env argument. These tests pin the end-to-end wiring at two of the four +// call-site families (contributor-cap early close + review-evasion), using an author that is ONLY protected +// via EXTRA (not in the built-in github-actions/dependabot/renovate set). + +afterEach(() => vi.unstubAllGlobals()); + +const EXTRA_BOT = "mergify[bot]"; + +describe("PROTECTED_AUTOCLOSE_AUTHORS_EXTRA production wiring (#8645)", () => { + it("automation-bot-skip helpers honor EXTRA when env is threaded through", () => { + const env = createTestEnv({ PROTECTED_AUTOCLOSE_AUTHORS_EXTRA: EXTRA_BOT }); + expect(isTrustedAutomationBotAuthor(EXTRA_BOT)).toBe(false); + expect(isTrustedAutomationBotAuthor(EXTRA_BOT, env)).toBe(true); + expect(isTrustedAutomationBotWebhookActor({ login: EXTRA_BOT, type: "Bot" }, EXTRA_BOT)).toBe(false); + expect(isTrustedAutomationBotWebhookActor({ login: EXTRA_BOT, type: "Bot" }, EXTRA_BOT, env)).toBe(true); + }); + + it("derivePublicCommentMergeFacts marks an EXTRA-only bot neverClosed only when env is passed", () => { + const env = createTestEnv({ PROTECTED_AUTOCLOSE_AUTHORS_EXTRA: EXTRA_BOT }); + const base = { + liveMergeState: "clean" as const, + mergeableState: "clean", + authorLogin: EXTRA_BOT, + liveCi: { ciState: "passed" as const, failingDetails: [], nonRequiredFailingDetails: [] }, + settings: { + hardGuardrailGlobs: [], + hardGuardrailGlobsOverridesInvariants: false, + manualReviewLabel: undefined, + } as Pick, + unifiedFiles: [{ path: "README.md" } as PullRequestFileRecord], + repoFullName: "acme/widgets", + prLabels: [] as string[], + }; + expect(derivePublicCommentMergeFacts(base).neverClosed).toBe(false); + expect(derivePublicCommentMergeFacts({ ...base, env }).neverClosed).toBe(true); + }); + + it("all five review-evasion guards: EXTRA-only bot early-returns (zero GitHub calls); without EXTRA the permission path is reached", async () => { + // codecov/patch on #8742 flagged review-evasion.ts at 80% (1 partial) because only self-close was + // exercised. Hit every production call site that gained the env argument so both outcomes of each + // isProtectedAutomationAuthor(..., env) branch are covered. + const evasionSettings = { + reviewEvasionProtection: "on", + autoCloseExemptLogins: [], + draftPrClosePolicy: "close", + synchronizeClosePolicy: "close", + } as unknown as RepositorySettings; + const pr = { + repoFullName: "owner/repo", + number: 7, + title: "t", + state: "open", + isDraft: true, + authorLogin: EXTRA_BOT, + headSha: "abc123", + } as PullRequestRecord; + const payload = { sender: { login: EXTRA_BOT } } as GitHubWebhookPayload; + + const protectedUrls: string[] = []; + vi.stubGlobal("fetch", async (input: string | URL | Request) => { + protectedUrls.push(String(input instanceof Request ? input.url : input)); + return new Response("not found", { status: 404 }); + }); + const protectedEnv = createTestEnv({ + PROTECTED_AUTOCLOSE_AUTHORS_EXTRA: EXTRA_BOT, + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + }); + await maybeCloseReviewEvasionSelfClose(protectedEnv, "d-extra-1", 123, "owner/repo", pr, payload, evasionSettings); + await maybeCloseReviewEvasionDraftConversion(protectedEnv, "d-extra-2", 123, "owner/repo", pr, payload, evasionSettings); + await maybeCloseRepeatedDraftCycling(protectedEnv, "d-extra-3", 123, "owner/repo", pr, payload, evasionSettings, 2); + await maybeCloseDraftPr(protectedEnv, "d-extra-4", 123, "owner/repo", pr, payload, evasionSettings); + await maybeCloseSynchronizeAmendment(protectedEnv, "d-extra-5", 123, "owner/repo", pr, payload, evasionSettings); + expect(protectedUrls).toEqual([]); + + vi.unstubAllGlobals(); + const unprotectedUrls: string[] = []; + vi.stubGlobal("fetch", async (input: string | URL | Request) => { + unprotectedUrls.push(String(input instanceof Request ? input.url : input)); + if (String(input).includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (String(input).includes("/collaborators/")) return Response.json({ permission: "read" }); + return new Response("not found", { status: 404 }); + }); + const unprotectedEnv = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await maybeCloseReviewEvasionSelfClose(unprotectedEnv, "d-no-1", 123, "owner/repo", pr, payload, evasionSettings); + await maybeCloseReviewEvasionDraftConversion(unprotectedEnv, "d-no-2", 123, "owner/repo", pr, payload, evasionSettings); + await maybeCloseRepeatedDraftCycling(unprotectedEnv, "d-no-3", 123, "owner/repo", pr, payload, evasionSettings, 2); + await maybeCloseDraftPr(unprotectedEnv, "d-no-4", 123, "owner/repo", pr, payload, evasionSettings); + await maybeCloseSynchronizeAmendment(unprotectedEnv, "d-no-5", 123, "owner/repo", pr, payload, evasionSettings); + // Without EXTRA the bot check no longer early-returns, so the maintainer-permission lookup runs. + expect(unprotectedUrls.some((url) => url.includes("/collaborators/") || url.includes("/access_tokens"))).toBe(true); + }); + + it("maybeCloseForContributorCapOnOpen: EXTRA-only bot over a configured cap is NOT closed", async () => { + // Disable the automation-bot-skip early-return so the contributor-cap short-circuit is the path under test — + // that is the call site family that previously dropped env even when PROTECTED_AUTOCLOSE_AUTHORS_EXTRA was set. + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + PROTECTED_AUTOCLOSE_AUTHORS_EXTRA: EXTRA_BOT, + LOOPOVER_SKIP_AUTOMATION_BOT_PRS: "false", + }); + 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: "Bot PR one", + state: "open", + user: { login: EXTRA_BOT }, + head: { sha: "b53" }, + labels: [], + body: "x", + }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 54, + title: "Bot PR two", + state: "open", + user: { login: EXTRA_BOT }, + head: { sha: "b54" }, + labels: [], + body: "y", + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + autonomy: { close: "auto", label: "auto" }, + }); + await upsertRepoFocusManifest( + env, + "JSONbored/gittensory", + { + settings: { + commentMode: "all_prs", + publicSurface: "comment_only", + checkRunMode: "off", + contributorCapLabel: "spam-cap", + reviewCheckMode: "required", + aiReviewMode: "advisory", + contributorOpenPrCap: 2, + skipAutomationBotAuthors: "off", + }, + }, + "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 === "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") || url.includes("/pulls/55/commits") || url.includes("/pulls/55/comments")) { + return Response.json([]); + } + if ((url.endsWith("/pulls/53") || url.endsWith("/pulls/54")) && method === "GET") return Response.json({ state: "open" }); + 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: EXTRA_BOT, type: "Bot" }, + head: { sha: "b55" }, + base: { ref: "main", sha: "base55" }, + mergeable_state: "clean", + labels: [], + body: "x", + }); + } + if (url.includes("/commits/b55/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/b55/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/commits/b55/check-suites")) return Response.json({ check_suites: [] }); + if (url.includes("/issues/55/labels")) return Response.json([]); + if (url.includes("/issues/55/comments")) return Response.json([]); + // Paginated list endpoints must return arrays — `{}` throws inside githubPaginatedList. + if (url.includes("/pulls/") || url.includes("/issues/")) return Response.json([]); + return Response.json({}); + }); + + // The early-cap short-circuit is the path under test: when EXTRA wires through, the bot is exempt and + // maybeCloseForContributorCapOnOpen returns without closing. The webhook then continues into the rest of + // the pipeline (which is out of scope here) — assert the close never fired regardless of later work. + await processJob(env, { + type: "github-webhook", + deliveryId: "extra-bot-cap-exempt", + 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" } }, + sender: { login: EXTRA_BOT, type: "Bot" }, + pull_request: { + number: 55, + title: "Bot's 3rd PR", + state: "open", + user: { login: EXTRA_BOT, type: "Bot" }, + head: { sha: "b55" }, + labels: [], + body: "x", + mergeable_state: "clean", + }, + }, + }).catch(() => undefined); + + 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); + }); +});