| |
| |
|
|
|
|
| const VERSION_HEADER_RE = /^##\s+\[?(\d+\.\d+\.\d+)\]?/;
|
|
|
| export function parseChangelog(text, issueNumber) {
|
| if (typeof text !== "string") return null;
|
| if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null;
|
| const needle = `#${issueNumber}`;
|
| const lines = text.split("\n");
|
|
|
| let currentSection = null;
|
| let currentVersion = null;
|
| for (const line of lines) {
|
| const headerMatch = line.match(VERSION_HEADER_RE);
|
| if (headerMatch) {
|
| currentSection = line.trim();
|
| currentVersion = headerMatch[1];
|
| continue;
|
| }
|
| if (!currentSection) continue;
|
|
|
| const idx = line.indexOf(needle);
|
| if (idx !== -1) {
|
| const nextIdx = idx + needle.length;
|
| const nextChar = line[nextIdx];
|
| const isWordBoundary = nextIdx >= line.length || /\W/.test(nextChar);
|
| if (isWordBoundary) {
|
| return {
|
| section: currentSection,
|
| version: currentVersion,
|
| line: line.trim(),
|
| };
|
| }
|
| }
|
| }
|
| 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 };
|
| }
|
|
|
| export function resolveVersion(mergedAt, tagsByDate, currentReleaseBranch) {
|
| const mergedTime = mergedAt instanceof Date ? mergedAt.getTime() : new Date(mergedAt).getTime();
|
| const after = tagsByDate.find((t) => t.date.getTime() >= mergedTime);
|
| if (after) {
|
| return { version: after.name, version_source: "tag_after_merge" };
|
| }
|
| return {
|
| version: currentReleaseBranch || "unreleased",
|
| version_source: "branch_unreleased",
|
| };
|
| }
|
|
|