Skip to content

Commit 4d6d016

Browse files
authored
Merge pull request #6454 from JSONbored/feat/draft-pr-bot-suppression
feat(review): add an opt-in draft-PR close policy to stop bot-review farming
2 parents b09f262 + cbb93f3 commit 4d6d016

13 files changed

Lines changed: 365 additions & 0 deletions

File tree

.loopover.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ review:
8787
# review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is
8888
# treated as dodging the one-shot review rather than an ordinary action (layered OVER the dashboard's
8989
# own default of "off").
90+
#
91+
# Draft-PR close policy: this repo's own CI capacity is shared across a steady stream of contributor PRs,
92+
# and draft PRs were being used to farm bot labels/AI-review/CI feedback for free without ever reaching a
93+
# real one-shot disposition -- close ANY draft immediately, including the first one, rather than waiting
94+
# for reviewEvasionProtection's narrower "already reviewed" or "repeated cycling" triggers above.
9095
settings:
9196
linkedIssueLabelPropagation:
9297
enabled: true
@@ -105,6 +110,7 @@ settings:
105110
removeOtherTypeLabels: false
106111
trustMaintainerAuthoredIssueForReward: true
107112
reviewEvasionProtection: close
113+
draftPrClosePolicy: close
108114

109115
# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let LoopOver open a
110116
# PR generating AGENTS.md/CLAUDE.md from this repo's own profile.

.loopover.yml.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,19 @@ settings:
10781078
# # Default: review-evasion.
10791079
# reviewEvasionComment: true # Post the public explanation comment before the enforcement close. Default: true.
10801080

1081+
# Draft-PR close policy (#draft-pr-close-policy, anti-abuse): distinct from reviewEvasionProtection above --
1082+
# that family only enforces AFTER a review has already run against the PR's current head, or on the 2nd+
1083+
# ready<->draft conversion. "close" enforces on ANY draft, including the very first one opened directly as a
1084+
# draft or converted to draft before any review pass runs, closing it immediately -- stops a contributor
1085+
# farming bot labels/AI-review/CI feedback for free while never reaching a real one-shot disposition. OFF BY
1086+
# DEFAULT (opt-in, unlike reviewEvasionProtection's default-close): closing every draft PR is a much harsher
1087+
# posture than reviewEvasionProtection's narrower abuse-pattern detection and can catch ordinary
1088+
# WIP-signaling contributors, so choose this deliberately. Shares reviewEvasionLabel/reviewEvasionComment and
1089+
# autoCloseExemptLogins with the family above. Deliberately does NOT record a moderation strike (unlike
1090+
# reviewEvasionProtection) -- this is a blanket policy against ordinary GitHub draft usage, not a detected
1091+
# abuse pattern.
1092+
# draftPrClosePolicy: off # off | close. Default: off.
1093+
10811094
# Merge-train FIFO gate (#selfhost-merge-train): without this, a PR merges the instant its OWN gate clears,
10821095
# with zero awareness of an older sibling PR still open in the same repo -- proven live to cause out-of-order
10831096
# merges and the conflicts that follow. "audit" logs what the gate WOULD hold, without actually holding

