diff --git a/src/github/pr-freshness.ts b/src/github/pr-freshness.ts index bcf7f59fa7..ce17598fab 100644 --- a/src/github/pr-freshness.ts +++ b/src/github/pr-freshness.ts @@ -11,7 +11,7 @@ export type PullRequestFreshness = } | { status: "stale"; - reason: "unavailable" | "closed" | "head_unresolved" | "head_changed"; + reason: "unavailable" | "closed" | "head_unresolved" | "head_changed" | "no_longer_draft"; expectedHeadSha: string | null; liveHeadSha: string | null; liveState: string | null; @@ -30,8 +30,9 @@ export function reviewedPullRequestHeadSha( } export function classifyPullRequestFreshness( - live: Pick | null | undefined, + live: Pick | null | undefined, expectedHeadSha: string | null | undefined, + options?: { requireDraft?: boolean }, ): PullRequestFreshness { const expected = normalizedHead(expectedHeadSha); if (!live) { @@ -51,6 +52,12 @@ export function classifyPullRequestFreshness( if (expected && liveHeadSha !== expected) { return { status: "stale", reason: "head_changed", expectedHeadSha: expected, liveHeadSha, liveState }; } + // The draft-dodge close is only justified while the PR is STILL a draft -- a same-head, still-open PR + // that was converted back to ready_for_review before the close fires has cleared its own justification + // (#2130 follow-up: head/state alone can't see this transition). + if (options?.requireDraft && live.draft !== true) { + return { status: "stale", reason: "no_longer_draft", expectedHeadSha: expected, liveHeadSha, liveState }; + } return { status: "current", liveHeadSha, liveState }; } @@ -61,15 +68,19 @@ export async function fetchPullRequestFreshness( repoFullName: string; pullNumber: number; expectedHeadSha?: string | null | undefined; + // Require the LIVE PR to still be a draft (the draft-dodge close's own justification). Absent/false + // preserves every other caller's existing head/state-only behavior exactly. + requireDraft?: boolean; }, ): Promise { + const options = args.requireDraft !== undefined ? { requireDraft: args.requireDraft } : {}; const token = (await createInstallationToken(env, args.installationId).catch(() => undefined)) ?? env.GITHUB_PUBLIC_TOKEN; - if (!token) return classifyPullRequestFreshness(undefined, args.expectedHeadSha); + if (!token) return classifyPullRequestFreshness(undefined, args.expectedHeadSha, options); const admissionKey = githubRateLimitAdmissionKeyForToken(env, token, args.installationId); const live = await fetchLivePullRequest(env, args.repoFullName, args.pullNumber, token, admissionKey); - return classifyPullRequestFreshness(live, args.expectedHeadSha); + return classifyPullRequestFreshness(live, args.expectedHeadSha, options); } export function pullRequestFreshnessDetail(result: PullRequestFreshness): string { @@ -77,5 +88,6 @@ export function pullRequestFreshnessDetail(result: PullRequestFreshness): string if (result.reason === "unavailable") return "live PR state could not be verified"; if (result.reason === "closed") return `PR is no longer open (live state: ${result.liveState ?? "unknown"})`; if (result.reason === "head_unresolved") return "live PR head SHA could not be verified"; + if (result.reason === "no_longer_draft") return "PR is no longer a draft"; return `PR head changed from ${result.expectedHeadSha ?? "unknown"} to ${result.liveHeadSha ?? "unknown"}`; } diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 83beac8d8e..269da375c1 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -3502,33 +3502,64 @@ async function processGitHubWebhook( agentDryRun: settings.agentDryRun, }); if (draftMode === "live") { - const codes = block.blockerCodes.join(", "); - await createIssueComment( - env, + // Live re-check (#2130): the two async DB reads above (getGateBlockOutcome, resolveAgentActionMode's + // isGlobalAgentFrozen) leave a window where a maintainer could merge/close the PR, or a fresh push + // could clear the gate failure, before this fires. Unlike the main gate-close path — which routes + // every close through executeAgentMaintenanceActions's freshness guard — this handler acted purely + // off the stale webhook-ingestion payload. Re-verify live state immediately before the mutation. + // requireDraft: head/state alone would still read "current" if the author converted the PR BACK + // to ready_for_review in that window -- the draft-dodge close's own justification no longer + // holds, since there is no longer a draft to be "dodging" the gate through. + const freshness = await fetchPullRequestFreshness(env, { installationId, repoFullName, - pr.number, - `Gate verdict stands for this commit — converting to draft does not reset the review. Re-submit a new PR with the issues addressed${codes ? ` (${codes})` : ""}.`, - ).catch(() => undefined); - await closePullRequest( - env, - installationId, - repoFullName, - pr.number, - ).catch(() => undefined); - await recordAuditEvent(env, { - eventType: "github_app.draft_dodge_closed", - actor: "gittensory", - targetKey: `${repoFullName}#${pr.number}`, - outcome: "completed", - detail: `closed draft-dodge attempt by ${pr.authorLogin ?? "unknown"} — prior gate failure on headSha ${pr.headSha} stands`, - metadata: { - deliveryId, + pullNumber: pr.number, + expectedHeadSha: pr.headSha, + requireDraft: true, + }); + if (freshness.status !== "current") { + await recordAuditEvent(env, { + eventType: "github_app.draft_dodge_closed", + actor: "gittensory", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + detail: `${pullRequestFreshnessDetail(freshness)} — draft-dodge close not executed`, + metadata: { + deliveryId, + repoFullName, + headSha: pr.headSha, + blockerCodes: block.blockerCodes, + }, + }).catch(() => undefined); + } else { + const codes = block.blockerCodes.join(", "); + await createIssueComment( + env, + installationId, repoFullName, - headSha: pr.headSha, - blockerCodes: block.blockerCodes, - }, - }).catch(() => undefined); + pr.number, + `Gate verdict stands for this commit — converting to draft does not reset the review. Re-submit a new PR with the issues addressed${codes ? ` (${codes})` : ""}.`, + ).catch(() => undefined); + await closePullRequest( + env, + installationId, + repoFullName, + pr.number, + ).catch(() => undefined); + await recordAuditEvent(env, { + eventType: "github_app.draft_dodge_closed", + actor: "gittensory", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + detail: `closed draft-dodge attempt by ${pr.authorLogin ?? "unknown"} — prior gate failure on headSha ${pr.headSha} stands`, + metadata: { + deliveryId, + repoFullName, + headSha: pr.headSha, + blockerCodes: block.blockerCodes, + }, + }).catch(() => undefined); + } } else if (draftMode === "dry_run") { /* v8 ignore next -- a deleted-account PR yields a null author login; the fallback is defensive */ const draftAuthor = pr.authorLogin ?? "unknown"; @@ -7491,6 +7522,42 @@ async function maybeRecloseDisallowedReopen( ); return true; // handled (decision made); never falls through to act on a stood-down repo } + // Live re-check (#2130): the maintainer-permission lookup, getLastCloserLogin's timeline read, and + // resolveRepositorySettings/isGlobalAgentFrozen above leave a window where the PR's live state could have + // moved — e.g. a maintainer re-closes it themselves, or reopens it a second time with real authorization — + // before this fires. Mirrors the draft-dodge sibling's identical fix; re-verify immediately before the mutation. + const reopenFreshness = await fetchPullRequestFreshness(env, { + installationId, + repoFullName, + pullNumber: pr.number, + expectedHeadSha: pr.headSha, + }); + if (reopenFreshness.status !== "current") { + await recordAuditEvent(env, { + eventType: "github_app.reopen_reclosed", + actor: "gittensory", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + detail: `${pullRequestFreshnessDetail(reopenFreshness)} — reopen re-close not executed`, + metadata: { deliveryId, repoFullName }, + }).catch(() => undefined); + return true; // handled (decision made); a stale re-check still counts as handled, not a fallthrough + } + // Head/state freshness alone can't see a permission grant: the SAME reopener could be promoted to a + // maintainer/admin/write collaborator (or added as one) in the window since the check above ran, which + // would authorize exactly the reopen this handler is about to undo. Re-verify immediately before the + // mutation, not just once at ingestion time. + if (await hasMaintainerPermission(reopener)) { + await recordAuditEvent(env, { + eventType: "github_app.reopen_reclosed", + actor: "gittensory", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + detail: `${reopener} now holds maintainer permission — reopen re-close not executed`, + metadata: { deliveryId, repoFullName }, + }).catch(() => undefined); + return true; // handled (decision made); a newly-authorized reopener still counts as handled + } await createIssueComment( env, installationId, diff --git a/test/unit/pr-freshness.test.ts b/test/unit/pr-freshness.test.ts index 4460701af7..197b4638f5 100644 --- a/test/unit/pr-freshness.test.ts +++ b/test/unit/pr-freshness.test.ts @@ -76,6 +76,41 @@ describe("PR freshness guards", () => { ).toBe("PR head changed from unknown to unknown"); }); + it("does not require draft state by default, even when the PR is no longer a draft", () => { + const result = classifyPullRequestFreshness({ state: "open", head: { sha: "sha1" }, draft: false }, "sha1"); + expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open" }); + }); + + it("REGRESSION (#2130 follow-up): treats a same-head PR converted back to ready_for_review as stale when the caller requires draft", () => { + const result = classifyPullRequestFreshness({ state: "open", head: { sha: "sha1" }, draft: false }, "sha1", { requireDraft: true }); + expect(result).toMatchObject({ status: "stale", reason: "no_longer_draft", liveState: "open", liveHeadSha: "sha1" }); + expect(pullRequestFreshnessDetail(result)).toBe("PR is no longer a draft"); + }); + + it("treats a still-draft PR as current when the caller requires draft", () => { + const result = classifyPullRequestFreshness({ state: "open", head: { sha: "sha1" }, draft: true }, "sha1", { requireDraft: true }); + expect(result).toEqual({ status: "current", liveHeadSha: "sha1", liveState: "open" }); + }); + + it("treats a missing draft field as stale when the caller requires draft (fail-safe: only an explicit true counts)", () => { + const result = classifyPullRequestFreshness({ state: "open", head: { sha: "sha1" } }, "sha1", { requireDraft: true }); + expect(result).toMatchObject({ status: "stale", reason: "no_longer_draft" }); + }); + + it("fetches live PR state including draft, and requires draft when requested", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async () => Response.json({ state: "open", head: { sha: "sha7" }, draft: false })); + await expect( + fetchPullRequestFreshness(env, { + installationId: 123, + repoFullName: "owner/repo", + pullNumber: 7, + expectedHeadSha: "sha7", + requireDraft: true, + }), + ).resolves.toMatchObject({ status: "stale", reason: "no_longer_draft" }); + }); + it("uses the stored PR head before falling back to advisory metadata", () => { expect(reviewedPullRequestHeadSha(" pr-sha ", "advisory-sha")).toBe("pr-sha"); expect(reviewedPullRequestHeadSha(null, " advisory-sha ")).toBe("advisory-sha"); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index e4afc1194a..e84cacc36e 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -10618,6 +10618,111 @@ describe("one-shot reopen prevention", () => { expect(webhookRow?.status).toBe("processed"); }); + it("does NOT re-close a disallowed reopen when live PR state has moved since the webhook was received (#2130, #2261)", 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" } }]); + 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" } }); + // 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. + vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "head_changed", expectedHeadSha: "abc123", liveHeadSha: "def456", liveState: "open" }); + + await processJob(env, { type: "github-webhook", deliveryId: "reopen-stale", eventName: "pull_request", payload: reopenedPayload("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.reopen_reclosed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("reopen re-close not executed"); + }); + + it("REGRESSION: does NOT re-close when the reopener gained maintainer permission before the close fires (#2130 follow-up)", async () => { + // Same head, still open — a head/state-only freshness check would say "current". But the reopener could + // have been promoted to a write/maintain/admin collaborator (or added as one) in the window between the + // initial permission read and this handler's close, which retroactively authorizes exactly the reopen + // this handler is about to undo. + const calls: Array<{ url: string; method: string }> = []; + let contributorPermissionCalls = 0; + 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")) { + contributorPermissionCalls += 1; + // First read (upstream decision to re-close at all): still just a reader. Second read (the live + // re-check right before the mutation): promoted to a write collaborator. + return Response.json({ permission: contributorPermissionCalls === 1 ? "read" : "write" }); + } + 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" } }]); + 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-promoted", eventName: "pull_request", payload: reopenedPayload("contributor") }); + + expect(contributorPermissionCalls).toBe(2); + 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.reopen_reclosed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toContain("now holds maintainer permission"); + }); + + it("swallows a recordAuditEvent failure on the stale-reopen denial path — handler still completes (#2130)", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + 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" } }]); + 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" } }); + 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")); + + await expect( + processJob(env, { type: "github-webhook", deliveryId: "reopen-stale-audit-fail", eventName: "pull_request", payload: reopenedPayload("contributor") }), + ).resolves.toBeUndefined(); + }); + + it("swallows a recordAuditEvent failure on the promoted-reopener denial path — handler still completes (#2130 follow-up)", async () => { + let contributorPermissionCalls = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/collaborators/contributor/permission")) { + contributorPermissionCalls += 1; + return Response.json({ permission: contributorPermissionCalls === 1 ? "read" : "write" }); + } + 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" } }]); + 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" } }); + vi.spyOn(repositoriesModule, "recordAuditEvent").mockRejectedValueOnce(new Error("D1 write error")); + + await expect( + processJob(env, { type: "github-webhook", deliveryId: "reopen-promoted-audit-fail", eventName: "pull_request", payload: reopenedPayload("contributor") }), + ).resolves.toBeUndefined(); + expect(contributorPermissionCalls).toBe(2); + }); + it("does NOT re-close a disallowed reopen on an OBSERVE-only / un-opted-in repo (autonomy floor, #review-audit)", async () => { const calls: Array<{ url: string; method: string }> = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { @@ -10886,6 +10991,76 @@ describe("converted_to_draft gate-close (draft-dodge prevention)", () => { expect(audit?.detail).toContain("contributor"); }); + it("does NOT draft-dodge close when live PR state has moved since the webhook was received (#2130)", 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: "t" }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupRepo(env); + await recordGateBlockOutcome(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", blockerCodes: ["missing_linked_issue"] }); + // A maintainer merged/closed the PR — or a fresh commit resolved the gate failure — in the window between + // webhook ingestion and this handler's async DB reads (getGateBlockOutcome, isGlobalAgentFrozen). The live + // re-check must catch it and deny the close rather than firing blind off the stale ingestion-time payload. + vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "closed", expectedHeadSha: "abc123", liveHeadSha: "abc123", liveState: "closed" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-dodge-stale", 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("draft-dodge close not executed"); + }); + + it("REGRESSION: does NOT draft-dodge close when the PR was converted back to ready_for_review before the close fires (#2130 follow-up)", async () => { + // Same head, still open — a head/state-only freshness check would say "current". But the draft-dodge + // close's whole justification is "the author is dodging the gate via draft state", which no longer holds + // once the PR is ready_for_review again — closing here would be wrong even though nothing else moved. + 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" }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupRepo(env); + await recordGateBlockOutcome(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", blockerCodes: ["missing_linked_issue"] }); + vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "no_longer_draft", expectedHeadSha: "abc123", liveHeadSha: "abc123", liveState: "open" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-dodge-no-longer-draft", 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); + expect(fetchPullRequestFreshness).toHaveBeenCalledWith(env, expect.objectContaining({ requireDraft: true })); + 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("no longer a draft"); + }); + + it("swallows a recordAuditEvent failure on the stale-draft-dodge denial path — handler still completes (#2130)", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + 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); + await recordGateBlockOutcome(env, { repoFullName: "JSONbored/gittensory", pullNumber: 42, headSha: "abc123", blockerCodes: ["missing_linked_issue"] }); + vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "stale", reason: "closed", expectedHeadSha: "abc123", liveHeadSha: "abc123", liveState: "closed" }); + vi.spyOn(repositoriesModule, "recordAuditEvent").mockRejectedValueOnce(new Error("D1 write error")); + + await expect( + processJob(env, { type: "github-webhook", deliveryId: "draft-dodge-stale-audit-fail", eventName: "pull_request", payload: draftPayload("contributor") }), + ).resolves.toBeUndefined(); + }); + it("does NOT draft-dodge close while the global freeze is on (#killswitch-gap)", async () => { const calls: Array<{ url: string; method: string }> = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {