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 src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1399,7 +1399,7 @@ async function maybeRunAgentMaintenance(
// reason ("duplicate of another open PR" via agent-actions when count > 0). When the flag is ON and this
// PR is the cluster winner, force the count to 0 so the winner's close reason OMITS the duplicate cause
// (it can still close on its own merits — CI/conflict/blockers). Flag-OFF short-circuits ⇒ the real
// count is used (byte-identical). Sparse legacy rows fall back to PR-number election.
// count is used (byte-identical). Sparse legacy rows fail closed so duplicate evidence remains visible.
linkedDuplicateCount: dupWinnerLinkedDuplicateCount(
linkedIssueDuplicatePullRequestRecordsForGate(pr, otherOpenPullRequests),
pr.number,
Expand Down
3 changes: 2 additions & 1 deletion src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,8 @@ function addPullRequestFindings(
);
// 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 fall back to PR-number election.
// winner survives while later claimants keep the finding. Sparse legacy rows fail closed instead of
// suppressing duplicate evidence with arbitrary PR-number ordering.
// Flag-OFF (default) short-circuits ⇒ the finding is pushed exactly as before (byte-identical).
if (overlappingPrs.length > 0 && !(duplicateWinnerEnabled && isDuplicateClusterWinnerByClaim(pr, overlappingPrs))) {
findings.push({
Expand Down
8 changes: 4 additions & 4 deletions src/signals/duplicate-winner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* When several OPEN PRs link the same issue (a duplicate cluster), the legacy behavior gate-blocks +
* auto-closes EVERY sibling as a duplicate — no winner survives. With the flag ON, exactly ONE winner is
* spared: the earliest observed linked-issue claimant. Sparse legacy rows that do not yet have claim timing
* fall back to PR-number election so migrated clusters do not keep every sibling blocked. Only the LOSERS are
* fail closed so unknown ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are
* blocked/closed; the winner still must pass CI / conflict / gate / linked-issue / slop on its OWN merits.
*
* This module is PURE — no IO, no Date, no random — so the same inputs always yield the same verdict and the
Expand Down Expand Up @@ -38,15 +38,15 @@ export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: n

/**
* True iff `pr` is the earliest known linked-issue claimant in the open duplicate cluster. Sparse legacy rows
* fall back to the original PR-number election; ties between known claim times also use PR number.
* fail closed; ties between known claim times use PR number.
*/
export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean {
if (openSiblings.length === 0) return true;
const prClaim = claimTimeMs(pr.linkedIssueClaimedAt);
if (prClaim === null) return isDuplicateClusterWinner(pr.number, openSiblings.map((sibling) => sibling.number));
if (prClaim === null) return false;
for (const sibling of openSiblings) {
const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt);
if (siblingClaim === null) return isDuplicateClusterWinner(pr.number, openSiblings.map((other) => other.number));
if (siblingClaim === null) return false;
if (siblingClaim < prClaim) return false;
if (siblingClaim === prClaim && sibling.number < pr.number) return false;
}
Expand Down
6 changes: 3 additions & 3 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4233,8 +4233,8 @@ export function buildPublicPrIntelligenceComment(args: {
const hardLinkedIssueBlock =
args.settings.linkedIssueGateMode === "block" && args.pr.linkedIssues.length === 0 && !hasClearNoIssueRationale(args.pr);
// Duplicate-winner adjudication (#dup-winner): when the flag is ON and this PR is the earliest observed
// linked-issue claimant, do NOT hard-block it as a duplicate — only the losers block. Sparse legacy rows fall
// back to PR-number election so migrated clusters do not all stay blocked.
// linked-issue claimant, do NOT hard-block it as a duplicate — only the losers block. Sparse legacy rows fail
// closed so unknown ordering cannot suppress duplicate evidence.
const hardDuplicateBlock =
args.settings.duplicatePrGateMode === "block" &&
linkedDuplicatePrs.length > 0 &&
Expand Down Expand Up @@ -4473,7 +4473,7 @@ export function buildPublicPrPanelSignalRows(args: {
const gateEnabled = args.settings.gateCheckMode === "enabled";
const hardLinkedIssueBlock = args.settings.linkedIssueGateMode === "block" && args.pr.linkedIssues.length === 0 && !hasClearNoIssueRationale(args.pr);
// Duplicate-winner adjudication (#dup-winner): suppress the earliest known claimant's hard-duplicate block
// (see the comment builder). Sparse legacy rows fall back to PR-number election; flag-OFF keeps legacy behavior.
// (see the comment builder). Sparse legacy rows fail closed; flag-OFF keeps legacy behavior.
const hardDuplicateBlock =
args.settings.duplicatePrGateMode === "block" &&
linkedDuplicatePrs.length > 0 &&
Expand Down
8 changes: 4 additions & 4 deletions test/unit/duplicate-winner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ describe("isDuplicateClusterWinnerByClaim (#dup-winner claim election)", () => {
expect(isDuplicateClusterWinnerByClaim(claim(13, "2026-06-29T10:00:00.000Z"), [claim(12, "2026-06-29T10:00:00.000Z")])).toBe(false);
});

it("falls back to PR-number election when sparse legacy rows lack claim timestamps", () => {
expect(isDuplicateClusterWinnerByClaim(claim(12, null), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(true);
it("fails closed when sparse legacy rows lack claim timestamps", () => {
expect(isDuplicateClusterWinnerByClaim(claim(12, null), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(false);
expect(isDuplicateClusterWinnerByClaim(claim(13, "2026-06-29T10:00:00.000Z"), [claim(12, null)])).toBe(false);
});

it("treats invalid claim timestamps as sparse rows for the PR-number fallback", () => {
expect(isDuplicateClusterWinnerByClaim(claim(12, "not-a-date"), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(true);
it("fails closed when sparse legacy rows have invalid claim timestamps", () => {
expect(isDuplicateClusterWinnerByClaim(claim(12, "not-a-date"), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(false);
expect(isDuplicateClusterWinnerByClaim(claim(13, "2026-06-29T10:00:00.000Z"), [claim(12, "not-a-date")])).toBe(false);
});
});
Expand Down
Loading