diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index 90fce89be4..000e8db7f6 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -157,7 +157,13 @@ export function buildPredictedGateVerdict(args: { // Linked-issue finding is surfaced when the repo's public policy treats it as anything but `off`, so the // gate can evaluate it; evaluateGateCheck decides whether it actually blocks (block) or stays advisory. - const requireLinkedIssue = gate.linkedIssue !== null && gate.linkedIssue !== "off"; + // The composite mergeReadiness gate forces the linked-issue sub-gate on (applyMergeReadinessGate), and the + // live path collects linked-issue evidence whenever merge-readiness is enabled (shouldCollectLinkedIssueEvidence, + // queue/processors.ts), so the predictor must surface the finding under mergeReadiness too — otherwise a + // `mergeReadiness:block` repo with linkedIssue unset predicts a false success while the live gate one-shot + // closes the PR on the missing-linked-issue blocker. (#merge-readiness-parity) + const requireLinkedIssue = + (gate.linkedIssue !== null && gate.linkedIssue !== "off") || (gate.mergeReadiness !== null && gate.mergeReadiness !== "off"); // `duplicateWinnerEnabled` is INTENTIONALLY omitted (#dup-winner): the prospective PR is synthetic #0, but a // real new PR opened into an existing duplicate cluster gets the HIGHEST number ⇒ it is always a duplicate // LOSER, never the winner. So the predictor must keep showing the duplicate finding (the honest pre-submit diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index b195a0ccd4..d17d15f4ca 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -113,6 +113,21 @@ describe("buildPredictedGateVerdict", () => { expect(result.blockers.some((b) => b.code === "duplicate_pr_risk")).toBe(true); }); + it("surfaces the missing-linked-issue blocker under composite mergeReadiness even when linkedIssue is unset (#merge-readiness-parity)", () => { + // mergeReadiness:block forces the composite linked-issue sub-gate to block; the live gate collects + // linked-issue evidence whenever merge-readiness is on (shouldCollectLinkedIssueEvidence), so the + // predictor must surface the finding here too — otherwise it shows a false success while the live gate + // one-shot auto-closes the PR. linkedIssue is left unset (null), so only the mergeReadiness term applies. + const blocked = verdict({ gate: { duplicates: "off", mergeReadiness: "block" }, input: { body: "no issue here", linkedIssues: [] }, issues: [] }); + expect(blocked.conclusion).toBe("failure"); + expect(blocked.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); + + // With neither linkedIssue nor mergeReadiness set, no missing-linked-issue finding is created (the + // false/false arm of the new condition) — matching the live gate, which collects no linked-issue evidence. + const noGate = verdict({ gate: { duplicates: "off" }, input: { body: "no issue here", linkedIssues: [] }, issues: [] }); + expect(noGate.blockers.some((b) => b.code === "missing_linked_issue")).toBe(false); + }); + it("honors public gate.firstTimeContributorGrace with predicted author history", () => { const newcomer = verdict({ gate: { duplicates: "block", firstTimeContributorGrace: true },