From 1ab582fba65b91abaa10ca78b88f29eb770254d5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:40:44 -0700 Subject: [PATCH] fix(review): enforce close autonomy on draft-dodge and reopen-reclose paths closeDraftDodgeAttemptIfBlocked and recloseDisallowedReopenIfNeeded bypass the unified maintenance planner by design (procedural-integrity enforcement, not a merit verdict), but unlike their 3 correct siblings (closeReviewEvasionSelfCloseIfActive/DraftConversionIfActive/ RepeatedDraftCyclingIfDetected) they never checked the close-specific autonomy class -- draft-dodge checked no actionClass at all (so any acting autonomy class satisfied it), and reopen-reclose only checked isAgentConfigured (true for any acting class). A repo that opts into some other autonomy class while deliberately leaving close unconfigured (deny-by-default) could still get PRs auto-closed via either path. Both now resolve the close autonomy class directly and deny (with an audit event) when it is not "auto", mirroring the 3 already-correct siblings. Extraction of the shared enforceDeterministicClose helper across all 5 close paths (this issue's second acceptance criterion) is deferred to an immediate fast-follow PR to keep this security fix minimal and reviewable in isolation. Fixes #4602 --- src/queue/processors.ts | 62 +++++++++++++++-- test/unit/queue.test.ts | 148 +++++++++++++++++++++++++++++++++++----- 2 files changed, 188 insertions(+), 22 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 464b580757..60c9275648 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -13032,6 +13032,36 @@ async function closeDraftDodgeAttemptIfBlocked( agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun, }); + // Close-autonomy gate (#4602): this enforcement path bypasses executeAgentMaintenanceActions entirely + // (like every check above), so it never got the standard pipeline's per-action-class autonomy check -- + // the outer dispatch condition only requires isAgentConfigured (true for ANY acting class), not + // specifically close. Without this, a repo that opts into some OTHER autonomy class (e.g. assign: "auto") + // while deliberately leaving close unconfigured (deny-by-default) could still have PRs auto-closed here. + // Mirrors the review-evasion siblings' identical gate; checked before the live/dry-run branch so a + // dry-run never claims "would close" when the repo isn't even configured to close for real. + const draftDodgeCloseAutonomy = resolveAutonomy(settings.autonomy, "close"); + if (draftDodgeCloseAutonomy !== "auto") { + await recordAuditEvent(env, { + eventType: "github_app.draft_dodge_closed", + actor: "gittensory", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + detail: + draftDodgeCloseAutonomy === "auto_with_approval" + ? `close autonomy requires approval -- draft-dodge close not enforced for ${pr.authorLogin ?? "unknown"}` + : `autonomy for close is not acting -- draft-dodge close not enforced for ${pr.authorLogin ?? "unknown"}`, + metadata: { + deliveryId, + repoFullName, + headSha: pr.headSha, + blockerCodes: block.blockerCodes, + }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler */ + () => undefined, + ); + return; + } if (draftMode === "live") { // Write-permission readiness (#2134): this close bypasses executeAgentMaintenanceActions entirely // (the whole point is to enforce the gate verdict against the CURRENT headSha even though the PR @@ -13058,6 +13088,7 @@ async function closeDraftDodgeAttemptIfBlocked( const draftDodgePermissionReadiness = resolveAgentPermissionReadiness({ autonomy: settings.autonomy, installationPermissions: draftDodgeInstallationPermissions, + actionClass: "close", }); if (draftDodgePermissionReadiness !== "ready") { /* v8 ignore next -- a deleted-account PR yields a null author login; the fallback is defensive */ @@ -13253,12 +13284,31 @@ async function recloseDisallowedReopenIfNeeded( // NOT touch GitHub, and dry-run records the would-be re-close without acting — so a dry-run is truly inert and // the global kill-switch is a COMPLETE stop. This close path previously bypassed pause/freeze/dry-run entirely. const reopenSettings = await resolveRepositorySettings(env, repoFullName); - // Honor the autonomy floor like every other write path (sweepRepoRegate / the live-action handler / the - // draft-dodge sibling all gate on isAgentConfigured): on an OBSERVE-only / un-opted-in repo (autonomy {} = - // deny-by-default) the agent must take NO action, so do not re-close. resolveAgentActionMode is orthogonal to - // autonomy (it only reflects pause/freeze/dry-run) and returns "live" for an unconfigured repo, so without this - // the re-close would genuinely reach GitHub on a repo that never authorized any action (#review-audit). - if (!isAgentConfigured(reopenSettings.autonomy)) return false; + // Close-autonomy gate (#4602): isAgentConfigured alone is too loose here -- it is true whenever ANY autonomy + // class is acting (e.g. merge: "auto"), not specifically close, so a repo that opts into some OTHER + // autonomy class while deliberately leaving close unconfigured (deny-by-default) could still have a + // disallowed reopen re-closed here. Check the close action class directly, mirroring the review-evasion + // siblings' identical gate. resolveAgentActionMode below is orthogonal to autonomy (it only reflects + // pause/freeze/dry-run) and returns "live" for an unconfigured repo, so without this the re-close would + // genuinely reach GitHub on a repo that never authorized the close action specifically (#review-audit). + const closeAutonomy = resolveAutonomy(reopenSettings.autonomy, "close"); + if (closeAutonomy !== "auto") { + await recordAuditEvent(env, { + eventType: "github_app.reopen_reclosed", + actor: "gittensory", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + detail: + closeAutonomy === "auto_with_approval" + ? `close autonomy requires approval -- reopen re-close not enforced for ${reopener}` + : `autonomy for close is not acting -- reopen re-close not enforced for ${reopener}`, + metadata: { deliveryId, repoFullName }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler */ + () => undefined, + ); + return false; + } const reopenMode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isDbFrozenForRepo(env, reopenSettings.agentGlobalFreezeOverride)), agentPaused: reopenSettings.agentPaused, diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index d1608f3c86..8e10ddb8d7 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -28230,7 +28230,7 @@ describe("one-shot reopen prevention", () => { }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); // opted into acting autonomy + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); // opted into acting autonomy await processJob(env, { type: "github-webhook", @@ -28264,7 +28264,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); // A maintainer legitimately reopened/re-approved the PR — or a queue retry replayed a stale payload — in // the window between the original webhook delivery and this handler's permission/closer-history reads. The // live re-check must catch it and deny the re-close rather than overwriting a live maintainer decision. @@ -28302,7 +28302,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", deliveryId: "reopen-promoted", eventName: "pull_request", payload: reopenedPayload("contributor") }); @@ -28340,7 +28340,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", deliveryId: "reopen-superseded", eventName: "pull_request", payload: reopenedPayload("contributor") }); @@ -28369,7 +28369,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", deliveryId: "reopen-same-latest", eventName: "pull_request", payload: reopenedPayload("contributor") }); @@ -28404,7 +28404,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", deliveryId: "reopen-window-stuffed", eventName: "pull_request", payload: reopenedPayload("contributor") }); @@ -28435,7 +28435,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", deliveryId: "reopen-timeline-error", eventName: "pull_request", payload: reopenedPayload("contributor") }); @@ -28463,7 +28463,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", deliveryId: "reopen-no-reopen-event", eventName: "pull_request", payload: reopenedPayload("contributor") }); @@ -28494,7 +28494,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); vi.spyOn(repositoriesModule, "recordAuditEvent").mockRejectedValueOnce(new Error("D1 write error")); await expect( @@ -28514,7 +28514,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "head_changed", expectedHeadSha: "abc123", liveHeadSha: "def456", liveState: "open" }); vi.spyOn(repositoriesModule, "recordAuditEvent").mockRejectedValueOnce(new Error("D1 write error")); @@ -28537,7 +28537,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); vi.spyOn(repositoriesModule, "recordAuditEvent").mockRejectedValueOnce(new Error("D1 write error")); await expect( @@ -28564,7 +28564,7 @@ describe("one-shot reopen prevention", () => { }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", @@ -28658,7 +28658,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); // opted into acting autonomy + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); // opted into acting autonomy await repositoriesModule.setGlobalAgentFrozen(env, true); // emergency brake on await processJob(env, { type: "github-webhook", deliveryId: "reopen-frozen", eventName: "pull_request", payload: reopenedPayload("contributor") }); expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); // never closed @@ -28680,7 +28680,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", agentDryRun: true, autonomy: { merge: "auto", request_changes: "auto" } }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", agentDryRun: true, autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); await processJob(env, { type: "github-webhook", deliveryId: "reopen-dryrun", eventName: "pull_request", payload: reopenedPayload("contributor") }); expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); // never closed const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.reopen_reclosed").first<{ outcome: string; detail: string }>(); @@ -28744,7 +28744,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); // opted into acting autonomy + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); // opted into acting autonomy await processJob(env, { type: "github-webhook", deliveryId: "window-evasion-reclose", eventName: "pull_request", payload: reopenedPayload("contributor") }); expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); const audit = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("github_app.reopen_reclosed").first<{ detail: string }>(); @@ -28765,7 +28765,7 @@ describe("one-shot reopen prevention", () => { return new Response("not found", { status: 404 }); }); const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); - await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); // opted into acting autonomy + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto", close: "auto" } }); // opted into acting autonomy await processJob(env, { type: "github-webhook", deliveryId: "bot-closer-reclose", eventName: "pull_request", payload: reopenedPayload("contributor") }); expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); }); @@ -28818,6 +28818,71 @@ describe("one-shot reopen prevention", () => { processJob(env, { type: "github-webhook", deliveryId: "reopen-api-fail-safe", eventName: "pull_request", payload: reopenedPayload("contributor") }), ).resolves.toBeUndefined(); }); + + it("REGRESSION (#4602): does NOT re-close a disallowed reopen when close autonomy is unconfigured, even though another class (merge) is auto", async () => { + // Before #4602, this guard gated only on isAgentConfigured(autonomy) -- true here because `merge` is + // acting -- with no check on the `close` action class specifically. A repo that opts into merge/review + // autonomy but deliberately leaves close unconfigured (deny-by-default) must NOT have PRs re-closed here. + const calls: Array<{ url: string; method: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/collaborators/contributor/permission")) return Response.json({ permission: "read" }); + if (url.endsWith("/collaborators/maintainer/permission")) return Response.json({ permission: "write" }); + if (url.includes("/issues/42/events")) return Response.json([{ event: "closed", actor: { login: "maintainer" } }, { event: "reopened", actor: { login: "contributor" } }]); + if (url.endsWith("/issues/42/comments")) return Response.json({ id: 99 }, { status: 201 }); + if (url.endsWith("/pulls/42") && method === "PATCH") return Response.json({ state: "closed" }); + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { merge: "auto", request_changes: "auto" } }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "reopen-close-autonomy-unconfigured", + eventName: "pull_request", + payload: reopenedPayload("contributor"), + }); + + expect(calls.some((call) => call.method === "PATCH" && call.url.endsWith("/pulls/42"))).toBe(false); + expect(calls.some((call) => call.method === "POST" && call.url.endsWith("/issues/42/comments"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.reopen_reclosed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("autonomy for close is not acting"); + expect(audit?.detail).toContain("reopen re-close not enforced for contributor"); + }); + + it("REGRESSION (#4602): denies with an approval-required message when close autonomy is auto_with_approval", async () => { + const calls: Array<{ url: string; method: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/collaborators/contributor/permission")) return Response.json({ permission: "read" }); + if (url.endsWith("/collaborators/maintainer/permission")) return Response.json({ permission: "write" }); + if (url.includes("/issues/42/events")) return Response.json([{ event: "closed", actor: { login: "maintainer" } }, { event: "reopened", actor: { login: "contributor" } }]); + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await repositoriesModule.upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autonomy: { close: "auto_with_approval" } }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "reopen-close-autonomy-approval", + eventName: "pull_request", + payload: reopenedPayload("contributor"), + }); + + expect(calls.some((call) => call.method === "PATCH" && call.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.reopen_reclosed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("close autonomy requires approval"); + }); }); describe("converted_to_draft gate-close (draft-dodge prevention)", () => { @@ -29483,6 +29548,57 @@ describe("converted_to_draft gate-close (draft-dodge prevention)", () => { const verifyBlock = await repositoriesModule.getGateBlockOutcome(env, "noslash", 77); expect(verifyBlock?.headSha).toBe("sha-noslash"); }); + + it("REGRESSION (#4602): does NOT draft-dodge close when close autonomy is unconfigured, even though another PR-write class (approve) is auto and pull_requests:write IS granted", async () => { + // Before #4602, resolveAgentPermissionReadiness's missing actionClass:"close" checked the UNION of every + // acting class's write-permission grant, not close's specifically -- `approve` is a PR-write class and + // pull_requests:write IS granted here (setupRepo's default), so readiness alone used to read "ready" and + // let the close proceed despite close itself never being authorized. + const calls: Array<{ url: string; method: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.endsWith("/issues/42/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 }); + if (url.endsWith("/pulls/42") && method === "PATCH") return Response.json({ state: "closed" }); + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupRepo(env, { autonomy: { approve: "auto" } }); + await recordGateBlockOutcome(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", blockerCodes: ["missing_linked_issue"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-dodge-close-autonomy-unconfigured", eventName: "pull_request", payload: draftPayload("contributor") }); + + expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/issues/42/comments"))).toBe(false); + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.draft_dodge_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("autonomy for close is not acting"); + expect(audit?.detail).toContain("draft-dodge close not enforced for contributor"); + }); + + it("REGRESSION (#4602): denies with an approval-required message when close autonomy is auto_with_approval", async () => { + const calls: Array<{ url: string; method: string }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + calls.push({ url, method: init?.method ?? "GET" }); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + return new Response("not found", { status: 404 }); + }); + + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupRepo(env, { autonomy: { close: "auto_with_approval" } }); + await recordGateBlockOutcome(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", blockerCodes: ["missing_linked_issue"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-dodge-close-autonomy-approval", eventName: "pull_request", payload: draftPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.draft_dodge_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("close autonomy requires approval"); + }); }); function draftEvasionPayload(author: string, headSha = "abc123"): any {