From 5e819ea4326bdb459b65de7be11c2f03d3aa4025 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:25:08 -0700 Subject: [PATCH 1/6] fix(review): default the copycat gate on for reward-eligible repos (#9033) copycatGateMode's absent-value fallback was a flat "off" for every repo (config-as-code only, no DB column), so the deterministic containment engine never ran unless a repo explicitly opted in via .loopover.yml -- a direct economic leak for a reward-eligible repo, since colluding accounts could file near-identical fixes and both merge undetected. resolveEffectiveCopycatGateMode resolves an unset mode to "warn" for a subnet-registered (reward-eligible) repo instead, so the engine runs and persists copycatScore/copycatMatchedPullNumber -- the signal the duplicate-cluster election needs. An explicit .loopover.yml value, including an explicit "off", always wins over this default. --- src/settings/copycat-gate-mode.ts | 46 ++++++++++++++ src/settings/repository-settings.ts | 13 +++- src/types.ts | 19 ++++-- test/unit/copycat-gate-mode.test.ts | 34 +++++++++++ ...ository-settings-copycat-gate-mode.test.ts | 60 +++++++++++++++++++ 5 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 src/settings/copycat-gate-mode.ts create mode 100644 test/unit/copycat-gate-mode.test.ts create mode 100644 test/unit/repository-settings-copycat-gate-mode.test.ts diff --git a/src/settings/copycat-gate-mode.ts b/src/settings/copycat-gate-mode.ts new file mode 100644 index 0000000000..8ce2c88e59 --- /dev/null +++ b/src/settings/copycat-gate-mode.ts @@ -0,0 +1,46 @@ +import type { CopycatGateMode } from "../types"; + +/** + * #9033: the copycat/plagiarism containment gate (`gate.copycat.mode`) is config-as-code only (no DB column, + * see {@link RepositorySettings.copycatGateMode}'s own doc comment) and its absent-value fallback was a flat + * "off" for every repo -- so a repo only ever got copycat/reward-farming protection if it explicitly opted in + * via its own `.loopover.yml`. Two colluding accounts (or one attacker with two identities) filing near-identical + * fixes under different linked issues could both merge and both earn Gittensor rewards, with nothing evaluating + * the content-similarity signal at all by default. + * + * This mirrors `resolveDuplicateWinnerEnabled` (settings/duplicate-winner-mode.ts)'s own inherit-vs-explicit + * shape: an EXPLICIT per-repo value (including an explicit "off") always wins outright, and only a genuinely + * UNSET value (the manifest never mentioned `gate.copycat.mode` at all) falls through to a resolved default -- + * except here the default itself is conditional on reward-eligibility rather than a single global flag, since + * `LOOPOVER_DUPLICATE_WINNER`-style env flags have no per-repo economic dimension to key off of, while copycat + * reward-farming specifically only pays out on a repo that actually earns Gittensor rewards. + */ + +/** Reward-eligible default tier (#9033): `warn` is the least destructive tier that still turns ON the + * deterministic containment engine (src/queue/copycat-detection.ts) for a reward-eligible repo that never + * configured `gate.copycat.mode` at all -- it surfaces the advisory finding and (critically) makes the engine + * persist `copycatScore`/`copycatMatchedPullNumber` on every PR, which is what the duplicate-cluster election + * (src/queue/duplicate-detection.ts, src/rules/advisory.ts's `duplicate_pr_risk` finding) needs to catch a + * cross-issue content match at all. It deliberately stops short of `label`/`block` as the DEFAULT: those tiers + * take a unilateral, single-PR action (a label, or an outright close) off a 3-line-shingle containment + * heuristic with no observed false-positive rate yet on a repo that never asked for this gate. A maintainer who + * wants the stronger tiers can still set `gate.copycat.mode: label`/`block` explicitly -- this default never + * overrides that choice (see {@link resolveEffectiveCopycatGateMode}). The actual cross-issue reward-farming + * close still happens even at `warn`, via the duplicate-cluster election path, which only needs a persisted + * score/match to exist -- not `copycatGateMode` at `label`/`block`. */ +export const DEFAULT_COPYCAT_GATE_MODE_FOR_REGISTERED_REPO: CopycatGateMode = "warn"; + +/** + * Resolve the EFFECTIVE `copycatGateMode` (#9033): an explicit value (from `.loopover.yml`'s `settings:` block or + * its `gate.copycat.mode` alias -- `copycatGateMode` has no DB column, so a defined value here can only have come + * from config-as-code) always wins, even an explicit `"off"`. Only a genuinely UNSET value (`null`/`undefined` -- + * the manifest never mentioned it) falls through to the reward-eligibility default: `isRegistered` (subnet- + * registered, i.e. this repo's merged PRs actually earn Gittensor rewards -- see `RepositoryRecord.isRegistered`, + * the same flag `registry/sync.ts` sets when a repo is present in the latest registry snapshot) resolves to + * {@link DEFAULT_COPYCAT_GATE_MODE_FOR_REGISTERED_REPO}; an unregistered repo keeps today's "off" default + * unchanged (self-host / not-yet-registered / de-registered repos see byte-identical behavior). + */ +export function resolveEffectiveCopycatGateMode(mode: CopycatGateMode | null | undefined, isRegistered: boolean): CopycatGateMode { + if (mode !== null && mode !== undefined) return mode; + return isRegistered ? DEFAULT_COPYCAT_GATE_MODE_FOR_REGISTERED_REPO : "off"; +} diff --git a/src/settings/repository-settings.ts b/src/settings/repository-settings.ts index 0a4487c823..8e3b4b8647 100644 --- a/src/settings/repository-settings.ts +++ b/src/settings/repository-settings.ts @@ -1,8 +1,9 @@ -import { getGlobalContributorBlacklist, getRepositorySettings } from "../db/repositories"; +import { getGlobalContributorBlacklist, getRepository, getRepositorySettings } from "../db/repositories"; import { loadOverride, type StorageEnv } from "../review/auto-apply"; import { resolveEffectiveSettings } from "../signals/focus-manifest"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { isAgentConfigured } from "./autonomy"; +import { resolveEffectiveCopycatGateMode } from "./copycat-gate-mode"; import type { RepositorySettings } from "../types"; /** Default-OFF self-tune flag (mirrors selftune-wire's `isSelfTuneEnabled`; inlined here to avoid a @@ -42,12 +43,20 @@ export function applySelfTuneOverrideToSettings( * delete the promoted override (a human, or re-opting-in, can still see/clear it) — it just stops it from * being read back while the opt-out is in effect. */ export async function resolveRepositorySettings(env: Env, repoFullName: string): Promise { - const [dbSettings, manifest, globalContributorBlacklist] = await Promise.all([ + const [dbSettings, manifest, globalContributorBlacklist, repo] = await Promise.all([ getRepositorySettings(env, repoFullName), loadRepoFocusManifest(env, repoFullName), getGlobalContributorBlacklist(env).catch(() => []), + // #9033: cheap, indexed single-row read (mirrors the three parallel reads above) purely to resolve the + // copycat-gate reward-eligibility default below -- never throws (getRepository resolves null, not rejects, + // for a repo LoopOver hasn't seen), so a lookup failure just means "not registered" (today's "off" default). + getRepository(env, repoFullName), ]); const effective = resolveEffectiveSettings(dbSettings, manifest, globalContributorBlacklist); + // #9033: reward-eligible default flip -- an EXPLICIT `.loopover.yml` value (including an explicit "off") always + // wins; only a genuinely unset value falls through to the registration-based default. Applied to every return + // path below (not just the fast path) since this must hold whether or not self-tune is enabled. + effective.copycatGateMode = resolveEffectiveCopycatGateMode(effective.copycatGateMode, repo?.isRegistered ?? false); if (!selfTuneFlagOn(env)) return effective; if (manifest.review.selftune === false) return effective; // explicit per-repo opt-out — same check as selfTuneRepos if (!isAgentConfigured(effective.autonomy)) return effective; // acting-autonomy consent revoked/never granted diff --git a/src/types.ts b/src/types.ts index 14394e94c9..84abec9a1d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -973,12 +973,19 @@ export type RepositorySettings = { /** `gate.cla.checkRunAppSlug`: the trusted GitHub App slug that must have produced `claCheckRunName`. Required * for check-run detection so contributor-controlled same-name runs cannot satisfy a blocking CLA gate. */ claCheckRunAppSlug?: string | null | undefined; - /** Copycat/plagiarism detection (#1969). `off` (default/absent) = no check; `warn`/`label`/`block` are - * escalating tiers the deterministic containment engine (src/queue/copycat-detection.ts, evaluated in - * src/queue/processors.ts alongside slop) acts on: `warn` surfaces an advisory finding only; `label` also - * applies a label (src/settings/agent-actions.ts's maybePlanCopycatLabel); `block` also closes the PR - * (closeKind: "copycat") and counts toward the moderation-rules strikes ledger. Config-as-code only — no - * DB column or dashboard toggle; set via `.loopover.yml gate.copycat.mode`. */ + /** Copycat/plagiarism detection (#1969). `off`/absent = no check when explicitly set that way, or on a repo + * that isn't reward-eligible; `warn`/`label`/`block` are escalating tiers the deterministic containment engine + * (src/queue/copycat-detection.ts, evaluated in src/queue/processors.ts alongside slop) acts on: `warn` + * surfaces an advisory finding only; `label` also applies a label (src/settings/agent-actions.ts's + * maybePlanCopycatLabel); `block` also closes the PR (closeKind: "copycat") and counts toward the + * moderation-rules strikes ledger. Config-as-code only — no DB column or dashboard toggle; set via + * `.loopover.yml gate.copycat.mode`. #9033: an UNSET value (never mentioned in `.loopover.yml`) no longer + * flatly resolves to "off" for every repo — `resolveRepositorySettings` + * (settings/repository-settings.ts, via {@link resolveEffectiveCopycatGateMode}) resolves it to `warn` for a + * reward-eligible (subnet-registered, `RepositoryRecord.isRegistered`) repo instead, so the containment engine + * actually runs (and persists `copycatScore`/`copycatMatchedPullNumber`) — the signal the duplicate-cluster + * election (queue/duplicate-detection.ts, rules/advisory.ts) needs to catch a cross-issue reward-farming + * duplicate. An EXPLICIT value (including an explicit `"off"`) always wins over this default. */ copycatGateMode?: CopycatGateMode | undefined; /** `gate.copycat.minScore`: containment/similarity score (0-100) at/above which `copycatGateMode` acts. * `null`/absent ⇒ the engine's own default threshold (85). Config-as-code only, alongside diff --git a/test/unit/copycat-gate-mode.test.ts b/test/unit/copycat-gate-mode.test.ts new file mode 100644 index 0000000000..f2c8a60809 --- /dev/null +++ b/test/unit/copycat-gate-mode.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_COPYCAT_GATE_MODE_FOR_REGISTERED_REPO, resolveEffectiveCopycatGateMode } from "../../src/settings/copycat-gate-mode"; + +// #9033: copycatGateMode's absent-value fallback used to be a flat "off" for every repo (config-as-code only, no +// DB column), so a reward-eligible repo got zero copycat/reward-farming protection unless it explicitly opted in +// via its own .loopover.yml. resolveEffectiveCopycatGateMode flips the DEFAULT (never an explicit choice) for a +// reward-eligible (subnet-registered) repo. +describe("resolveEffectiveCopycatGateMode (#9033)", () => { + it("resolves the reward-eligible default (warn) when the mode is undefined and the repo is registered", () => { + expect(resolveEffectiveCopycatGateMode(undefined, true)).toBe("warn"); + expect(resolveEffectiveCopycatGateMode(undefined, true)).toBe(DEFAULT_COPYCAT_GATE_MODE_FOR_REGISTERED_REPO); + }); + + it("resolves the reward-eligible default (warn) when the mode is null and the repo is registered", () => { + expect(resolveEffectiveCopycatGateMode(null, true)).toBe("warn"); + }); + + it("keeps the legacy off default when the mode is unset and the repo is NOT registered", () => { + expect(resolveEffectiveCopycatGateMode(undefined, false)).toBe("off"); + expect(resolveEffectiveCopycatGateMode(null, false)).toBe("off"); + }); + + it("an explicit off ALWAYS wins over the reward-eligible default -- config-as-code opt-out is never overridden", () => { + expect(resolveEffectiveCopycatGateMode("off", true)).toBe("off"); + expect(resolveEffectiveCopycatGateMode("off", false)).toBe("off"); + }); + + it("an explicit warn/label/block ALWAYS wins regardless of registration status", () => { + for (const mode of ["warn", "label", "block"] as const) { + expect(resolveEffectiveCopycatGateMode(mode, true)).toBe(mode); + expect(resolveEffectiveCopycatGateMode(mode, false)).toBe(mode); + } + }); +}); diff --git a/test/unit/repository-settings-copycat-gate-mode.test.ts b/test/unit/repository-settings-copycat-gate-mode.test.ts new file mode 100644 index 0000000000..c2c666e4f7 --- /dev/null +++ b/test/unit/repository-settings-copycat-gate-mode.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { getDb } from "../../src/db/client"; +import { repositories } from "../../src/db/schema"; +import { upsertRepositorySettings } from "../../src/db/repositories"; +import { resolveRepositorySettings } from "../../src/settings/repository-settings"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { createTestEnv } from "../helpers/d1"; + +// #9033: copycatGateMode has no DB column (config-as-code only) -- registration status ("reward-eligible", i.e. +// this repo's merged PRs actually earn Gittensor rewards) is a `repositories.is_registered` column instead, so +// these tests seed that row directly via drizzle (mirrors upsertRepositoryFromGitHub's own insert shape) rather +// than through a settings helper that has no field for it. +async function seedRepo(env: Env, fullName: string, isRegistered: boolean): Promise { + const [owner, name] = fullName.split("/") as [string, string]; + await getDb(env.DB) + .insert(repositories) + .values({ fullName, owner, name, isRegistered }); +} + +describe("resolveRepositorySettings: copycatGateMode reward-eligible default (#9033)", () => { + it("resolves to warn for a REGISTERED repo that never configured gate.copycat.mode", async () => { + const env = createTestEnv(); + await seedRepo(env, "acme/registered-no-config", true); + await upsertRepositorySettings(env, { repoFullName: "acme/registered-no-config" }); + const settings = await resolveRepositorySettings(env, "acme/registered-no-config"); + expect(settings.copycatGateMode).toBe("warn"); + }); + + it("keeps off for an UNREGISTERED repo that never configured gate.copycat.mode (byte-identical to before #9033)", async () => { + const env = createTestEnv(); + await seedRepo(env, "acme/unregistered-no-config", false); + await upsertRepositorySettings(env, { repoFullName: "acme/unregistered-no-config" }); + const settings = await resolveRepositorySettings(env, "acme/unregistered-no-config"); + expect(settings.copycatGateMode).toBe("off"); + }); + + it("keeps off for a repo LoopOver has never seen at all (getRepository resolves null, treated as unregistered)", async () => { + const env = createTestEnv(); + const settings = await resolveRepositorySettings(env, "acme/never-seen"); + expect(settings.copycatGateMode).toBe("off"); + }); + + it("an explicit gate.copycat.mode: off in .loopover.yml is NEVER overridden, even on a registered repo", async () => { + const env = createTestEnv(); + await seedRepo(env, "acme/registered-explicit-off", true); + await upsertRepositorySettings(env, { repoFullName: "acme/registered-explicit-off" }); + await upsertRepoFocusManifest(env, "acme/registered-explicit-off", { gate: { copycat: { mode: "off" } } }); + const settings = await resolveRepositorySettings(env, "acme/registered-explicit-off"); + expect(settings.copycatGateMode).toBe("off"); + }); + + it("an explicit gate.copycat.mode: block in .loopover.yml is honored on an UNREGISTERED repo (config-as-code always wins)", async () => { + const env = createTestEnv(); + await seedRepo(env, "acme/unregistered-explicit-block", false); + await upsertRepositorySettings(env, { repoFullName: "acme/unregistered-explicit-block" }); + await upsertRepoFocusManifest(env, "acme/unregistered-explicit-block", { gate: { copycat: { mode: "block" } } }); + const settings = await resolveRepositorySettings(env, "acme/unregistered-explicit-block"); + expect(settings.copycatGateMode).toBe("block"); + }); +}); From c96a001642c2900ec811b5e7900cef94e8dd7e0a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:31:32 -0700 Subject: [PATCH 2/6] fix(review): wire copycat containment matches into duplicate-cluster election (#9033) The duplicate-winner election (queue/duplicate-detection.ts) keyed entirely on shared linkedIssues overlap, so two PRs citing different issues -- one a near-identical copy of the other -- were never even considered duplicate-cluster candidates, letting both merge and both earn rewards independently. resolveCopycatDuplicateSibling (src/signals/copycat-duplicate.ts) bridges a PR's already-persisted copycat containment match into linkedIssueDuplicatePullRequestRecordsForGate and reconcileLiveDuplicateSiblings, so a cross-issue content match above the configured threshold now feeds the SAME winner-election, close- count, and live-staleness-reconciliation machinery a same-linked-issue duplicate already does. Scoped to still-open siblings only -- a match against an already-merged PR is left to copycatGateMode's own single-PR warn/label/block actuation. Every new parameter is optional and defaults to "never contributes a copycat sibling", so no existing caller's behavior changes. --- src/queue/duplicate-detection.ts | 50 +++++++++++-- src/queue/processors.ts | 4 +- src/signals/copycat-duplicate.ts | 33 +++++++++ test/unit/copycat-duplicate.test.ts | 58 +++++++++++++++ test/unit/duplicate-detection.test.ts | 74 +++++++++++++++++++ .../reconcile-live-duplicate-siblings.test.ts | 53 +++++++++++++ 6 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 src/signals/copycat-duplicate.ts create mode 100644 test/unit/copycat-duplicate.test.ts create mode 100644 test/unit/duplicate-detection.test.ts diff --git a/src/queue/duplicate-detection.ts b/src/queue/duplicate-detection.ts index 753ce851e0..907bca7e67 100644 --- a/src/queue/duplicate-detection.ts +++ b/src/queue/duplicate-detection.ts @@ -9,9 +9,10 @@ import { createInstallationToken } from "../github/app"; import { fetchLivePullRequestState } from "../github/backfill"; import { githubRateLimitAdmissionKeyForToken } from "../github/client"; +import { resolveCopycatDuplicateSibling } from "../signals/copycat-duplicate"; import { isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber } from "../signals/duplicate-winner"; import { isDuplicateWinnerEnabledGlobally, resolveDuplicateWinnerEnabled } from "../settings/duplicate-winner-mode"; -import type { PullRequestRecord, RepositorySettings } from "../types"; +import type { CopycatGateMode, PullRequestRecord, RepositorySettings } from "../types"; import { mapWithConcurrency } from "./map-with-concurrency"; /** Same order of magnitude as processors.ts's other per-item live GitHub fan-outs (#5835). */ @@ -71,7 +72,16 @@ export function dupWinnerLinkedDuplicateWinnerNumber( * * FAIL-OPEN to the stored state: a sibling is dropped ONLY on a positive "not open" confirmation — an unreadable * live fetch keeps it, so a transient GitHub hiccup never newly spares a real loser. Flag-OFF (default), no - * linked issues, or no overlapping sibling ⇒ returns the input unchanged with no extra API calls. + * linked issues (and no copycat-matched sibling, #9033), or no overlapping sibling ⇒ returns the input unchanged + * with no extra API calls. + * + * #9033: the overlapping-sibling set is no longer linked-issue overlap ALONE. `pr`'s own persisted copycat + * containment match ({@link resolveCopycatDuplicateSibling}) — when the effective `copycatGateMode` would act on + * it — names an OPEN sibling whose added code this PR's content is contained in, even when the two PRs cite + * DIFFERENT linked issues. That sibling is a live "two competing submissions for the same work" race exactly + * like a same-linked-issue overlap, so it must go through the SAME staleness reconciliation before winner + * election runs, or a stale-cached-open copycat match could demote the true earliest claimant just like an + * un-reconciled linked-issue sibling could (the bug this function exists to prevent in the first place). */ export async function reconcileLiveDuplicateSiblings( env: Env, @@ -79,15 +89,16 @@ export async function reconcileLiveDuplicateSiblings( repoFullName: string, pr: PullRequestRecord, otherOpenPullRequests: PullRequestRecord[], - settings: Pick, + settings: Pick, ): Promise { if (!resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode)) return otherOpenPullRequests; const linkedIssues = new Set(pr.linkedIssues); - if (linkedIssues.size === 0) return otherOpenPullRequests; + const copycatSibling = resolveCopycatDuplicateSibling(pr, otherOpenPullRequests, settings.copycatGateMode, settings.copycatGateMinScore); + if (linkedIssues.size === 0 && copycatSibling === undefined) return otherOpenPullRequests; const overlapping = otherOpenPullRequests.filter( (other) => other.state === "open" && - other.linkedIssues.some((issue) => linkedIssues.has(issue)), + (other.linkedIssues.some((issue) => linkedIssues.has(issue)) || other.number === copycatSibling?.number), ); if (overlapping.length === 0) return otherOpenPullRequests; const installationToken = @@ -126,21 +137,44 @@ export async function reconcileLiveDuplicateSiblings( export function linkedIssueDuplicatePullRequestsForGate( pr: PullRequestRecord, pullRequests: PullRequestRecord[], + copycatGateMode?: CopycatGateMode | null | undefined, + copycatGateMinScore?: number | null | undefined, ): number[] { - return linkedIssueDuplicatePullRequestRecordsForGate(pr, pullRequests).map((otherPr) => otherPr.number); + return linkedIssueDuplicatePullRequestRecordsForGate(pr, pullRequests, copycatGateMode, copycatGateMinScore).map((otherPr) => otherPr.number); } +/** + * #9033: this set is no longer linked-issue overlap ALONE. A sibling that `pr`'s own persisted copycat + * containment assessment names ({@link resolveCopycatDuplicateSibling}) — when the effective `copycatGateMode` + * would act on it — is added too, even when the two PRs cite DIFFERENT linked issues: a cross-issue content match + * is the SAME "these are the same work, only one should merge" signal a shared linked issue is, so it must feed + * the identical winner-election / close-count / related-work consumers of this function, not a parallel, + * disconnected mechanism. `copycatGateMode`/`copycatGateMinScore` default to `undefined` (⇒ never contributes a + * copycat sibling) so every pre-#9033 caller that doesn't pass them stays byte-identical. + */ export function linkedIssueDuplicatePullRequestRecordsForGate( pr: PullRequestRecord, pullRequests: PullRequestRecord[], + copycatGateMode?: CopycatGateMode | null | undefined, + copycatGateMinScore?: number | null | undefined, ): PullRequestRecord[] { const linkedIssues = new Set(pr.linkedIssues); - if (linkedIssues.size === 0) return []; + // Only an OPEN candidate can be a duplicate-cluster sibling (mirrors the flatMap's own `state !== "open"` + // skip below) -- resolveCopycatDuplicateSibling's documented contract is "scoped to open siblings only". + const copycatSibling = resolveCopycatDuplicateSibling( + pr, + pullRequests.filter((otherPr) => otherPr.state === "open"), + copycatGateMode, + copycatGateMinScore, + ); + if (linkedIssues.size === 0 && copycatSibling === undefined) return []; return [ ...new Map( pullRequests.flatMap((otherPr) => { if (otherPr.number === pr.number || otherPr.state !== "open") return []; - return otherPr.linkedIssues.some((issue) => linkedIssues.has(issue)) ? [[otherPr.number, otherPr] as const] : []; + const isLinkedIssueOverlap = otherPr.linkedIssues.some((issue) => linkedIssues.has(issue)); + const isCopycatSibling = copycatSibling !== undefined && otherPr.number === copycatSibling.number; + return isLinkedIssueOverlap || isCopycatSibling ? [[otherPr.number, otherPr] as const] : []; }), ).values(), ].sort((left, right) => left.number - right.number); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index bfd3c0ac42..e4876c77cc 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -3460,7 +3460,7 @@ async function runAgentMaintenancePlanAndExecute( autoMaintain.requireApprovals === 0 || (liveReviewDecision ?? pr.reviewDecision) === "APPROVED"; const duplicateWinnerEnabled = resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode); - const openDuplicateSiblings = linkedIssueDuplicatePullRequestRecordsForGate(pr, otherOpenPullRequests); + const openDuplicateSiblings = linkedIssueDuplicatePullRequestRecordsForGate(pr, otherOpenPullRequests, settings.copycatGateMode, settings.copycatGateMinScore); // AI-review low-confidence guardrail (#4603): resolved PURELY from this pass's own gate evaluation + settings // (no extra network/DB call, unlike migrationCollisionHold/unlinkedIssueMatchHold above) -- undefined unless the // gate failed SOLELY on a sub-aiReviewCloseConfidence-floor ai_consensus_defect/ai_review_split finding under @@ -10237,6 +10237,8 @@ async function maybePublishPrPublicSurface( const linkedDuplicatePrsForGate = linkedIssueDuplicatePullRequestRecordsForGate( pr, otherOpenPullRequests, + settings.copycatGateMode, + settings.copycatGateMinScore, ); const duplicateWinnerEnabled = resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode); const isDupWinner = diff --git a/src/signals/copycat-duplicate.ts b/src/signals/copycat-duplicate.ts new file mode 100644 index 0000000000..4427d3695e --- /dev/null +++ b/src/signals/copycat-duplicate.ts @@ -0,0 +1,33 @@ +import { copycatWouldActOnPersistedScore } from "./copycat"; +import type { CopycatGateMode, PullRequestRecord } from "../types"; + +/** + * #9033: bridges the copycat/plagiarism containment engine's per-PR verdict (copycat-detection.ts, + * src/signals/copycat.ts) into the duplicate-cluster machinery (queue/duplicate-detection.ts, + * rules/advisory.ts), which previously keyed ENTIRELY on shared `linkedIssues` overlap -- so two PRs citing + * DIFFERENT issues, one a near-identical copy of the other's added code, were never even considered duplicate- + * cluster candidates, regardless of content similarity. Two colluding accounts (or one attacker with two + * identities) could exploit exactly this gap to double-earn Gittensor rewards for one piece of work. + * + * Resolves `pr`'s already-PERSISTED copycat assessment (`copycatScore`/`copycatMatchedPullNumber`, written by + * copycat-detection.ts's runCopycatAssessment during this PR's own gate evaluation -- never re-scored here) to + * the specific OPEN sibling PR it names, when-and-only-when the containment engine would actually ACT on that + * score under the repo's current effective `copycatGateMode`/`copycatGateMinScore` (mirrors + * copycatWouldActOnPersistedScore's exact semantics: non-`off` mode, a real match, score >= threshold). + * + * Scoped to OPEN siblings only, by construction: `copycatMatchedPullNumber` can reference either a still-open + * sibling (a live "two competing submissions for the same work" race -- exactly what duplicate-cluster election + * exists to adjudicate) or an already-merged PR (prior art this PR copied from, with no "other side" to elect a + * winner against -- that case is fully handled by copycatGateMode's own single-PR warn/label/block actuation, + * see settings/agent-actions.ts). Passing only `otherOpenPullRequests` here means a merged-PR match can never + * incorrectly surface as a duplicate-cluster sibling. + */ +export function resolveCopycatDuplicateSibling( + pr: Pick, + otherOpenPullRequests: readonly PullRequestRecord[], + copycatGateMode: CopycatGateMode | null | undefined, + copycatGateMinScore: number | null | undefined, +): PullRequestRecord | undefined { + if (!copycatWouldActOnPersistedScore(pr.copycatScore, pr.copycatMatchedPullNumber, copycatGateMode, copycatGateMinScore)) return undefined; + return otherOpenPullRequests.find((otherPr) => otherPr.number === pr.copycatMatchedPullNumber); +} diff --git a/test/unit/copycat-duplicate.test.ts b/test/unit/copycat-duplicate.test.ts new file mode 100644 index 0000000000..073452e756 --- /dev/null +++ b/test/unit/copycat-duplicate.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { resolveCopycatDuplicateSibling } from "../../src/signals/copycat-duplicate"; +import type { PullRequestRecord } from "../../src/types"; + +function openPr(number: number): PullRequestRecord { + return { repoFullName: "acme/widgets", number, title: `PR ${number}`, state: "open", labels: [], linkedIssues: [] }; +} + +// #9033: resolveCopycatDuplicateSibling is the bridge between the copycat containment engine's persisted +// per-PR verdict and the duplicate-cluster election, which otherwise never learns about a cross-issue content +// match at all. +describe("resolveCopycatDuplicateSibling (#9033)", () => { + it("returns undefined when copycatGateMode is off, even with a high score and a real matched PR", () => { + const pr = { copycatScore: 95, copycatMatchedPullNumber: 12 }; + expect(resolveCopycatDuplicateSibling(pr, [openPr(12)], "off", null)).toBeUndefined(); + }); + + it("returns undefined when copycatGateMode is undefined (absent-means-off convention)", () => { + const pr = { copycatScore: 95, copycatMatchedPullNumber: 12 }; + expect(resolveCopycatDuplicateSibling(pr, [openPr(12)], undefined, null)).toBeUndefined(); + }); + + it("returns undefined when there is no matched PR at all", () => { + const pr = { copycatScore: 0, copycatMatchedPullNumber: null }; + expect(resolveCopycatDuplicateSibling(pr, [openPr(12)], "warn", null)).toBeUndefined(); + }); + + it("returns undefined when the score is below the resolved threshold", () => { + const pr = { copycatScore: 40, copycatMatchedPullNumber: 12 }; + expect(resolveCopycatDuplicateSibling(pr, [openPr(12)], "warn", 85)).toBeUndefined(); + }); + + it("returns the matched sibling when the score clears the threshold under warn mode", () => { + const pr = { copycatScore: 90, copycatMatchedPullNumber: 12 }; + const sibling = openPr(12); + expect(resolveCopycatDuplicateSibling(pr, [sibling], "warn", 85)).toBe(sibling); + }); + + it("returns the matched sibling when the score clears the threshold under label/block mode too", () => { + const pr = { copycatScore: 90, copycatMatchedPullNumber: 12 }; + const sibling = openPr(12); + expect(resolveCopycatDuplicateSibling(pr, [sibling], "label", 85)).toBe(sibling); + expect(resolveCopycatDuplicateSibling(pr, [sibling], "block", 85)).toBe(sibling); + }); + + it("returns undefined when the matched PR number does not correspond to a still-OPEN sibling (e.g. it was a merged-PR match)", () => { + const pr = { copycatScore: 95, copycatMatchedPullNumber: 999 }; + expect(resolveCopycatDuplicateSibling(pr, [openPr(12)], "warn", 85)).toBeUndefined(); + }); + + it("falls back to the engine default threshold (85) when copycatGateMinScore is null", () => { + const highEnough = { copycatScore: 85, copycatMatchedPullNumber: 12 }; + const tooLow = { copycatScore: 84, copycatMatchedPullNumber: 12 }; + const sibling = openPr(12); + expect(resolveCopycatDuplicateSibling(highEnough, [sibling], "warn", null)).toBe(sibling); + expect(resolveCopycatDuplicateSibling(tooLow, [sibling], "warn", null)).toBeUndefined(); + }); +}); diff --git a/test/unit/duplicate-detection.test.ts b/test/unit/duplicate-detection.test.ts new file mode 100644 index 0000000000..17bd59614d --- /dev/null +++ b/test/unit/duplicate-detection.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { linkedIssueDuplicatePullRequestRecordsForGate, linkedIssueDuplicatePullRequestsForGate } from "../../src/queue/duplicate-detection"; +import type { PullRequestRecord } from "../../src/types"; + +function pr(over: Partial & { number: number }): PullRequestRecord { + return { + repoFullName: "acme/widgets", + title: `PR ${over.number}`, + state: "open", + labels: [], + linkedIssues: [], + ...over, + }; +} + +describe("linkedIssueDuplicatePullRequestRecordsForGate", () => { + it("returns [] when the PR links no issue and has no copycat match", () => { + expect(linkedIssueDuplicatePullRequestRecordsForGate(pr({ number: 9, linkedIssues: [] }), [pr({ number: 5, linkedIssues: [1] })])).toEqual([]); + }); + + it("returns the linked-issue-overlapping open sibling (byte-identical to before #9033 with no copycat args)", () => { + const sibling = pr({ number: 5, linkedIssues: [1] }); + const result = linkedIssueDuplicatePullRequestRecordsForGate(pr({ number: 9, linkedIssues: [1] }), [sibling]); + expect(result.map((p) => p.number)).toEqual([5]); + }); + + it("excludes the PR itself and any non-open sibling from the linked-issue overlap", () => { + const self = pr({ number: 9, linkedIssues: [1] }); + const closedSibling = pr({ number: 4, state: "closed", linkedIssues: [1] }); + expect(linkedIssueDuplicatePullRequestRecordsForGate(self, [self, closedSibling])).toEqual([]); + }); + + // #9033: a cross-issue content match. + it("returns undefined-free [] for a copycat match when copycatGateMode is off (or absent)", () => { + const candidate = pr({ number: 9, linkedIssues: [1], copycatScore: 95, copycatMatchedPullNumber: 12 }); + const sibling = pr({ number: 12, linkedIssues: [999] }); // DIFFERENT linked issue + expect(linkedIssueDuplicatePullRequestRecordsForGate(candidate, [sibling], "off", null)).toEqual([]); + expect(linkedIssueDuplicatePullRequestRecordsForGate(candidate, [sibling])).toEqual([]); + }); + + it("includes the copycat-matched sibling even though it cites a DIFFERENT linked issue, once copycatGateMode would act", () => { + const candidate = pr({ number: 9, linkedIssues: [1], copycatScore: 95, copycatMatchedPullNumber: 12 }); + const sibling = pr({ number: 12, linkedIssues: [999] }); + const result = linkedIssueDuplicatePullRequestRecordsForGate(candidate, [sibling], "warn", 85); + expect(result.map((p) => p.number)).toEqual([12]); + }); + + it("does not include a copycat match whose score is below the resolved threshold", () => { + const candidate = pr({ number: 9, linkedIssues: [1], copycatScore: 40, copycatMatchedPullNumber: 12 }); + const sibling = pr({ number: 12, linkedIssues: [999] }); + expect(linkedIssueDuplicatePullRequestRecordsForGate(candidate, [sibling], "warn", 85)).toEqual([]); + }); + + it("does not include a copycat match that points at a CLOSED PR (only open siblings can be a duplicate-cluster member)", () => { + const candidate = pr({ number: 9, linkedIssues: [1], copycatScore: 95, copycatMatchedPullNumber: 12 }); + const closedMatch = pr({ number: 12, state: "closed", linkedIssues: [999] }); + expect(linkedIssueDuplicatePullRequestRecordsForGate(candidate, [closedMatch], "warn", 85)).toEqual([]); + }); + + it("dedupes when a sibling is BOTH a linked-issue overlap AND the copycat match — appears once, sorted by number", () => { + const candidate = pr({ number: 9, linkedIssues: [1], copycatScore: 95, copycatMatchedPullNumber: 5 }); + const both = pr({ number: 5, linkedIssues: [1] }); + const other = pr({ number: 20, linkedIssues: [1] }); + const result = linkedIssueDuplicatePullRequestRecordsForGate(candidate, [other, both], "warn", 85); + expect(result.map((p) => p.number)).toEqual([5, 20]); + }); + + it("linkedIssueDuplicatePullRequestsForGate mirrors the record helper, mapped to PR numbers, threading the copycat args through", () => { + const candidate = pr({ number: 9, linkedIssues: [1], copycatScore: 95, copycatMatchedPullNumber: 12 }); + const sibling = pr({ number: 12, linkedIssues: [999] }); + expect(linkedIssueDuplicatePullRequestsForGate(candidate, [sibling], "warn", 85)).toEqual([12]); + expect(linkedIssueDuplicatePullRequestsForGate(candidate, [sibling])).toEqual([]); + }); +}); diff --git a/test/unit/reconcile-live-duplicate-siblings.test.ts b/test/unit/reconcile-live-duplicate-siblings.test.ts index fd12dcd72f..842f1d734e 100644 --- a/test/unit/reconcile-live-duplicate-siblings.test.ts +++ b/test/unit/reconcile-live-duplicate-siblings.test.ts @@ -16,6 +16,10 @@ function makePr(number: number, state: string, linkedIssues: number[]): PullRequ return { repoFullName: "owner/repo", number, title: `PR ${number}`, state, labels: [], linkedIssues }; } +function makeCopycatPr(number: number, state: string, linkedIssues: number[], copycatMatchedPullNumber: number, copycatScore = 95): PullRequestRecord { + return { ...makePr(number, state, linkedIssues), copycatScore, copycatMatchedPullNumber }; +} + // "inherit" (no per-repo override) preserves every existing test's semantics below unchanged -- the flag alone // governs, exactly as before duplicateWinnerMode existed. const settings = { duplicateWinnerMode: undefined }; @@ -201,4 +205,53 @@ describe("reconcileLiveDuplicateSiblings (#dup-winner / audit #15)", () => { expect(maxInFlight).toBeLessThanOrEqual(10); expect(maxInFlight).toBeGreaterThan(1); }); + + // #9033: a copycat-content-matched sibling (a DIFFERENT linked issue, or none at all) must go through the SAME + // live staleness reconciliation as a linked-issue-overlapping sibling, or a stale-cached-open copycat match + // could demote the true earliest claimant just like an un-reconciled linked-issue sibling could. + describe("copycat-matched siblings (#9033)", () => { + it("flag ON, no linked issues but a copycat-matched sibling LIVE-closed ⇒ dropped", async () => { + const env = createTestEnv(); + env.LOOPOVER_DUPLICATE_WINNER = "true"; + stubLiveStates({ 12: "closed" }); + const siblings = [makePr(12, "open", [999])]; // different linked issue -- would never overlap on its own + const pr = makeCopycatPr(9, "open", [], 12); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, { ...settings, copycatGateMode: "warn", copycatGateMinScore: 85 }); + expect(result.map((p) => p.number)).toEqual([]); + }); + + it("flag ON, a copycat-matched sibling LIVE-open ⇒ kept", async () => { + const env = createTestEnv(); + env.LOOPOVER_DUPLICATE_WINNER = "true"; + stubLiveStates({ 12: "open" }); + const siblings = [makePr(12, "open", [999])]; + const pr = makeCopycatPr(9, "open", [], 12); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, { ...settings, copycatGateMode: "warn", copycatGateMinScore: 85 }); + expect(result.map((p) => p.number)).toEqual([12]); + }); + + it("copycatGateMode off ⇒ the copycat match is never considered, even with a high score", async () => { + const env = createTestEnv(); + env.LOOPOVER_DUPLICATE_WINNER = "true"; + vi.stubGlobal("fetch", async () => { + throw new Error("fetch must not be called -- no linked-issue overlap and copycat mode is off"); + }); + const siblings = [makePr(12, "open", [999])]; + const pr = makeCopycatPr(9, "open", [], 12); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, { ...settings, copycatGateMode: "off", copycatGateMinScore: 85 }); + expect(result).toBe(siblings); + }); + + it("a copycat score below the resolved threshold ⇒ unchanged (not a cluster member)", async () => { + const env = createTestEnv(); + env.LOOPOVER_DUPLICATE_WINNER = "true"; + vi.stubGlobal("fetch", async () => { + throw new Error("fetch must not be called -- score is below threshold"); + }); + const siblings = [makePr(12, "open", [999])]; + const pr = makeCopycatPr(9, "open", [], 12, 40); + const result = await reconcileLiveDuplicateSiblings(env, null, "owner/repo", pr, siblings, { ...settings, copycatGateMode: "warn", copycatGateMinScore: 85 }); + expect(result).toBe(siblings); + }); + }); }); From 98f27bc8a81c0a766463d6de2a1a2916399b43c7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:36:10 -0700 Subject: [PATCH 3/6] fix(review): raise a duplicate_pr_risk finding on cross-issue copycat overlap (#9033) The duplicate_pr_risk gate finding -- the mechanism that actually holds or blocks a PR as a duplicate -- keyed strictly on shared linkedIssues overlap, so a cross-issue copycat containment match never surfaced here even after the duplicate-cluster election learned about it: the election could name a winner, but the loser's gate never actually failed on it. addPullRequestFindings now folds a PR's own persisted copycat containment match into the same overlap set a shared linked issue produces, treating it as corroborated by construction (a threshold- cleared content match is stronger evidence than a bare changed-file overlap). This is what makes the loser in a cross-issue reward-farming attempt actually get held/closed instead of merging independently. --- src/queue/processors.ts | 8 +++++ src/rules/advisory.ts | 57 ++++++++++++++++++++++++++----- test/unit/rules.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index e4876c77cc..a53b1b8c74 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1607,6 +1607,8 @@ export async function sweepRepoRegate( duplicateWinnerEnabled, linkedIssueAuthorLogins, confirmedNoOpenLinkedIssue, + copycatGateMode: settings.copycatGateMode, + copycatGateMinScore: settings.copycatGateMinScore, }); const gate = evaluateGateCheck( advisory, @@ -4089,6 +4091,8 @@ export async function reReviewStoredPullRequest( duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode), confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, + copycatGateMode: settings.copycatGateMode, + copycatGateMinScore: settings.copycatGateMinScore, }); await persistAdvisory(env, advisory); // #2537 follow-up (gate-flagged): the durable review cache's only invalidation path is markPullRequestReviewsInvalidated @@ -6944,6 +6948,8 @@ async function handlePullRequestWebhookEvent( duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode), confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, + copycatGateMode: settings.copycatGateMode, + copycatGateMinScore: settings.copycatGateMinScore, }); await persistAdvisory(env, advisory); // Auto-project/milestone matching (#3183): independent of the gate/disposition entirely -- a missed or @@ -14296,6 +14302,8 @@ export async function buildAuthorizedPrActionAdvisory( duplicateWinnerEnabled: resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode), confirmedNoOpenLinkedIssue, linkedIssueAuthorLogins, + copycatGateMode: settings.copycatGateMode, + copycatGateMinScore: settings.copycatGateMinScore, }); return { repo, advisory, otherOpenPullRequests }; } diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 4f044cca9f..8ad4a59585 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -22,6 +22,7 @@ import type { AdvisoryFinding, AdvisorySeverity, AiReviewLowConfidenceDisposition, + CopycatGateMode, GateRuleMode, IssueRecord, PullRequestFileRecord, @@ -29,6 +30,7 @@ import type { RepositoryRecord, } from "../types"; import type { CollisionCluster, CollisionReport } from "../signals/engine"; +import { resolveCopycatDuplicateSibling } from "../signals/copycat-duplicate"; import { isDuplicateClusterWinnerByClaim } from "../signals/duplicate-winner"; import type { GuardrailPathMatch } from "../signals/change-guardrail"; import { isCodeFile } from "../signals/local-branch"; @@ -376,6 +378,14 @@ export function buildPullRequestAdvisory( * — this is fail-open by construction: the caller only ever sets it true after a live check confirms * every reference is dead, never on ambiguity. */ confirmedNoOpenLinkedIssue?: boolean; + /** #9033: the repo's EFFECTIVE `copycatGateMode`/`copycatGateMinScore` (already resolved by the caller via + * `resolveRepositorySettings` — reward-eligible repos default this to `warn` even with no `.loopover.yml` + * entry, see `settings/copycat-gate-mode.ts`). Used ONLY to decide whether `pr`'s own persisted copycat + * containment match ({@link resolveCopycatDuplicateSibling}) should ALSO count as a duplicate-cluster + * overlap, even when the matched sibling cites a different linked issue (or none). Absent/`off` ⇒ byte- + * identical to before this field existed — the duplicate finding stays linked-issue-overlap only. */ + copycatGateMode?: CopycatGateMode | null | undefined; + copycatGateMinScore?: number | null | undefined; } = {}, ): Advisory { const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown"; @@ -401,7 +411,18 @@ export function buildPullRequestAdvisory( action: "Re-deliver the webhook or wait for the next sync.", }); } else { - addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? [], Boolean(context.confirmedNoOpenLinkedIssue)); + addPullRequestFindings( + repo, + pr, + findings, + context.otherOpenPullRequests ?? [], + Boolean(context.requireLinkedIssue), + Boolean(context.duplicateWinnerEnabled), + context.linkedIssueAuthorLogins ?? [], + Boolean(context.confirmedNoOpenLinkedIssue), + context.copycatGateMode, + context.copycatGateMinScore, + ); } return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined); } @@ -940,8 +961,15 @@ function addRepoFindings(repo: RepositoryRecord, findings: AdvisoryFinding[]): v * the issue and force a close). Absent `changedFiles` data on either side — the common case today, since the * open-PR file-collision enrichment (`enrichOpenPullRequestsWithChangedFiles`, #2653) is deliberately scoped * away from the gate's own `otherOpenPullRequests` input (see its own scoping note in processors.ts) — degrades - * to "uncorroborated", the safe default: it can never manufacture false corroboration from missing data. PURE. */ -function hasDuplicateOverlapCorroboration(pr: PullRequestRecord, otherPr: PullRequestRecord): boolean { + * to "uncorroborated", the safe default: it can never manufacture false corroboration from missing data. + * + * #9033: `otherPr` being the PR's own persisted COPYCAT containment match ALSO corroborates it directly, even + * with no changed-file-path evidence at all — the deterministic containment engine (src/signals/copycat.ts) + * already requires the best-scoring candidate to clear a maintainer-configured similarity threshold with an + * unambiguous (earlier-submission) direction before it ever names a match, which is itself concrete, code-level + * evidence, strictly stronger than a bare path overlap. PURE. */ +function hasDuplicateOverlapCorroboration(pr: PullRequestRecord, otherPr: PullRequestRecord, isCopycatMatch: boolean): boolean { + if (isCopycatMatch) return true; const mineFiles = pr.changedFiles; const theirsFiles = otherPr.changedFiles; if (mineFiles && mineFiles.length > 0 && theirsFiles && theirsFiles.length > 0) { @@ -960,6 +988,8 @@ function addPullRequestFindings( duplicateWinnerEnabled: boolean, linkedIssueAuthorLogins: (string | null | undefined)[], confirmedNoOpenLinkedIssue: boolean, + copycatGateMode: CopycatGateMode | null | undefined, + copycatGateMinScore: number | null | undefined, ): void { if (pr.state !== "open") { findings.push({ @@ -988,9 +1018,19 @@ function addPullRequestFindings( action: "If this PR is intended to solve an issue, link it explicitly in the PR body.", }); } else { - const overlappingPrs = otherOpenPullRequests.filter((otherPr) => + const linkedIssueOverlapPrs = otherOpenPullRequests.filter((otherPr) => otherPr.linkedIssues.some((issueNumber) => pr.linkedIssues.includes(issueNumber)), ); + // #9033: a cross-issue content match — this PR's own persisted copycat containment assessment names an + // OPEN sibling above the configured threshold, EVEN THOUGH the two PRs cite different linked issues (or this + // PR cites none at all). Two colluding accounts filing near-identical fixes under different linked issues + // is exactly the reward-farming gap this closes: without it, neither PR is ever considered a duplicate- + // cluster candidate of the other, regardless of how similar their added code is. + const copycatSibling = resolveCopycatDuplicateSibling(pr, otherOpenPullRequests, copycatGateMode, copycatGateMinScore); + const overlappingPrs = + copycatSibling !== undefined && !linkedIssueOverlapPrs.some((otherPr) => otherPr.number === copycatSibling.number) + ? [...linkedIssueOverlapPrs, copycatSibling] + : linkedIssueOverlapPrs; // Duplicate-winner adjudication (#dup-winner): when the flag is ON and this PR is the earliest observed // linked-issue claimant, SKIP the duplicate finding — suppressing it suppresses the gate failure, so the // winner survives while later claimants keep the finding. Sparse legacy rows fail closed instead of @@ -1003,15 +1043,16 @@ function addPullRequestFindings( // gate that fails solely on it (#9129, see the duplicate-only hold below). A PURELY body-text overlap gets // a SEPARATE, always-non-blocking code (`duplicate_pr_risk_unconfirmed`, see resolveConfiguredGateMode) — // an adversary who cites the same issue number in a throwaway PR body, with no code required, can never - // manufacture a hold or a close through this path. - const corroboratedPrs = overlappingPrs.filter((otherPr) => hasDuplicateOverlapCorroboration(pr, otherPr)); - const uncorroboratedPrs = overlappingPrs.filter((otherPr) => !hasDuplicateOverlapCorroboration(pr, otherPr)); + // manufacture a hold or a close through this path. #9033: the copycat-matched sibling is corroborated by + // construction (isCopycatMatch), regardless of its changed-file overlap. + const corroboratedPrs = overlappingPrs.filter((otherPr) => hasDuplicateOverlapCorroboration(pr, otherPr, otherPr.number === copycatSibling?.number)); + const uncorroboratedPrs = overlappingPrs.filter((otherPr) => !hasDuplicateOverlapCorroboration(pr, otherPr, otherPr.number === copycatSibling?.number)); if (corroboratedPrs.length > 0) { findings.push({ code: "duplicate_pr_risk", severity: "warning", title: "Linked issue overlaps another open PR with corroborating changes", - detail: `Other open pull requests reference the same linked issue set AND show corroborating changed-file overlap or a non-trivial diff: ${corroboratedPrs.map((otherPr) => `#${otherPr.number}`).join(", ")}.`, + detail: `Other open pull requests show corroborating overlap (shared linked issue with changed-file/diff evidence, and/or a content-similarity match): ${corroboratedPrs.map((otherPr) => `#${otherPr.number}`).join(", ")}.`, action: "This looks like a genuine race for the same issue. Coordinate with the other contributor, or wait for a maintainer to triage before assuming priority.", }); } diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index dd94254069..e1a0157971 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -333,6 +333,81 @@ describe("advisory rules", () => { expect(advisory.findings.map((finding) => finding.code)).toContain("duplicate_pr_risk_unconfirmed"); }); + // #9033: a cross-issue copycat containment match must feed the SAME duplicate_pr_risk finding a shared linked + // issue does -- the exact reward-farming gap where two colluding accounts file near-identical fixes under + // DIFFERENT linked issues and both merge independently. + describe("copycat cross-issue duplicate overlap (#9033)", () => { + function candidatePr(over: Partial = {}): PullRequestRecord { + return { + repoFullName: repo.fullName, + number: 12, + title: "Add registry sync", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [4], + ...over, + }; + } + + it("flags a CONFIRMED duplicate risk against a sibling citing a DIFFERENT linked issue, once the copycat containment match clears the threshold", () => { + const pr = candidatePr({ linkedIssues: [4], copycatScore: 92, copycatMatchedPullNumber: 13 }); + const sibling = candidatePr({ number: 13, title: "Alternative registry sync", linkedIssues: [999] }); + + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: [sibling], copycatGateMode: "warn", copycatGateMinScore: 85 }); + + expect(advisory.findings.map((finding) => finding.code)).toContain("duplicate_pr_risk"); + expect(advisory.findings.map((finding) => finding.code)).not.toContain("duplicate_pr_risk_unconfirmed"); + }); + + it("does NOT flag a duplicate risk when copycatGateMode is off, even with a high containment score", () => { + const pr = candidatePr({ linkedIssues: [4], copycatScore: 92, copycatMatchedPullNumber: 13 }); + const sibling = candidatePr({ number: 13, title: "Alternative registry sync", linkedIssues: [999] }); + + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: [sibling], copycatGateMode: "off", copycatGateMinScore: 85 }); + + expect(advisory.findings.map((finding) => finding.code)).not.toContain("duplicate_pr_risk"); + expect(advisory.findings.map((finding) => finding.code)).not.toContain("duplicate_pr_risk_unconfirmed"); + }); + + it("does NOT flag a duplicate risk when the containment score is below the resolved threshold", () => { + const pr = candidatePr({ linkedIssues: [4], copycatScore: 40, copycatMatchedPullNumber: 13 }); + const sibling = candidatePr({ number: 13, title: "Alternative registry sync", linkedIssues: [999] }); + + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: [sibling], copycatGateMode: "warn", copycatGateMinScore: 85 }); + + expect(advisory.findings.map((finding) => finding.code)).not.toContain("duplicate_pr_risk"); + }); + + it("#dup-winner + copycat: the cluster winner is spared even when the overlap is a cross-issue copycat match", () => { + const winner = candidatePr({ number: 12, linkedIssues: [4], linkedIssueClaimedAt: "2026-06-29T10:00:00.000Z", copycatScore: 92, copycatMatchedPullNumber: 13 }); + const laterSibling = candidatePr({ number: 13, title: "Alternative registry sync", linkedIssues: [999], linkedIssueClaimedAt: "2026-06-29T10:01:00.000Z" }); + + const advisory = buildPullRequestAdvisory(repo, winner, { + otherOpenPullRequests: [laterSibling], + duplicateWinnerEnabled: true, + copycatGateMode: "warn", + copycatGateMinScore: 85, + }); + + expect(advisory.findings.map((finding) => finding.code)).not.toContain("duplicate_pr_risk"); + }); + + it("a sibling that is BOTH the linked-issue overlap AND the copycat match is not double-counted", () => { + const pr = candidatePr({ linkedIssues: [4], copycatScore: 92, copycatMatchedPullNumber: 13 }); + const sibling = candidatePr({ number: 13, title: "Alternative registry sync", linkedIssues: [4] }); // SAME linked issue + + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: [sibling], copycatGateMode: "warn", copycatGateMinScore: 85 }); + + const duplicateFindings = advisory.findings.filter((finding) => finding.code === "duplicate_pr_risk"); + expect(duplicateFindings).toHaveLength(1); + expect(duplicateFindings[0]?.detail).toContain("#13"); + expect(duplicateFindings[0]?.detail.match(/#13/g)).toHaveLength(1); + }); + }); + it("#dup-winner: flag OFF + would-be-winner ⇒ duplicate finding STILL present (byte-identical)", () => { const wouldBeWinner: PullRequestRecord = { repoFullName: repo.fullName, From b5f0f01545ff6e2b24829361c571f085e41b59b3 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:39:27 -0700 Subject: [PATCH 4/6] fix(review): raise the copycat candidate bound and rank merged prior art by overlap (#9033) MAX_COPYCAT_CANDIDATES (25) capped how many prior-art candidates got their full patch content fetched and scored -- on an active repo, a copycat's actual match could sit past that cutoff and never get compared at all. Raised to 50 (still a bounded, cheap, single-repo-scoped set of D1 reads, not external API calls). The recently-merged phase also now ranks its path-overlapping candidates by OVERLAP COUNT descending before spending its share of the budget, rather than merely the most recently merged ones that happen to overlap at all -- a stronger prior signal of a genuine match, at no extra fetch cost since changedFiles is already loaded. --- src/queue/copycat-detection.ts | 41 ++++++++++++++++++++++------- test/unit/copycat-detection.test.ts | 33 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/queue/copycat-detection.ts b/src/queue/copycat-detection.ts index 08135351c8..b4aaf17d0d 100644 --- a/src/queue/copycat-detection.ts +++ b/src/queue/copycat-detection.ts @@ -16,8 +16,16 @@ export function shouldCollectCopycatEvidence(settings: Pick { - // Each phase's candidate list is already bounded by MAX_COPYCAT_CANDIDATES (25) before any fetch starts, so + // Each phase's candidate list is already bounded by MAX_COPYCAT_CANDIDATES before any fetch starts, so // fetching every candidate's files CONCURRENTLY within a phase (rather than one listPullRequestFiles round- - // trip at a time, up to 50 sequential DB reads total across both phases) does not raise the worst-case fan- - // out -- it only removes the artificial serialization between independent reads. Promise.all preserves each - // phase's original candidate order in its result array regardless of resolution order, so priorArt's - // ordering (open siblings first, then merged) is unchanged. + // trip at a time, up to 2x MAX_COPYCAT_CANDIDATES sequential DB reads total across both phases) does not raise + // the worst-case fan-out -- it only removes the artificial serialization between independent reads. + // Promise.all preserves each phase's original candidate order in its result array regardless of resolution + // order, so priorArt's ordering (open siblings first, then merged) is unchanged. const openCandidates = args.otherOpenPullRequests.slice(0, MAX_COPYCAT_CANDIDATES); const openPriorArt: CopycatPriorArtCandidate[] = await Promise.all( openCandidates.map(async (sibling) => { @@ -80,9 +97,15 @@ export async function runCopycatAssessment( if (remainingBudget > 0) { const changedPathSet = new Set(args.files.map((file) => file.path)); const recentMerged = await listRecentMergedPullRequests(env, args.repoFullName).catch(() => []); + // #9033: rank path-overlapping candidates by OVERLAP COUNT (descending) before spending the bounded budget -- + // more shared changed-file paths is a stronger prior signal of a genuine match than mere merge recency. A + // stable sort keeps candidates tied on overlap count in their original (mergedAt-desc) order. const overlapping = recentMerged - .filter((candidate) => candidate.changedFiles.some((path) => changedPathSet.has(path))) - .slice(0, remainingBudget); + .map((candidate) => ({ candidate, overlapCount: candidate.changedFiles.filter((path) => changedPathSet.has(path)).length })) + .filter((entry) => entry.overlapCount > 0) + .sort((left, right) => right.overlapCount - left.overlapCount) + .slice(0, remainingBudget) + .map((entry) => entry.candidate); mergedPriorArt = await Promise.all( overlapping.map(async (candidate) => { const files = await listPullRequestFiles(env, args.repoFullName, candidate.number).catch(() => []); diff --git a/test/unit/copycat-detection.test.ts b/test/unit/copycat-detection.test.ts index 4177775efc..b7041fb3e2 100644 --- a/test/unit/copycat-detection.test.ts +++ b/test/unit/copycat-detection.test.ts @@ -136,6 +136,39 @@ describe("runCopycatAssessment", () => { expect(result.matches.map((m) => m.pullNumber)).toEqual([7]); }); + // #9033: within a bounded merged-candidate budget, the STRONGEST path-overlap signal is chosen over the most + // recent one -- a candidate with more shared changed-file paths is a better prior-art guess than a candidate + // that merely happens to be newer. + it("prioritizes recently-merged candidates by changed-file OVERLAP COUNT, not mere recency, within the bounded budget (#9033)", async () => { + const env = createTestEnv(); + // Shrink the remaining merged-candidate budget to 2 by filling the open-sibling slice with + // MAX_COPYCAT_CANDIDATES - 2 unseeded open PRs (no content -- listPullRequestFiles returns [] for them). + const openSiblings = Array.from({ length: MAX_COPYCAT_CANDIDATES - 2 }, (_, i) => openSibling(i + 1, "2026-06-01T00:00:00Z")); + + // #500: single-path overlap, but MOST RECENT (mergedAt latest). + await upsertRecentMergedPullRequest(env, recentMerged(500, "2026-06-04T00:00:00Z", ["src/a.ts"])); + await upsertPullRequestFile(env, file(500, "src/a.ts", "+recent single-overlap content")); + // #501: two-path overlap, but OLDER than #500 -- must still win a budget slot over #500 on overlap count. + await upsertRecentMergedPullRequest(env, recentMerged(501, "2026-06-01T00:00:00Z", ["src/a.ts", "src/b.ts"])); + await upsertPullRequestFile(env, file(501, "src/a.ts", "+older double-overlap content a")); + // #502: single-path overlap, OLDEST -- should lose its budget slot to #501's stronger overlap signal. + await upsertRecentMergedPullRequest(env, recentMerged(502, "2026-05-30T00:00:00Z", ["src/a.ts"])); + await upsertPullRequestFile(env, file(502, "src/a.ts", "+oldest single-overlap content")); + + const result = await runCopycatAssessment(env, { + repoFullName: REPO, + pr: { number: 100, createdAt: "2026-06-05T00:00:00Z" }, + files: [file(100, "src/a.ts", "some content"), file(100, "src/b.ts", "some other content")], + otherOpenPullRequests: openSiblings, + mode: "block", + minScore: null, + }); + // #501 (2-path overlap) and #500 (1-path, most recent) fill the 2 merged-candidate slots; #502 (1-path, + // oldest, weakest overlap) loses out and is never even fetched/scored. + const mergedMatchNumbers = result.matches.map((m) => m.pullNumber).filter((n) => n >= 500); + expect(mergedMatchNumbers.sort((a, b) => a - b)).toEqual([500, 501]); + }); + it("never acts when the only candidate is the earlier (victim) submission's own later, independent PR — direction excludes it", async () => { const env = createTestEnv(); const sourceLines = "+function add(a, b) {\n+const total = a + b;\n+logger.debug(total);\n+return total;\n+}\n+export default add;"; From af63414fec24621437bf2d6283881b6d8833ac7d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:46:08 -0700 Subject: [PATCH 5/6] test(review): add end-to-end cross-issue copycat reward-farming regression (#9033) Exercises the full confirmed scenario from #9033: two PRs on a reward- eligible repo, different linked issues, near-identical added code. Covers reward-eligible settings resolution, the duplicate-cluster helpers, the gate finding/conclusion, and the actual maintenance disposition -- proving the later cross-issue copycat PR is held for manual review (never merges independently) while the earlier claimant merges untouched on its own merits. Also documents an important existing interaction: #9129 already downgrades any gate failure caused solely by duplicate_pr_risk (even a corroborated one) to a neutral hold rather than an outright close, so the cross-issue copycat match now goes through that identical, precision-safe mechanism instead of bypassing it. --- .../copycat-reward-farming-regression.test.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 test/unit/copycat-reward-farming-regression.test.ts diff --git a/test/unit/copycat-reward-farming-regression.test.ts b/test/unit/copycat-reward-farming-regression.test.ts new file mode 100644 index 0000000000..116ace99f6 --- /dev/null +++ b/test/unit/copycat-reward-farming-regression.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { getDb } from "../../src/db/client"; +import { repositories } from "../../src/db/schema"; +import { upsertRepositorySettings } from "../../src/db/repositories"; +import { + dupWinnerLinkedDuplicateCount, + dupWinnerLinkedDuplicateWinnerNumber, + linkedIssueDuplicatePullRequestRecordsForGate, +} from "../../src/queue/duplicate-detection"; +import { gateCheckPolicy } from "../../src/queue/processors"; +import { buildPullRequestAdvisory, evaluateGateCheck } from "../../src/rules/advisory"; +import { AGENT_LABEL_NEEDS_REVIEW, planAgentMaintenanceActions, type AgentActionPlanInput } from "../../src/settings/agent-actions"; +import { resolveRepositorySettings } from "../../src/settings/repository-settings"; +import type { PullRequestRecord, RepositoryRecord } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +/** + * #9033 CENTERPIECE REGRESSION: the confirmed reward-farming scenario -- two colluding accounts (or one + * attacker with two identities) file near-identical fixes under DIFFERENT linked issues on a reward-eligible + * (subnet-registered) repo. Before this fix: + * 1. copycatGateMode defaulted to "off" for every repo, so the containment engine never ran and never + * persisted a score/match at all unless the repo explicitly opted in via .loopover.yml. + * 2. Even with a persisted match, the duplicate-cluster election and the duplicate_pr_risk gate finding both + * keyed ENTIRELY on shared linkedIssues overlap, so a cross-issue match was invisible to both. + * Result: the second PR merged independently and both earned rewards for one piece of work. + * + * This test exercises the full chain end-to-end: reward-eligible settings resolution -> the duplicate-cluster + * helpers -> the gate advisory/finding -> the actual close/merge disposition -- proving the loser is held/closed + * as a duplicate instead of merging independently, while the winner is untouched and still merges on its own + * merits. + */ +async function seedRegisteredRepo(env: Env, fullName: string): Promise { + const [owner, name] = fullName.split("/") as [string, string]; + await getDb(env.DB) + .insert(repositories) + .values({ fullName, owner, name, isRegistered: true }); +} + +describe("#9033 cross-issue copycat reward-farming regression", () => { + it("holds/closes the later cross-issue copycat PR as a duplicate while the earlier claimant merges untouched", async () => { + const env = createTestEnv(); + const repoFullName = "acme/reward-eligible-repo"; + await seedRegisteredRepo(env, repoFullName); + await upsertRepositorySettings(env, { repoFullName }); + + // The repo never configured gate.copycat.mode in .loopover.yml -- reward-eligibility alone must turn the + // containment engine on, and duplicatePrGateMode's own DB-layer default is already "block". + const settings = await resolveRepositorySettings(env, repoFullName); + expect(settings.copycatGateMode).toBe("warn"); + expect(settings.duplicatePrGateMode).toBe("block"); + + const repo: RepositoryRecord = { + fullName: repoFullName, + owner: "acme", + name: "reward-eligible-repo", + isInstalled: true, + isRegistered: true, + isPrivate: false, + }; + + // PR #10 claims issue #1 first; PR #11 claims a DIFFERENT issue (#2) later, with near-identical added code. + // copycatScore/copycatMatchedPullNumber on #11 simulate what runCopycatAssessment already computed and + // persisted during #11's own gate evaluation (copycat-detection.ts's own responsibility, exercised + // separately in copycat-detection.test.ts) -- this test picks up from that persisted verdict. + const winner: PullRequestRecord = { + repoFullName, + number: 10, + title: "Fix the rate limiter", + state: "open", + authorLogin: "account-one", + authorAssociation: "NONE", + headSha: "sha-10", + labels: [], + linkedIssues: [1], + linkedIssueClaimedAt: "2026-07-20T10:00:00.000Z", + createdAt: "2026-07-20T10:00:00.000Z", + }; + const loser: PullRequestRecord = { + repoFullName, + number: 11, + title: "Patch the throttling bug", + state: "open", + authorLogin: "account-two", + authorAssociation: "NONE", + headSha: "sha-11", + labels: [], + linkedIssues: [2], // a DIFFERENT linked issue than #10 -- the exact evasion this issue describes + linkedIssueClaimedAt: "2026-07-20T11:00:00.000Z", // claimed an hour LATER + createdAt: "2026-07-20T11:00:00.000Z", + copycatScore: 96, + copycatMatchedPullNumber: 10, // the containment engine already named #10 as the earlier original + }; + + // --- Duplicate-cluster helpers now see the cross-issue match --- + const loserSiblings = linkedIssueDuplicatePullRequestRecordsForGate(loser, [winner], settings.copycatGateMode, settings.copycatGateMinScore); + expect(loserSiblings.map((p) => p.number)).toEqual([10]); + expect(dupWinnerLinkedDuplicateCount(loserSiblings, loser.number, loser.linkedIssueClaimedAt, true, loser.createdAt)).toBe(1); + expect(dupWinnerLinkedDuplicateWinnerNumber(loserSiblings, loser.number, loser.linkedIssueClaimedAt, true, loser.createdAt)).toBe(10); + + const winnerSiblings = linkedIssueDuplicatePullRequestRecordsForGate(winner, [loser], settings.copycatGateMode, settings.copycatGateMinScore); + // #10 has no persisted copycat match of its own (it's the original, never accused of copying #11) and no + // shared linked issue with #11 -- so #10 sees no duplicate-cluster sibling at all. + expect(winnerSiblings).toEqual([]); + expect(dupWinnerLinkedDuplicateCount(winnerSiblings, winner.number, winner.linkedIssueClaimedAt, true, winner.createdAt)).toBe(0); + + // --- The gate finding itself: #11 (the loser) fails on duplicate_pr_risk; #10 (the winner) does not --- + const loserAdvisory = buildPullRequestAdvisory(repo, loser, { + otherOpenPullRequests: [winner], + duplicateWinnerEnabled: true, + copycatGateMode: settings.copycatGateMode, + copycatGateMinScore: settings.copycatGateMinScore, + }); + expect(loserAdvisory.findings.map((f) => f.code)).toContain("duplicate_pr_risk"); + const loserGate = evaluateGateCheck(loserAdvisory, gateCheckPolicy(settings)); + // #9129: a gate that fails SOLELY on a (even corroborated) duplicate_pr_risk finding is downgraded to a + // NEUTRAL hold, never an outright close -- this is the SAME precision-safe treatment a genuine same-issue + // duplicate already gets today (a rival PR's own claim can never unilaterally force a close), and #9033's + // cross-issue copycat match now goes through this identical mechanism instead of bypassing it entirely. + expect(loserGate.conclusion).toBe("neutral"); + expect(loserGate.warnings.map((w) => w.code)).toContain("duplicate_pr_risk"); + + const winnerAdvisory = buildPullRequestAdvisory(repo, winner, { + otherOpenPullRequests: [loser], + duplicateWinnerEnabled: true, + copycatGateMode: settings.copycatGateMode, + copycatGateMinScore: settings.copycatGateMinScore, + }); + expect(winnerAdvisory.findings.map((f) => f.code)).not.toContain("duplicate_pr_risk"); + const winnerGate = evaluateGateCheck(winnerAdvisory, gateCheckPolicy(settings)); + expect(winnerGate.conclusion).toBe("success"); + + // --- The actual disposition: the loser is HELD for manual review instead of merging independently; the + // winner MERGES on its own merits, untouched. --- + function planInput(overrides: Partial & Pick): AgentActionPlanInput { + return { + autonomy: { merge: "auto", close: "auto", review_state_label: "auto" }, + autoMaintain: { requireApprovals: 0, mergeMethod: "squash" }, + slopGateMinScore: 60, + changedPaths: [], + hardGuardrailGlobs: [], + authorIsOwner: false, + authorIsAdmin: false, + authorIsAutomationBot: false, + ciState: "passed", + pr: { labels: [], mergeableState: "clean" }, + ...overrides, + }; + } + + const loserPlan = planAgentMaintenanceActions( + planInput({ + conclusion: loserGate.conclusion, + blockerTitles: [], + pr: { + labels: [], + mergeableState: "clean", + linkedDuplicateCount: dupWinnerLinkedDuplicateCount(loserSiblings, loser.number, loser.linkedIssueClaimedAt, true, loser.createdAt), + linkedDuplicateWinnerNumber: dupWinnerLinkedDuplicateWinnerNumber(loserSiblings, loser.number, loser.linkedIssueClaimedAt, true, loser.createdAt), + }, + }), + ); + // The core claim: the loser does NOT merge independently, and is not silently left undecided either -- it is + // held with a manual-review label so a maintainer triages the cross-issue race, exactly like a same-issue race. + expect(loserPlan.some((action) => action.actionClass === "merge")).toBe(false); + expect(loserPlan.some((action) => action.actionClass === "close")).toBe(false); + const loserHold = loserPlan.find((action) => action.actionClass === "label" && action.label === AGENT_LABEL_NEEDS_REVIEW); + expect(loserHold).toBeDefined(); + expect(loserHold?.reason).toContain("verdict=neutral"); + + const winnerPlan = planAgentMaintenanceActions( + planInput({ + conclusion: "success", + blockerTitles: [], + pr: { labels: [], mergeableState: "clean" }, + }), + ); + expect(winnerPlan.some((action) => action.actionClass === "merge")).toBe(true); + expect(winnerPlan.some((action) => action.actionClass === "close")).toBe(false); + }); +}); From 03ef003cca0bf3f7338101acad87435cc48c339d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:49:44 -0700 Subject: [PATCH 6/6] chore(engine): bump @loopover/engine to 3.15.3 for the advisory.ts gate-decision twin (#9033) src/rules/advisory.ts's evaluateGateCheckCore gained a host-only cross-issue copycat corroboration branch (the duplicate-cluster election fix). It intentionally does not mirror into packages/loopover-engine/src/advisory/gate-advisory.ts -- the engine twin is deliberately kept slim (no signals/engine, no PullRequestRecord.copycatScore) so the miner/mcp CLI packages never drag that dependency graph in. Bumps the engine package version (the guard's own documented escape hatch for a one-sided fix, same shape as the precedent manual bump in 6aacf087b), syncing package-lock.json, the miner's expected-engine.version pin, and the release-please manifest alongside it. --- .release-please-manifest.json | 2 +- package-lock.json | 2 +- packages/loopover-engine/package.json | 2 +- packages/loopover-miner/expected-engine.version | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 32368beff6..2fe3bf03fe 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,6 +1,6 @@ { "packages/loopover-mcp": "3.15.2", - "packages/loopover-engine": "3.15.2", + "packages/loopover-engine": "3.15.3", "packages/loopover-miner": "3.15.2", "packages/loopover-ui-kit": "1.2.0" } diff --git a/package-lock.json b/package-lock.json index 4b1b2cceb5..66d7d9b8ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21029,7 +21029,7 @@ }, "packages/loopover-engine": { "name": "@loopover/engine", - "version": "3.15.2", + "version": "3.15.3", "license": "AGPL-3.0-only", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.205", diff --git a/packages/loopover-engine/package.json b/packages/loopover-engine/package.json index 0293938872..5d1b0cbf4d 100644 --- a/packages/loopover-engine/package.json +++ b/packages/loopover-engine/package.json @@ -1,6 +1,6 @@ { "name": "@loopover/engine", - "version": "3.15.2", + "version": "3.15.3", "license": "AGPL-3.0-only", "type": "module", "description": "Shared deterministic engine logic for the LoopOver review stack and loopover-miner.", diff --git a/packages/loopover-miner/expected-engine.version b/packages/loopover-miner/expected-engine.version index 861845e450..b7b6bc30c0 100644 --- a/packages/loopover-miner/expected-engine.version +++ b/packages/loopover-miner/expected-engine.version @@ -1 +1 @@ -3.15.2 +3.15.3