diff --git a/scripts/features/lib/delivered.mjs b/scripts/features/lib/delivered.mjs index 56e869da4a..72462bc939 100644 --- a/scripts/features/lib/delivered.mjs +++ b/scripts/features/lib/delivered.mjs @@ -37,3 +37,52 @@ export function parseChangelog(text, issueNumber) { } return null; } + +function isExplicitClose(pr, issueNumber) { + const text = `${pr.title ?? ""}\n${pr.body ?? ""}`; + const re = new RegExp(`\\b(closes?|fixes?|fixed|resolves?|resolved)\\s+#${issueNumber}\\b`, "i"); + return re.test(text); +} + +function justMentions(pr, issueNumber) { + const text = `${pr.title ?? ""}\n${pr.body ?? ""}`; + return new RegExp(`#${issueNumber}\\b`).test(text); +} + +export function detectDelivered(issueNumber, signals) { + const { mergedPrs = [], changelog = "", gitCommits = [] } = signals; + + const closesPr = mergedPrs.find((p) => isExplicitClose(p, issueNumber)); + const mentionPr = closesPr || mergedPrs.find((p) => justMentions(p, issueNumber)); + const changelogHit = parseChangelog(changelog, issueNumber); + const gitHit = gitCommits.length > 0 ? gitCommits[0] : null; + + const A = !!closesPr; + const B = !!(mentionPr && !closesPr); + const C = !!changelogHit; + const D = !!gitHit; + + let confidence = "none"; + if (A) confidence = "high"; + else if ((C && D) || (B && C) || (B && D)) confidence = "medium"; + else if (B || C || D) confidence = "low"; + + const evidence = {}; + if (closesPr) { + evidence.pr_merged = { + number: closesPr.number, + merged_at: closesPr.mergedAt, + ref: `closes #${issueNumber}`, + }; + } else if (mentionPr) { + evidence.pr_merged = { + number: mentionPr.number, + merged_at: mentionPr.mergedAt, + ref: `mentions #${issueNumber}`, + }; + } + if (changelogHit) evidence.changelog_section = changelogHit.section; + if (gitHit) evidence.git_commits = gitCommits.slice(0, 5).map((c) => c.hash); + + return { confidence, evidence }; +} diff --git a/tests/unit/feature-triage/delivered.test.mjs b/tests/unit/feature-triage/delivered.test.mjs index 316e337dba..d4d964d859 100644 --- a/tests/unit/feature-triage/delivered.test.mjs +++ b/tests/unit/feature-triage/delivered.test.mjs @@ -47,3 +47,67 @@ describe("parseChangelog", () => { assert.match(r.line, /#980/); }); }); + +import { detectDelivered } from "../../../scripts/features/lib/delivered.mjs"; + +const CLOSES_PR = { + number: 2380, + title: "feat: add native playground", + body: "closes #1046", + mergedAt: "2026-03-10T00:00:00Z", + mergeCommit: { oid: "abc1234" }, +}; +const MENTION_PR = { + number: 2381, + title: "Improve foo", + body: "Related to #1046 and others", + mergedAt: "2026-03-12T00:00:00Z", + mergeCommit: { oid: "def5678" }, +}; + +describe("detectDelivered", () => { + it("HIGH confidence when PR merged with 'closes #N'", () => { + const r = detectDelivered(1046, { + mergedPrs: [CLOSES_PR], + changelog: "", + gitCommits: [], + }); + assert.equal(r.confidence, "high"); + assert.equal(r.evidence.pr_merged.number, 2380); + assert.match(r.evidence.pr_merged.ref, /closes/i); + }); + + it("MEDIUM confidence when PR-mention + CHANGELOG", () => { + const r = detectDelivered(1046, { + mergedPrs: [MENTION_PR], + changelog: "## [3.7.2]\n- Foo (#1046)\n", + gitCommits: [], + }); + assert.equal(r.confidence, "medium"); + assert.equal(r.evidence.pr_merged.number, 2381); + assert.equal(r.evidence.changelog_section, "## [3.7.2]"); + }); + + it("MEDIUM confidence when CHANGELOG + git log", () => { + const r = detectDelivered(1046, { + mergedPrs: [], + changelog: "## [3.7.2]\n- Foo (#1046)\n", + gitCommits: [{ hash: "abc", date: new Date("2026-03-10"), subject: "feat: thing #1046" }], + }); + assert.equal(r.confidence, "medium"); + }); + + it("LOW confidence when only CHANGELOG", () => { + const r = detectDelivered(1046, { + mergedPrs: [], + changelog: "## [3.7.2]\n- Foo (#1046)\n", + gitCommits: [], + }); + assert.equal(r.confidence, "low"); + }); + + it("NONE when no signals", () => { + const r = detectDelivered(1046, { mergedPrs: [], changelog: "", gitCommits: [] }); + assert.equal(r.confidence, "none"); + }); +});