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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/loopover-engine/package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion packages/loopover-miner/expected-engine.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.15.2
3.15.3
41 changes: 32 additions & 9 deletions src/queue/copycat-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@ export function shouldCollectCopycatEvidence(settings: Pick<RepositorySettings,
/** Bound on how many prior-art candidates get their full patch content fetched and scored — keeps a single
* gate evaluation's extra DB reads bounded regardless of repo activity, per #1969's own "bounded, fail-safe,
* precision-first" requirement. Open siblings (a live "someone raced my PR" case) are prioritized over
* historical merged PRs; the remainder of the budget goes to recently-merged candidates. */
export const MAX_COPYCAT_CANDIDATES = 25;
* historical merged PRs; the remainder of the budget goes to recently-merged candidates.
*
* #9033: raised from 25 to 50 — on an active reward-eligible repo, a copycat's actual match could be candidate
* #26+ and never get compared at all, the exact gap that let a cross-issue reward-farming duplicate evade
* detection. 50 doubles the headroom (worst case: up to 50 concurrent `listPullRequestFiles` reads per phase,
* a single-repo-scoped indexed D1 read each, not an external API call, so this stays cheap and bounded) while
* the recently-merged phase ALSO now spends its share of the budget on the candidates most likely to actually
* match (see `runCopycatAssessment`'s own doc comment on the overlap-count-sorted pre-filter) rather than
* merely the most recent ones that happen to touch an overlapping path at all. */
export const MAX_COPYCAT_CANDIDATES = 50;

/** Drop generated/lockfile/vendored files from a copycat comparison, reusing review-diff.ts's own
* `diffFilePriority` classification (tier 4 = lockfiles/dist/build/out/coverage/vendor/node_modules) rather
Expand Down Expand Up @@ -49,6 +57,15 @@ export function comparableAddedLines(
* overlap with the current PR (cheap — `RecentMergedPullRequestRecord.changedFiles` is already loaded) BEFORE
* their more expensive patch content is fetched at all, so a candidate that could not possibly overlap never
* costs an extra `listPullRequestFiles` read.
*
* #9033: `listRecentMergedPullRequests` can return up to 200 rows, and MAX_COPYCAT_CANDIDATES (the fetch budget)
* is smaller than that on an active repo — so which of the path-overlapping candidates get the expensive fetch
* matters. Rather than merely taking the `remainingBudget` MOST-RECENT overlapping candidates (the previous
* `mergedAt`-desc order, inherited from `listRecentMergedPullRequests`'s own query), they are re-sorted by
* OVERLAP COUNT (how many changed-file paths they actually share with this PR) descending first — more shared
* paths is a stronger prior signal of a real match than mere recency, and this is still a cheap in-memory sort
* over already-loaded `changedFiles` data, not an extra fetch. `Array.prototype.sort` is stable, so candidates
* tied on overlap count keep their original recency order.
*/
export async function runCopycatAssessment(
env: Env,
Expand All @@ -61,12 +78,12 @@ export async function runCopycatAssessment(
minScore: RepositorySettings["copycatGateMinScore"];
},
): Promise<CopycatAssessment> {
// 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) => {
Expand All @@ -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(() => []);
Expand Down
50 changes: 42 additions & 8 deletions src/queue/duplicate-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -71,23 +72,33 @@ 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,
installationId: number | null,
repoFullName: string,
pr: PullRequestRecord,
otherOpenPullRequests: PullRequestRecord[],
settings: Pick<RepositorySettings, "duplicateWinnerMode">,
settings: Pick<RepositorySettings, "duplicateWinnerMode" | "copycatGateMode" | "copycatGateMinScore">,
): Promise<PullRequestRecord[]> {
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 =
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,8 @@ export async function sweepRepoRegate(
duplicateWinnerEnabled,
linkedIssueAuthorLogins,
confirmedNoOpenLinkedIssue,
copycatGateMode: settings.copycatGateMode,
copycatGateMinScore: settings.copycatGateMinScore,
});
const gate = evaluateGateCheck(
advisory,
Expand Down Expand Up @@ -3460,7 +3462,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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -10237,6 +10243,8 @@ async function maybePublishPrPublicSurface(
const linkedDuplicatePrsForGate = linkedIssueDuplicatePullRequestRecordsForGate(
pr,
otherOpenPullRequests,
settings.copycatGateMode,
settings.copycatGateMinScore,
);
const duplicateWinnerEnabled = resolveDuplicateWinnerEnabled(isDuplicateWinnerEnabledGlobally(env), settings.duplicateWinnerMode);
const isDupWinner =
Expand Down Expand Up @@ -14294,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 };
}
Expand Down
Loading
Loading