Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5866,15 +5866,15 @@ async function handlePullRequestWebhookEvent(
// Draft-dodge guard (#converted-to-draft): a contributor converting an OPEN PR to draft cannot use
// draft state to keep a gate-rejected PR alive. When a prior gate failure exists for the PR's current
// headSha (and the block has not been maintainer-overridden), close the PR immediately — the gate
// verdict stands and does not reset on draft conversion. Skipped when the agent is unconfigured or
// paused (the gate doesn't act on paused repos) and for owner / automation PRs.
// verdict stands and does not reset on draft conversion. Skipped when the agent is unconfigured and for
// owner / automation PRs. Pause/freeze/dry-run are enforced inside evaluateCloseEnforcementGate (same as
// the other 4 close-enforcement guards) so a paused stand-down is audited (#6604), not silently skipped.
if (
payload.action === "converted_to_draft" &&
installationId &&
pr.headSha &&
pr.state === "open" &&
isAgentConfigured(settings.autonomy) &&
!settings.agentPaused &&
!isProtectedAutomationAuthor(pr.authorLogin)
) {
// Deliberately UNCAUGHT here: closeDraftDodgeAttemptIfBlocked catches every operation that should
Expand Down
11 changes: 6 additions & 5 deletions src/queue/review-evasion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ type CloseEnforcementGateResult =
* grant rather than attempting a doomed mutation -- the reopen-reclose guard was the last holdout (#6603).
* The parameter stays nullable rather than required: it is the shape a future non-mutating caller would
* need, and narrowing it is a signature change #6603 deliberately left out of scope.
* `paused: null` records NO audit event on a paused/frozen repo -- draft-dodge's existing, preserved gap
* (every other guard here DOES audit a paused stand-down; draft-dodge simply never has). */
* Every caller also passes a real `paused` object so a paused/frozen stand-down is audited the same way for
* all five close-enforcement guards (#6604). */
async function evaluateCloseEnforcementGate(args: {
env: Env;
installationId: number;
Expand Down Expand Up @@ -278,9 +278,10 @@ async function closeDraftDodgeAttemptIfBlocked(
detail: `dry-run: would close draft-dodge attempt by ${draftDodgeAuthor} — prior gate failure on headSha ${pr.headSha} stands`,
metadata: { ...gateMetadata, mode: "dry_run" },
},
// Draft-dodge is the one guard of the 5 that records NO audit event on a paused/frozen repo -- a
// pre-existing gap this extraction preserves rather than fixes (a refactor must not change behavior).
paused: null,
paused: {
detail: `agent actions paused -- draft-dodge close not enforced for ${draftDodgeAuthor}`,
metadata: gateMetadata,
},
permissionReadiness: {
detail: `denied draft-dodge close for ${draftDodgeAuthor} — pull_requests: write not granted`,
metadata: gateMetadata,
Expand Down
9 changes: 7 additions & 2 deletions test/unit/queue-lifecycle-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,9 @@ describe("converted_to_draft gate-close (draft-dodge prevention)", () => {
await repositoriesModule.setGlobalAgentFrozen(env, true);
await processJob(env, { type: "github-webhook", deliveryId: "draft-frozen", eventName: "pull_request", payload: draftPayload("contributor") });
expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); // never closed under freeze
expect(await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.draft_dodge_closed").first<{ n: number }>()).toMatchObject({ n: 0 });
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("agent actions paused -- draft-dodge close not enforced for contributor");
});

it("dry-run: audits a would-be draft-dodge close without touching GitHub (#killswitch-gap)", async () => {
Expand Down Expand Up @@ -1542,7 +1544,7 @@ describe("converted_to_draft gate-close (draft-dodge prevention)", () => {
expect(calls.some((c) => c.includes("PATCH") && c.includes("/pulls/42"))).toBe(false);
});

it("no-ops when the agent is paused (agentPaused=true)", async () => {
it("no-ops when the agent is paused (agentPaused=true) and audits the paused stand-down (#6604)", async () => {
const calls: string[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
Expand All @@ -1558,6 +1560,9 @@ describe("converted_to_draft gate-close (draft-dodge prevention)", () => {
await processJob(env, { type: "github-webhook", deliveryId: "draft-paused", eventName: "pull_request", payload: draftPayload("contributor") });

expect(calls.some((c) => c.includes("PATCH") && c.includes("/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("agent actions paused -- draft-dodge close not enforced for contributor");
});

it("no-ops when the agent autonomy is not configured (autonomy=null)", async () => {
Expand Down