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: 6 additions & 0 deletions .loopover.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
13 changes: 13 additions & 0 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions migrations/0156_draft_pr_close_policy.sql
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 6 additions & 0 deletions src/config/loopover-repo-focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -835,6 +837,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewEvasionProtection: normalizeReviewEvasionProtection(settings.reviewEvasionProtection),
reviewEvasionLabel: settings.reviewEvasionLabel ?? DEFAULT_REVIEW_EVASION_LABEL,
reviewEvasionComment: settings.reviewEvasionComment ?? true,
draftPrClosePolicy: normalizeDraftPrClosePolicy(settings.draftPrClosePolicy),
mergeTrainMode: normalizeMergeTrainMode(settings.mergeTrainMode),
screenshotTableGate: normalizeScreenshotTableGateConfig(settings.screenshotTableGate, []),
} satisfies RepositorySettings;
Expand Down Expand Up @@ -916,6 +919,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewEvasionProtection: resolved.reviewEvasionProtection,
reviewEvasionLabel: resolved.reviewEvasionLabel,
reviewEvasionComment: resolved.reviewEvasionComment,
draftPrClosePolicy: resolved.draftPrClosePolicy,
mergeTrainMode: resolved.mergeTrainMode,
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
screenshotTableGateWhenLabelsJson: jsonString(resolved.screenshotTableGate.whenLabels),
Expand Down Expand Up @@ -1005,6 +1009,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
reviewEvasionProtection: resolved.reviewEvasionProtection,
reviewEvasionLabel: resolved.reviewEvasionLabel,
reviewEvasionComment: resolved.reviewEvasionComment,
draftPrClosePolicy: resolved.draftPrClosePolicy,
mergeTrainMode: resolved.mergeTrainMode,
screenshotTableGateEnabled: resolved.screenshotTableGate.enabled,
screenshotTableGateWhenLabelsJson: jsonString(resolved.screenshotTableGate.whenLabels),
Expand Down Expand Up @@ -7817,6 +7822,10 @@ function normalizeReviewEvasionProtection(value: string | null | undefined): "of
return value === "off" ? "off" : "close";
}

function normalizeDraftPrClosePolicy(value: string | null | undefined): "off" | "close" {
return value === "close" ? "close" : "off";
}

function normalizeMergeTrainMode(value: string | null | undefined): "off" | "audit" | "enforce" {
return value === "audit" || value === "enforce" ? value : "off";
}
Expand Down
4 changes: 4 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ export const repositorySettings = sqliteTable("repository_settings", {
reviewEvasionProtection: text("review_evasion_protection").notNull().default("off"),
reviewEvasionLabel: text("review_evasion_label").notNull().default("review-evasion"),
reviewEvasionComment: integer("review_evasion_comment", { mode: "boolean" }).notNull().default(true),
// Draft-PR close policy (#draft-pr-close-policy): off by default -- unlike reviewEvasionProtection above,
// this enforces on ANY draft (including the first one, before a review has run), so a maintainer opts in
// deliberately rather than getting it on by default.
draftPrClosePolicy: text("draft_pr_close_policy").notNull().default("off"),
// Merge-train FIFO gate (#selfhost-merge-train): off by default, same "opt-in, no surprise behavior change"
// shape as reviewEvasionProtection above.
mergeTrainMode: text("merge_train_mode").notNull().default("off"),
Expand Down
6 changes: 6 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,12 @@ export const RepositorySettingsSchema = z
),
reviewEvasionLabel: z.string().nullable().optional(),
reviewEvasionComment: z.boolean().optional(),
draftPrClosePolicy: z
.enum(["off", "close"])
.optional()
.describe(
"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.",
),
mergeTrainMode: z.enum(["off", "audit", "enforce"]).optional(),
screenshotTableGate: z
.object({
Expand Down
20 changes: 20 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export { runCopycatAssessment, shouldCollectCopycatEvidence } from "./copycat-de
// working.
import {
maybeCloseDraftDodgeAttempt,
maybeCloseDraftPr,
maybeCloseRepeatedDraftCycling,
maybeCloseReviewEvasionDraftConversion,
maybeCloseReviewEvasionSelfClose,
Expand Down Expand Up @@ -5834,6 +5835,25 @@ async function handlePullRequestWebhookEvent(
settings,
);
}
// Draft-PR close policy (#draft-pr-close-policy): opt-in, off by default -- distinct from the two review-
// evasion guards above, which only enforce once a review has already run against this head. Fires on
// EITHER trigger for a draft PR: opened directly as a draft, or converted to draft after opening --
// closing it before any review pass gets a chance to run at all, so a contributor can't farm bot labels/
// AI-review/CI feedback from a PR that never reaches a real one-shot disposition. Placed after both
// review-evasion draft guards above so a PR already closed by either of them fails this guard's own
// freshness re-check instead of being redundantly re-closed (same ordering rationale as the
// repeated-cycling guard below).
if ((payload.action === "opened" || payload.action === "converted_to_draft") && installationId && pr.isDraft) {
await maybeCloseDraftPr(
env,
deliveryId,
installationId,
repoFullName,
pr,
payload,
settings,
);
}
// Review-evasion protection: repeated ready<->draft 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;
Expand Down
Loading
Loading