From cbb93f3ebc9d77204ddece12403e0de0bf8d44f7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:05:59 -0700 Subject: [PATCH] feat(review): add an opt-in draft-PR close policy to stop bot-review farming Contributors were opening PRs directly as draft (or converting to draft immediately after opening) to get labels, AI review, and slop-gate feedback from the bot without ever being subject to a real one-shot disposition -- distinct from the existing reviewEvasionProtection family, which only enforces after a review has already run against the PR's current head, or on the 2nd+ ready<->draft conversion. Adds draftPrClosePolicy ("off" | "close", off by default) as a new RepositorySettings field (migration + schema + resolver + openapi, mirroring reviewEvasionProtection's exact wiring) and a new maybeCloseDraftPr guard in src/queue/review-evasion.ts, closely modeled on the existing draft-conversion review-evasion guard but firing on ANY draft (opened directly as one, or converted to one) rather than requiring a prior review pass. Deliberately does not record a moderation strike -- this is a blanket repo policy against ordinary GitHub draft usage, not a detected abuse pattern. Enabled for this repo's own dogfood config (.loopover.yml + the bundled fallback manifest). Companion to the CI-side fix (#6448, merged) that stops the heavy job fan-out from running on draft PRs in the first place. --- .loopover.yml | 6 + .loopover.yml.example | 13 ++ apps/loopover-ui/public/openapi.json | 8 ++ config/examples/loopover.full.yml | 13 ++ migrations/0156_draft_pr_close_policy.sql | 10 ++ src/config/loopover-repo-focus-manifest.ts | 6 + src/db/repositories.ts | 9 ++ src/db/schema.ts | 4 + src/openapi/schemas.ts | 6 + src/queue/processors.ts | 20 +++ src/queue/review-evasion.ts | 140 +++++++++++++++++++++ src/types.ts | 13 ++ test/unit/queue-lifecycle-guards.test.ts | 117 +++++++++++++++++ 13 files changed, 365 insertions(+) create mode 100644 migrations/0156_draft_pr_close_policy.sql diff --git a/.loopover.yml b/.loopover.yml index 13634d158b..cb34d411ad 100644 --- a/.loopover.yml +++ b/.loopover.yml @@ -87,6 +87,11 @@ review: # review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is # treated as dodging the one-shot review rather than an ordinary action (layered OVER the dashboard's # own default of "off"). +# +# Draft-PR close policy: this repo's own CI capacity is shared across a steady stream of contributor PRs, +# and draft PRs were being used to farm bot labels/AI-review/CI feedback for free without ever reaching a +# real one-shot disposition -- close ANY draft immediately, including the first one, rather than waiting +# for reviewEvasionProtection's narrower "already reviewed" or "repeated cycling" triggers above. settings: linkedIssueLabelPropagation: enabled: true @@ -105,6 +110,7 @@ settings: removeOtherTypeLabels: false trustMaintainerAuthoredIssueForReward: true reviewEvasionProtection: close + draftPrClosePolicy: close # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let LoopOver open a # PR generating AGENTS.md/CLAUDE.md from this repo's own profile. diff --git a/.loopover.yml.example b/.loopover.yml.example index 766e17a82d..9ab0a969a2 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -1078,6 +1078,19 @@ settings: # # Default: review-evasion. # reviewEvasionComment: true # Post the public explanation comment before the enforcement close. Default: true. + # Draft-PR close policy (#draft-pr-close-policy, anti-abuse): distinct from reviewEvasionProtection above -- + # that family only enforces AFTER a review has already run against the PR's current head, or on the 2nd+ + # ready<->draft conversion. "close" enforces on ANY draft, including the very first one opened directly as a + # draft or converted to draft before any review pass runs, closing it immediately -- stops a contributor + # farming bot labels/AI-review/CI feedback for free while never reaching a real one-shot disposition. OFF BY + # DEFAULT (opt-in, unlike reviewEvasionProtection's default-close): closing every draft PR is a much harsher + # posture than reviewEvasionProtection's narrower abuse-pattern detection and can catch ordinary + # WIP-signaling contributors, so choose this deliberately. Shares reviewEvasionLabel/reviewEvasionComment and + # autoCloseExemptLogins with the family above. Deliberately does NOT record a moderation strike (unlike + # reviewEvasionProtection) -- this is a blanket policy against ordinary GitHub draft usage, not a detected + # abuse pattern. + # draftPrClosePolicy: off # off | close. Default: off. + # Merge-train FIFO gate (#selfhost-merge-train): without this, a PR merges the instant its OWN gate clears, # with zero awareness of an older sibling PR still open in the same repo -- proven live to cause out-of-order # merges and the conflicts that follow. "audit" logs what the gate WOULD hold, without actually holding diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 06aa0ffd19..47d67b242f 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -9689,6 +9689,14 @@ "off", "enabled" ] + }, + "draftPrClosePolicy": { + "type": "string", + "enum": [ + "off", + "close" + ], + "description": "Off by default (opt-in, unlike reviewEvasionProtection's default-close). \"close\" enforces on ANY draft PR, including the very first one, before a review pass has had a chance to run -- distinct from reviewEvasionProtection's family, which only enforces after a review already ran or on the 2nd+ draft conversion." } }, "required": [ diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index b7ddb758d3..a7323bad68 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -1092,6 +1092,19 @@ settings: # # Default: review-evasion. # reviewEvasionComment: true # Post the public explanation comment before the enforcement close. Default: true. + # Draft-PR close policy (#draft-pr-close-policy, anti-abuse): distinct from reviewEvasionProtection above -- + # that family only enforces AFTER a review has already run against the PR's current head, or on the 2nd+ + # ready<->draft conversion. "close" enforces on ANY draft, including the very first one opened directly as a + # draft or converted to draft before any review pass runs, closing it immediately -- stops a contributor + # farming bot labels/AI-review/CI feedback for free while never reaching a real one-shot disposition. OFF BY + # DEFAULT (opt-in, unlike reviewEvasionProtection's default-close): closing every draft PR is a much harsher + # posture than reviewEvasionProtection's narrower abuse-pattern detection and can catch ordinary + # WIP-signaling contributors, so choose this deliberately. Shares reviewEvasionLabel/reviewEvasionComment and + # autoCloseExemptLogins with the family above. Deliberately does NOT record a moderation strike (unlike + # reviewEvasionProtection) -- this is a blanket policy against ordinary GitHub draft usage, not a detected + # abuse pattern. + # draftPrClosePolicy: off # off | close. Default: off. + # Merge-train FIFO gate (#selfhost-merge-train): without this, a PR merges the instant its OWN gate clears, # with zero awareness of an older sibling PR still open in the same repo -- proven live to cause out-of-order # merges and the conflicts that follow. "audit" logs what the gate WOULD hold, without actually holding diff --git a/migrations/0156_draft_pr_close_policy.sql b/migrations/0156_draft_pr_close_policy.sql new file mode 100644 index 0000000000..e06cec50b3 --- /dev/null +++ b/migrations/0156_draft_pr_close_policy.sql @@ -0,0 +1,10 @@ +-- Draft-PR close policy (#draft-pr-close-policy): contributors were opening PRs directly as draft (or +-- converting to draft immediately after opening) to farm bot labels/AI-review/CI feedback without ever +-- being subject to a real one-shot disposition -- distinct from the existing reviewEvasionProtection family +-- (draft-dodge / self-close / draft-conversion / repeated-cycling), which only enforces AFTER a review has +-- already run against the PR's current head, or on the 2nd+ conversion. This policy enforces on ANY draft, +-- including the very first one, before a review pass has had a chance to run at all. Off by default (opt-in, +-- unlike reviewEvasionProtection's default-close) since immediately closing every draft PR is a much harsher +-- posture than reviewEvasionProtection's narrower abuse-pattern detection, and a maintainer should choose it +-- deliberately. +ALTER TABLE repository_settings ADD COLUMN draft_pr_close_policy TEXT NOT NULL DEFAULT 'off'; diff --git a/src/config/loopover-repo-focus-manifest.ts b/src/config/loopover-repo-focus-manifest.ts index f5c296931c..99fa94beed 100644 --- a/src/config/loopover-repo-focus-manifest.ts +++ b/src/config/loopover-repo-focus-manifest.ts @@ -91,6 +91,11 @@ review: # review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is # treated as dodging the one-shot review rather than an ordinary action (layered OVER the dashboard's # own default of "off"). +# +# Draft-PR close policy: this repo's own CI capacity is shared across a steady stream of contributor PRs, +# and draft PRs were being used to farm bot labels/AI-review/CI feedback for free without ever reaching a +# real one-shot disposition -- close ANY draft immediately, including the first one, rather than waiting +# for reviewEvasionProtection's narrower "already reviewed" or "repeated cycling" triggers above. settings: linkedIssueLabelPropagation: enabled: true @@ -109,6 +114,7 @@ settings: removeOtherTypeLabels: false trustMaintainerAuthoredIssueForReward: true reviewEvasionProtection: close + draftPrClosePolicy: close # Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let LoopOver open a # PR generating AGENTS.md/CLAUDE.md from this repo's own profile. diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 4715da2fbb..e3ecf6a140 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -640,6 +640,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise reviewEvasionProtection: "close", // #4011: default-ON -- see normalizeReviewEvasionProtection's doc comment reviewEvasionLabel: DEFAULT_REVIEW_EVASION_LABEL, reviewEvasionComment: true, + draftPrClosePolicy: "off", mergeTrainMode: "off", screenshotTableGate: { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [], requireViewports: [], requireThemes: [] }, }; @@ -720,6 +721,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise reviewEvasionProtection: normalizeReviewEvasionProtection(row.reviewEvasionProtection), reviewEvasionLabel: row.reviewEvasionLabel, reviewEvasionComment: row.reviewEvasionComment, + draftPrClosePolicy: normalizeDraftPrClosePolicy(row.draftPrClosePolicy), mergeTrainMode: normalizeMergeTrainMode(row.mergeTrainMode), screenshotTableGate: parseScreenshotTableGateRow(row), createdAt: row.createdAt, @@ -835,6 +837,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partialdraft cycling (#gaming-tactic-draft-cycle). Only counts a // conversion PERFORMED BY THE PR'S OWN AUTHOR -- a maintainer/third-party converting the PR to draft is an // unrelated action and must never contribute to (or be conflated with) the author's own cycling pattern; diff --git a/src/queue/review-evasion.ts b/src/queue/review-evasion.ts index 1191513e94..3f9e4b2bf5 100644 --- a/src/queue/review-evasion.ts +++ b/src/queue/review-evasion.ts @@ -519,6 +519,11 @@ async function recloseDisallowedReopenIfNeeded( // this family, mirroring github_app.draft_dodge_closed / github_app.reopen_reclosed. const REVIEW_EVASION_CLOSED_EVENT_TYPE = "github_app.review_evasion_closed"; +// Separate eventType from REVIEW_EVASION_CLOSED_EVENT_TYPE above (#draft-pr-close-policy): this is a blanket +// repo POLICY enforced against ordinary, first-time draft usage, not a detected abuse PATTERN like the +// review-evasion family -- keeping it a distinct audit category lets an operator query the two apart. +const DRAFT_PR_CLOSED_EVENT_TYPE = "github_app.draft_pr_closed"; + // Whether `login` holds a maintainer-equivalent permission on repoFullName -- the owner, an ADMIN_GITHUB_LOGINS // entry, or a collaborator with admin/maintain/write access. Shared by both review-evasion guards below; // mirrors recloseDisallowedReopenIfNeeded's identical `hasMaintainerPermission` closure (kept as a standalone @@ -1082,3 +1087,138 @@ async function closeRepeatedDraftCyclingIfDetected( ); } } + +/** Draft-PR close policy (#draft-pr-close-policy): distinct from the four review-evasion guards above, which + * all key off a review having ALREADY run (an active pass, a prior recorded gate failure, or a repeated + * 2nd+ cycle) -- this guard enforces on ANY draft, including the very first one, opened directly as a draft + * or converted to draft before a review has had any chance to run at all. The abuse pattern this closes is a + * contributor farming bot labels/AI-review/CI feedback from a PR that never reaches a real one-shot + * disposition. Off by default (`settings.draftPrClosePolicy !== "close"` bails immediately) -- unlike + * reviewEvasionProtection's default-close, this is opt-in: it can catch ordinary, legitimate WIP-signaling + * contributors who simply open (or convert to) a draft with no abusive intent, so a maintainer chooses it + * deliberately for a specific repo rather than getting it for free. Deliberately does NOT record a + * moderation strike (unlike the review-evasion siblings) -- this is a blanket repo policy applied to + * ordinary GitHub draft usage, not a detected abuse pattern, so it would be unfair to count it toward a + * contributor's ban threshold. Only the PR's OWN author opening/converting their OWN PR to draft is a + * candidate (mirrors the review-evasion siblings' identical actor check) -- a maintainer opening a draft on + * a contributor's behalf, or converting someone else's PR to draft, is an ordinary maintainer action. + * Per-PR actuation-locked like its siblings. */ +export async function maybeCloseDraftPr( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, +): Promise { + if (settings.draftPrClosePolicy !== "close") return; + await withPrActuationLock(env, repoFullName, pr.number, "draft-pr-close-policy", () => + closeDraftPrIfPolicyEnabled(env, deliveryId, installationId, repoFullName, pr, payload, settings), + ); +} + +async function closeDraftPrIfPolicyEnabled( + env: Env, + deliveryId: string, + installationId: number, + repoFullName: string, + pr: PullRequestRecord, + payload: GitHubWebhookPayload, + settings: RepositorySettings, +): Promise { + if (!pr.isDraft) return; + const actorLogin = (payload.sender?.login ?? "").toLowerCase(); + const authorLogin = (pr.authorLogin ?? "").toLowerCase(); + if (!actorLogin || !authorLogin || actorLogin !== authorLogin) return; + if (isProtectedAutomationAuthor(pr.authorLogin)) return; + if (isAutoCloseExempt(pr.authorLogin, settings.autoCloseExemptLogins)) return; + if (!pr.headSha) return; + const headSha = pr.headSha; + if (await hasMaintainerOrOwnerPermission(env, installationId, repoFullName, authorLogin)) return; + + const targetKey = `${repoFullName}#${pr.number}`; + const gateMetadata = { deliveryId, repoFullName, headSha }; + const gate = await evaluateCloseEnforcementGate({ + env, + installationId, + repoFullName, + pr, + settings, + eventType: DRAFT_PR_CLOSED_EVENT_TYPE, + targetKey, + actionLabel: "draft-PR close policy", + actor: String(pr.authorLogin), + metadata: gateMetadata, + dryRun: { + detail: `dry-run: would close draft PR opened/converted by ${pr.authorLogin}`, + metadata: { ...gateMetadata, mode: "dry_run" }, + }, + paused: { + detail: `agent actions paused -- draft-PR close policy not enforced for ${pr.authorLogin}`, + metadata: gateMetadata, + }, + permissionReadiness: { + detail: `denied draft-PR close for ${pr.authorLogin} -- pull_requests: write not granted`, + metadata: gateMetadata, + }, + freshness: { + // requireDraft: the justification evaporates if the author marked the PR ready again in the window + // between ingestion and this check, mirroring the review-evasion siblings' identical fix (#2130). + requireDraft: true, + detailSuffix: " -- draft-PR close not executed", + metadata: gateMetadata, + }, + }); + if (!gate.proceed) return; + + const closeError = await closePullRequest(env, installationId, repoFullName, pr.number) + .then(() => null) + .catch((error: unknown) => error); + if (closeError !== null) { + await recordAuditEvent(env, { + eventType: DRAFT_PR_CLOSED_EVENT_TYPE, + actor: "loopover", + targetKey, + outcome: "error", + detail: `FAILED to close draft PR by ${pr.authorLogin} -- the close API call did not succeed; the PR may still be open`, + metadata: { ...gateMetadata, error: errorMessage(closeError) }, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + return; + } + + const shouldPostComment = settings.reviewEvasionComment ?? true; + if (shouldPostComment) { + await createIssueComment( + env, + installationId, + repoFullName, + pr.number, + "This repository closes pull requests automatically while they're in draft, to keep CI capacity and review bandwidth available for work that's ready to review. Reopen (or open a fresh pull request) once your changes are ready — LoopOver will pick it up from there.", + ).catch( + /* v8 ignore next -- fail-safe: a courtesy-comment failure never blocks the handler. */ + () => undefined, + ); + } + const label = resolveNullableLabel(settings.reviewEvasionLabel, DEFAULT_REVIEW_EVASION_LABEL); + if (label !== null) { + /* v8 ignore next -- fail-safe: a label-application failure never blocks the handler (the enforcement close already happened). */ + await ensurePullRequestLabel(env, installationId, repoFullName, pr.number, label, { createMissingLabel: true }).catch(() => undefined); + } + await recordAuditEvent(env, { + eventType: DRAFT_PR_CLOSED_EVENT_TYPE, + actor: "loopover", + targetKey, + outcome: "completed", + detail: `closed draft PR by ${pr.authorLogin} -- draftPrClosePolicy is "close"`, + metadata: gateMetadata, + }).catch( + /* v8 ignore next -- fail-safe: an audit write failure never blocks the handler. */ + () => undefined, + ); + /* v8 ignore next -- best-effort: the guarded CAS update never rejects against a healthy D1, and a cleanup failure here must never block the webhook. */ + await terminalizeActiveReviewTracking(env, repoFullName, pr.number, { onlyIfHeadSha: pr.headSha }).catch(() => undefined); +} diff --git a/src/types.ts b/src/types.ts index 4bce741f31..6f200cace2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1251,6 +1251,19 @@ export type RepositorySettings = { * repo re-enabling `"close"` (or removing an `"off"` override, which now also resolves to `"close"`) can * immediately treat a historical off-period cycle as "repeated" on the very next legitimate conversion. */ reviewEvasionProtection?: "off" | "close" | undefined; + /** Draft-PR close policy (#draft-pr-close-policy): distinct from {@link reviewEvasionProtection} above -- + * that family only enforces AFTER a review has already run against the PR's current head, or on the 2nd+ + * ready↔draft conversion. `"close"` enforces on ANY draft, including the very first one opened + * directly as a draft or converted to draft before any review pass runs, closing it immediately to stop a + * contributor from farming bot labels/AI-review/CI feedback for free while never reaching a real one-shot + * disposition. `"off"` (the default) is unchanged behavior -- unlike `reviewEvasionProtection`, this is + * opt-in, not default-on, since immediately closing every draft PR is a much harsher posture than + * reviewEvasionProtection's narrower abuse-pattern detection and can catch ordinary WIP-signaling + * contributors, so a maintainer should choose it deliberately. Shares `autoCloseExemptLogins` and + * `reviewEvasionLabel`/`reviewEvasionComment` with the `reviewEvasionProtection` family (same anti-abuse + * label/comment conventions, no need for separate config). See `queue/review-evasion.ts`'s + * `maybeCloseDraftPr`. */ + draftPrClosePolicy?: "off" | "close" | undefined; /** Merge-train FIFO gate (#selfhost-merge-train): without this, a PR merges the instant its OWN gate * clears, with zero awareness of an older sibling PR still open in the same repo -- proven live to cause * out-of-order merges and the conflicts that follow. `"off"` (the default) is unchanged behavior. diff --git a/test/unit/queue-lifecycle-guards.test.ts b/test/unit/queue-lifecycle-guards.test.ts index 57868219c1..bbb4fe37d8 100644 --- a/test/unit/queue-lifecycle-guards.test.ts +++ b/test/unit/queue-lifecycle-guards.test.ts @@ -3472,6 +3472,123 @@ describe("review-evasion protection (#review-evasion-protection)", () => { expect(await repositoriesModule.bumpPullRequestDraftConversionCount(env, "JSONbored/gittensory", 999999)).toBe(0); }); }); + + describe("draft-PR close policy (#draft-pr-close-policy)", () => { + function draftOpenedPayload(author: string, headSha = "abc123"): any { + return { + action: "opened", + installation: { id: 123 }, + repository: { id: 1, name: "gittensory", full_name: "JSONbored/gittensory", private: false, default_branch: "main", owner: { login: "JSONbored" } }, + sender: { login: author, type: "User" }, + pull_request: { + id: 4242, + number: 42, + state: "open", + title: "Some PR", + body: "Body.", + user: { login: author }, + head: { sha: headSha, ref: "fix", repo: { full_name: `${author}/gittensory`, owner: { login: author } } }, + base: { sha: "base123", ref: "main", repo: { full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } } }, + draft: true, + merged: false, + mergeable_state: "clean", + created_at: "2026-05-27T00:00:00Z", + updated_at: "2026-05-27T00:00:00Z", + }, + }; + } + + it("does nothing when draftPrClosePolicy is off (the default) -- opening directly as a draft is unaffected", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off" }); // draftPrClosePolicy defaults to "off" + + await processJob(env, { type: "github-webhook", deliveryId: "draft-policy-off-opened", eventName: "pull_request", payload: draftOpenedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("closes immediately when a PR is opened directly as a draft -- no prior review needed, unlike reviewEvasionProtection", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off", draftPrClosePolicy: "close" }); + // Deliberately no startActiveReviewTracking / recordGateBlockOutcome call -- unlike every reviewEvasionProtection + // sibling, this policy fires on the very first draft, before any review has ever run. + + await processJob(env, { type: "github-webhook", deliveryId: "draft-policy-opened", eventName: "pull_request", payload: draftOpenedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); + expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/issues/42/comments"))).toBe(true); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.draft_pr_closed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("completed"); + expect(audit?.detail).toContain("contributor"); + }); + + it("closes immediately when the author converts their own PR to draft", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off", draftPrClosePolicy: "close" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-policy-converted", eventName: "pull_request", payload: draftEvasionPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.draft_pr_closed").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + }); + + it("does NOT record a moderation strike -- this is a blanket policy against ordinary draft usage, not a detected abuse pattern", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off", draftPrClosePolicy: "close" }); + await repositoriesModule.upsertGlobalModerationConfig(env, { enabled: true, rules: ["review_evasion"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-policy-no-strike", eventName: "pull_request", payload: draftOpenedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(true); + const strike = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("moderation.violation.review_evasion").first<{ n: number }>(); + expect(strike?.n).toBe(0); + }); + + it("honors settings.autoCloseExemptLogins -- the shared allowlist the review-evasion family already uses", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off", draftPrClosePolicy: "close", autoCloseExemptLogins: ["contributor"] }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-policy-exempt", eventName: "pull_request", payload: draftOpenedPayload("contributor") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when the author holds write collaborator permission", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls, { collaboratorPermission: "write" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off", draftPrClosePolicy: "close" }); + + await processJob(env, { type: "github-webhook", deliveryId: "draft-policy-maintainer", eventName: "pull_request", payload: draftOpenedPayload("write-collaborator") }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + + it("does nothing when a THIRD PARTY converts someone else's PR to draft -- an ordinary maintainer action, not the author's own choice", async () => { + const calls: Array<{ url: string; method: string }> = []; + stubEvasionFetch(calls, { collaboratorPermission: "write" }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), GITHUB_APP_SLUG: "gittensory" }); + await setupEvasionRepo(env, { reviewEvasionProtection: "off", draftPrClosePolicy: "close" }); + + const payload = draftEvasionPayload("contributor"); + payload.sender = { login: "a-maintainer", type: "User" }; + + await processJob(env, { type: "github-webhook", deliveryId: "draft-policy-third-party", eventName: "pull_request", payload }); + + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/pulls/42"))).toBe(false); + }); + }); }); describe("markPullRequestLinkedIssueHardRuleViolated (#linked-issue-hard-rule-persistence)", () => {