apps/loopover-ui/public/openapi.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9689,6 +9689,14 @@
96899689
"off",
96909690
"enabled"
96919691
]
9692+
},
9693+
"draftPrClosePolicy": {
9694+
"type": "string",
9695+
"enum": [
9696+
"off",
9697+
"close"
9698+
],
9699+
"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."
96929700
}
96939701
},
96949702
"required": [

config/examples/loopover.full.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,19 @@ settings:
10921092
# # Default: review-evasion.
10931093
# reviewEvasionComment: true # Post the public explanation comment before the enforcement close. Default: true.
10941094

1095+
# Draft-PR close policy (#draft-pr-close-policy, anti-abuse): distinct from reviewEvasionProtection above --
1096+
# that family only enforces AFTER a review has already run against the PR's current head, or on the 2nd+
1097+
# ready<->draft conversion. "close" enforces on ANY draft, including the very first one opened directly as a
1098+
# draft or converted to draft before any review pass runs, closing it immediately -- stops a contributor
1099+
# farming bot labels/AI-review/CI feedback for free while never reaching a real one-shot disposition. OFF BY
1100+
# DEFAULT (opt-in, unlike reviewEvasionProtection's default-close): closing every draft PR is a much harsher
1101+
# posture than reviewEvasionProtection's narrower abuse-pattern detection and can catch ordinary
1102+
# WIP-signaling contributors, so choose this deliberately. Shares reviewEvasionLabel/reviewEvasionComment and
1103+
# autoCloseExemptLogins with the family above. Deliberately does NOT record a moderation strike (unlike
1104+
# reviewEvasionProtection) -- this is a blanket policy against ordinary GitHub draft usage, not a detected
1105+
# abuse pattern.
1106+
# draftPrClosePolicy: off # off | close. Default: off.
1107+
10951108
# Merge-train FIFO gate (#selfhost-merge-train): without this, a PR merges the instant its OWN gate clears,
10961109
# with zero awareness of an older sibling PR still open in the same repo -- proven live to cause out-of-order
10971110
# merges and the conflicts that follow. "audit" logs what the gate WOULD hold, without actually holding
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Draft-PR close policy (#draft-pr-close-policy): contributors were opening PRs directly as draft (or
2+
-- converting to draft immediately after opening) to farm bot labels/AI-review/CI feedback without ever
3+
-- being subject to a real one-shot disposition -- distinct from the existing reviewEvasionProtection family
4+
-- (draft-dodge / self-close / draft-conversion / repeated-cycling), which only enforces AFTER a review has
5+
-- already run against the PR's current head, or on the 2nd+ conversion. This policy enforces on ANY draft,
6+
-- including the very first one, before a review pass has had a chance to run at all. Off by default (opt-in,
7+
-- unlike reviewEvasionProtection's default-close) since immediately closing every draft PR is a much harsher
8+
-- posture than reviewEvasionProtection's narrower abuse-pattern detection, and a maintainer should choose it
9+
-- deliberately.
10+
ALTER TABLE repository_settings ADD COLUMN draft_pr_close_policy TEXT NOT NULL DEFAULT 'off';

src/config/loopover-repo-focus-manifest.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ review:
9191
# review pass running, a prior recorded gate failure, or a repeated ready<->draft cycle on this PR, is
9292
# treated as dodging the one-shot review rather than an ordinary action (layered OVER the dashboard's
9393
# own default of "off").
94+
#
95+
# Draft-PR close policy: this repo's own CI capacity is shared across a steady stream of contributor PRs,
96+
# and draft PRs were being used to farm bot labels/AI-review/CI feedback for free without ever reaching a
97+
# real one-shot disposition -- close ANY draft immediately, including the first one, rather than waiting
98+
# for reviewEvasionProtection's narrower "already reviewed" or "repeated cycling" triggers above.
9499
settings:
95100
linkedIssueLabelPropagation:
96101
enabled: true
@@ -109,6 +114,7 @@ settings:
109114
removeOtherTypeLabels: false
110115
trustMaintainerAuthoredIssueForReward: true
111116
reviewEvasionProtection: close
117+
draftPrClosePolicy: close
112118
113119
# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let LoopOver open a
114120
# PR generating AGENTS.md/CLAUDE.md from this repo's own profile.

src/db/repositories.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
640640
reviewEvasionProtection: "close", // #4011: default-ON -- see normalizeReviewEvasionProtection's doc comment
641641
reviewEvasionLabel: DEFAULT_REVIEW_EVASION_LABEL,
642642
reviewEvasionComment: true,
643+
draftPrClosePolicy: "off",
643644
mergeTrainMode: "off",
644645
screenshotTableGate: { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [], requireViewports: [], requireThemes: [] },
645646
};
@@ -720,6 +721,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
720721
reviewEvasionProtection: normalizeReviewEvasionProtection(row.reviewEvasionProtection),
721722
reviewEvasionLabel: row.reviewEvasionLabel,
722723
reviewEvasionComment: row.reviewEvasionComment,
724+
draftPrClosePolicy: normalizeDraftPrClosePolicy(row.draftPrClosePolicy),
723725
mergeTrainMode: normalizeMergeTrainMode(row.mergeTrainMode),
724726
screenshotTableGate: parseScreenshotTableGateRow(row),
725727
createdAt: row.createdAt,
@@ -835,6 +837,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
835837
reviewEvasionProtection: normalizeReviewEvasionProtection(settings.reviewEvasionProtection),
836838
reviewEvasionLabel: settings.reviewEvasionLabel ?? DEFAULT_REVIEW_EVASION_LABEL,
837839
reviewEvasionComment: settings.reviewEvasionComment ?? true,
840+
draftPrClosePolicy: normalizeDraftPrClosePolicy(settings.draftPrClosePolicy),
838841
mergeTrainMode: normalizeMergeTrainMode(settings.mergeTrainMode),
839842
screenshotTableGate: normalizeScreenshotTableGateConfig(settings.screenshotTableGate, []),
840843
} satisfies RepositorySettings;
@@ -916,6 +919,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
916919
reviewEvasionProtection: resolved.reviewEvasionProtection,
917920
reviewEvasionLabel: resolved.reviewEvasionLabel,
918921
reviewEvasionComment: resolved.reviewEvasionComment,
922+
draftPrClosePolicy: resolved.draftPrClosePolicy,
919923
mergeTrainMode: resolved.mergeTrainMode,
920924
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
921925
screenshotTableGateWhenLabelsJson: jsonString(resolved.screenshotTableGate.whenLabels),
@@ -1005,6 +1009,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
10051009
reviewEvasionProtection: resolved.reviewEvasionProtection,
10061010
reviewEvasionLabel: resolved.reviewEvasionLabel,
10071011
reviewEvasionComment: resolved.reviewEvasionComment,
1012+
draftPrClosePolicy: resolved.draftPrClosePolicy,
10081013
mergeTrainMode: resolved.mergeTrainMode,
10091014
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
10101015
screenshotTableGateWhenLabelsJson: jsonString(resolved.screenshotTableGate.whenLabels),
@@ -7817,6 +7822,10 @@ function normalizeReviewEvasionProtection(value: string | null | undefined): "of
78177822
return value === "off" ? "off" : "close";
78187823
}
78197824

