File size: 3,244 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/**
 * Delivery detection: PR merged + CHANGELOG + git log, with confidence grading.
 */

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;
    // Match #N with word boundary: look for needle followed by non-word char or end
    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",
  };
}