diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index de0d4767f..402509554 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -169,7 +169,10 @@ export function buildPredictedGateVerdict(args: { // from the snapshot, never a live fetch. (#self-authored-parity) const issueAuthorByNumber = new Map(issues.filter((issue) => issue.repoFullName === input.repoFullName).map((issue) => [issue.number, issue.authorLogin ?? null])); const linkedIssueAuthorLogins = syntheticPr.linkedIssues.map((issueNumber) => issueAuthorByNumber.get(issueNumber) ?? null); - const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: pullRequests, requireLinkedIssue, linkedIssueAuthorLogins }); + // Mirror the live gate (listOtherOpenPullRequests): a closed/merged PR sharing a linked issue must not fire + // duplicate_pr_risk. authorHistory below still needs every state for its grace counts. + const openSiblings = pullRequests.filter((otherPr) => otherPr.state === "open"); + const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: openSiblings, requireLinkedIssue, linkedIssueAuthorLogins }); // Deterministic pre-merge checks parity (#11/#18): the LIVE gate enforces the repo's `review.pre_merge_checks` // (from the SAME public .gittensory.yml the predictor already reads). With the PR's changed paths supplied, diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index dc10a9fb6..76e019a4a 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -65,6 +65,20 @@ describe("buildPredictedGateVerdict", () => { expect(result.blockers.some((b) => b.code === "duplicate_pr_risk")).toBe(false); }); + it("does NOT raise duplicate_pr_risk for closed/merged siblings sharing the linked issue (open-only parity with the live gate)", () => { + // A merged PR and an abandoned closed PR both share the new PR's linked issue, but neither is still + // competing, so the predictor must not flag a duplicate. + const result = verdict({ + gate: { duplicates: "block" }, + pullRequests: [ + { ...openPr(100, "Earlier upload retry", [7], "someone-else"), state: "merged", mergedAt: "2026-06-01T00:00:00.000Z" }, + { ...openPr(101, "Abandoned upload retry", [7], "someone-else"), state: "closed" }, + ], + }); + expect(result.blockers.some((b) => b.code === "duplicate_pr_risk")).toBe(false); + expect(result.conclusion).toBe("success"); + }); + it("predicts a BLOCK for a missing linked issue only when linkedIssue:block", () => { const blocked = verdict({ gate: { linkedIssue: "block" }, input: { body: "no issue here", linkedIssues: [] }, issues: [] }); expect(blocked.conclusion).toBe("failure");