From 6c097b0f2704d134c377d8ab538d892e26548682 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Thu, 25 Jun 2026 02:23:22 -0400 Subject: [PATCH] fix(rules): surface missing-linked-issue in predictor under composite mergeReadiness gate The pre-submission gate predictor derived requireLinkedIssue from gate.linkedIssue alone, omitting the composite mergeReadiness term. Since applyMergeReadinessGate forces the linked-issue sub-gate to block when mergeReadiness is enabled, and the live path collects linked-issue evidence whenever merge-readiness is on (shouldCollectLinkedIssueEvidence), a repo with mergeReadiness:block and linkedIssue unset got a false 'success' prediction while the live gate one-shot auto-closes the PR on the missing-linked-issue blocker. Include the mergeReadiness term so the predictor matches the live gate, and add a regression test pinning both the new blocker path and the no-gate arm. --- src/rules/predicted-gate.ts | 8 +++++++- test/unit/predicted-gate.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index de0d4767fc..2d49c68785 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 dc10a9fb67..0817da50e3 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -105,6 +105,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 },