7825+
function normalizeDraftPrClosePolicy(value: string | null | undefined): "off" | "close" {
7826+
return value === "close" ? "close" : "off";
7827+
}
7828+
78207829
function normalizeMergeTrainMode(value: string | null | undefined): "off" | "audit" | "enforce" {
78217830
return value === "audit" || value === "enforce" ? value : "off";
78227831
}

src/db/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ export const repositorySettings = sqliteTable("repository_settings", {
176176
reviewEvasionProtection: text("review_evasion_protection").notNull().default("off"),
177177
reviewEvasionLabel: text("review_evasion_label").notNull().default("review-evasion"),
178178
reviewEvasionComment: integer("review_evasion_comment", { mode: "boolean" }).notNull().default(true),
179+
// Draft-PR close policy (#draft-pr-close-policy): off by default -- unlike reviewEvasionProtection above,
180+
// this enforces on ANY draft (including the first one, before a review has run), so a maintainer opts in
181+
// deliberately rather than getting it on by default.
182+
draftPrClosePolicy: text("draft_pr_close_policy").notNull().default("off"),
179183
// Merge-train FIFO gate (#selfhost-merge-train): off by default, same "opt-in, no surprise behavior change"
180184
// shape as reviewEvasionProtection above.
181185
mergeTrainMode: text("merge_train_mode").notNull().default("off"),

src/openapi/schemas.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,12 @@ export const RepositorySettingsSchema = z
858858
),
859859
reviewEvasionLabel: z.string().nullable().optional(),
860860
reviewEvasionComment: z.boolean().optional(),
861+
draftPrClosePolicy: z
862+
.enum(["off", "close"])
863+
.optional()
864+
.describe(
865+
"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.",
866+
),
861867
mergeTrainMode: z.enum(["off", "audit", "enforce"]).optional(),
862868
screenshotTableGate: z
863869
.object({

src/queue/processors.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ export { runCopycatAssessment, shouldCollectCopycatEvidence } from "./copycat-de
381381
// working.
382382
import {
383383
maybeCloseDraftDodgeAttempt,
384+
maybeCloseDraftPr,
384385
maybeCloseRepeatedDraftCycling,
385386
maybeCloseReviewEvasionDraftConversion,
386387
maybeCloseReviewEvasionSelfClose,
@@ -5834,6 +5835,25 @@ async function handlePullRequestWebhookEvent(
58345835
settings,
58355836
);
58365837
}
5838+
// Draft-PR close policy (#draft-pr-close-policy): opt-in, off by default -- distinct from the two review-
5839+
// evasion guards above, which only enforce once a review has already run against this head. Fires on
5840+
// EITHER trigger for a draft PR: opened directly as a draft, or converted to draft after opening --
5841+
// closing it before any review pass gets a chance to run at all, so a contributor can't farm bot labels/
5842+
// AI-review/CI feedback from a PR that never reaches a real one-shot disposition. Placed after both
5843+
// review-evasion draft guards above so a PR already closed by either of them fails this guard's own
5844+
// freshness re-check instead of being redundantly re-closed (same ordering rationale as the
5845+
// repeated-cycling guard below).
5846+
if ((payload.action === "opened" || payload.action === "converted_to_draft") && installationId && pr.isDraft) {
5847+
await maybeCloseDraftPr(
5848+
env,
5849+
deliveryId,
5850+
installationId,
5851+
repoFullName,
5852+
pr,
5853+
payload,
5854+
settings,
5855+
);
5856+
}
58375857
// Review-evasion protection: repeated ready<->draft cycling (#gaming-tactic-draft-cycle). Only counts a
58385858
// conversion PERFORMED BY THE PR'S OWN AUTHOR -- a maintainer/third-party converting the PR to draft is an
58395859
// unrelated action and must never contribute to (or be conflated with) the author's own cycling pattern;

0 commit comments

Comments
 (0)