Class: Kettle::Dev::ChangelogCLI

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/dev/changelog_cli.rb,
sig/kettle/dev/changelog_cli.rbs

Overview

CLI for updating CHANGELOG.md with new version sections

Constant Summary collapse

UNRELEASED_SECTION_HEADING =

Returns:

  • (String)
"[Unreleased]:"
/^\s*\[[^\]]+\]:\s+\S+/
DEEP_HEADING_RE =

Matches an ATX heading at H4 or deeper (####, #####, ...)

/^\#{4,}\s/

Instance Method Summary collapse

Constructor Details

#initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false) ⇒ ChangelogCLI

Initialize the changelog CLI Sets up paths for CHANGELOG.md and coverage.json

Parameters:

  • strict (Boolean) (defaults to: true) —

    when true (default), require coverage and yard data; raise errors if unavailable

  • enforce_coverage_thresholds (Boolean) (defaults to: true) —

    when true, fail strict coverage generation below project thresholds

  • update_prep (Boolean) (defaults to: false) —

    when true, update the most recent prepared release section in place



27
28
29
30
31
32
33
34
# File 'lib/kettle/dev/changelog_cli.rb', line 27

def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false)
  @root = Kettle::Dev::CIHelpers.project_root
  @changelog_path = File.join(@root, "CHANGELOG.md")
  @coverage_path = File.join(@root, "coverage", "coverage.json")
  @strict = strict
  @enforce_coverage_thresholds = enforce_coverage_thresholds
  @update_prep = update_prep
end

Instance Method Details

#abort(msg) ⇒ void

This method returns an undefined value.

Abort with error message

Parameters:

  • msg (String)


21
22
23
# File 'sig/kettle/dev/changelog_cli.rbs', line 21

def abort(msg)
  Kettle::Dev::ExitAdapter.abort(msg)
end

#convert_heading_tag_suffix_to_list(text) ⇒ String

Convert legacy heading tag suffix to list format

Parameters:

  • text (String)

Returns:

  • (String)


684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
# File 'lib/kettle/dev/changelog_cli.rb', line 684

def convert_heading_tag_suffix_to_list(text)
  lines = text.lines
  # Build a set of versions that have a tag reference (e.g., "[1.2.3t]: ...").
  # IMPORTANT: Only scan the footer link-ref block (starting at the [Unreleased]: line)
  # to avoid accidentally picking up body content.
  scan_start = lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) } || lines.length
  t_versions = {}
  non_t_tag_refs = {}
  lines[scan_start..-1].to_a.each do |l|
    # Case A: explicit tag ref key like [1.2.3t]: ...
    if (m = l.match(/^\[(\d+\.\d+\.\d+)t\]:\s+(\S+)/))
      t_versions[m[1]] = true
      next
    end
    # Case B: non-t ref that nevertheless points to a tag URL (GitHub or GitLab)
    if (m2 = l.match(/^\[(\d+\.\d+\.\d+)\]:\s+(\S+)/))
      url = m2[2]
      # Accept only when the URL clearly points to a tag for the SAME version
      # Support both GitHub and GitLab style tag URLs
      if (murl = url.match(%r{/(?:releases/)?tags?/v(\d+\.\d+\.\d+)}i))
        version_in_url = murl[1]
        if version_in_url == m2[1]
          non_t_tag_refs[m2[1]] = url
        end
      end
    end
  end
  # Any version that has either explicit t-ref or a non-t tag-ref is considered tagged
  tag_ref_versions = {}
  t_versions.keys.each { |v| tag_ref_versions[v] = true }
  non_t_tag_refs.keys.each { |v| tag_ref_versions[v] = true }

  out = []
  i = 0
  while i < lines.length
    line = lines[i]
    # Case 1: Heading contains legacy tag suffix we should convert
    m = line.match(/^## \[(\d+\.\d+\.\d+)\](.*)\(\[tag\]\[(\d+\.\d+\.\d+)t\]\)\s*$/i)
    if m && m[1] == m[3]
      ver = m[1]
      middle = m[2]
      new_heading = ("## [#{ver}]" + middle).rstrip + "\n"
      out << new_heading
      # If the next non-blank line is already a TAG list item, don't add another
      k = i + 1
      k += 1 while k < lines.length && lines[k].strip == ""
      unless k < lines.length && lines[k].lstrip.start_with?("- TAG:")
        out << "\n"
        out << "- TAG: [v#{ver}][#{ver}t]\n"
        out << "\n"
      end
      # Skip any existing blank lines following the heading to avoid duplicate spacing
      i = k
      next
    end

    # Case 2: Heading does NOT contain suffix, but a matching tag ref exists; ensure a TAG list item
    if (m2 = line.match(/^## \[(\d+\.\d+\.\d+)\](.*)$/))
      ver2 = m2[1]
      # Skip Unreleased heading and non-release headings
      unless ver2.nil?
        k = i + 1
        k += 1 while k < lines.length && lines[k].strip == ""
        needs_tag = tag_ref_versions[ver2] && !(k < lines.length && lines[k].lstrip.start_with?("- TAG:"))
        if needs_tag
          out << (line.end_with?("\n") ? line : line + "\n")
          out << "\n"
          out << "- TAG: [v#{ver2}][#{ver2}t]\n"
          out << "\n"
          i = k
          next
        end
      end
    end

    # Footer duplication: if we are in the footer block and encounter a non-t tag-ref
    # without a matching t-ref, emit the t-ref immediately after with the same URL.
    if i >= scan_start
      if (mref = line.match(/^\[(\d+\.\d+\.\d+)\]:\s+(\S+)/))
        vref = mref[1]
        mref[2]
        if non_t_tag_refs[vref] && !t_versions[vref]
          out << line
          out << "[#{vref}t]: #{non_t_tag_refs[vref]}\n"
          t_versions[vref] = true
          i += 1
          next
        end
      end
    end

    out << line
    i += 1
  end
  out.join
end

#coverage_lines ⇒ [String?, String?]

Get coverage lines from coverage.json

Returns:

  • ([String?, String?])


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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'sig/kettle/dev/changelog_cli.rbs', line 36

def coverage_lines
  if @strict
    # Always generate fresh coverage data in strict mode
    # Delete old coverage files to ensure we get current data
    coverage_dir = File.dirname(@coverage_path)
    if Dir.exist?(coverage_dir)
      puts "Cleaning old coverage data from #{Kettle::Dev.display_path(coverage_dir)}..."
      Dir.glob(File.join(coverage_dir, "*")).each do |file|
        File.delete(file) if File.file?(file)
      end
    end

    puts "Generating fresh coverage data by running: bundle exec kettle-test"

    success = system(changelog_coverage_env, "bundle", "exec", "kettle-test", chdir: @root)

    unless success
      raise "bundle exec kettle-test failed with exit status #{$?.exitstatus || "unknown"}"
    end

    puts "Coverage generation complete."

    ensure_changelog_coverage_json!
  else
    # Non-strict mode: check if coverage.json exists, warn if not
    unless File.file?(@coverage_path)
      warn(coverage_json_missing_message)
      warn("Run: K_SOUP_COV_FORMATTERS=json bundle exec kettle-test to generate it")
      return [nil, nil]
    end
  end

  # Parse the coverage data
  data = JSON.parse(File.read(@coverage_path))
  files = data["coverage"] || {}
  file_count = 0
  total_lines = 0
  covered_lines = 0
  total_branches = 0
  covered_branches = 0
  files.each_value do |h|
    lines = h["lines"] || []
    line_relevant = lines.count { |x| x.is_a?(Integer) }
    line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
    if line_relevant > 0
      file_count += 1
      total_lines += line_relevant
      covered_lines += line_covered
    end
    branches = h["branches"] || []
    branches.each do |b|
      next unless b.is_a?(Hash)

      cov = b["coverage"]
      next unless cov.is_a?(Numeric)

      total_branches += 1
      covered_branches += 1 if cov > 0
    end
  end
  line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
  branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
  line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
  branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
  [line_str, branch_str]
rescue JSON::ParserError => e
  if @strict
    raise "Failed to parse coverage JSON at #{@coverage_path}: #{e.class}: #{e.message}"
  else
    warn("Failed to parse coverage: #{e.class}: #{e.message}")
    [nil, nil]
  end
rescue => e
  if @strict
    raise "Failed to get coverage data: #{e.class}: #{e.message}"
  else
    warn("Failed to get coverage data: #{e.class}: #{e.message}")
    [nil, nil]
  end
end

#detect_initial_compare_base(_lines = nil) ⇒ String

Detect initial compare base from changelog

Parameters:

  • lines (Array[String])

Returns:

  • (String)


947
948
949
950
951
952
953
954
955
956
957
958
959
# File 'lib/kettle/dev/changelog_cli.rb', line 947

def detect_initial_compare_base(_lines = nil)
  env_sha = ENV.fetch("KETTLE_CHANGELOG_INITIAL_SHA", nil)
  return env_sha.strip if env_sha && !env_sha.strip.empty?

  sha = git_root_commit
  return sha if sha

  warn(
    "Could not determine initial git root commit; using HEAD^ as compare base. " \
      "Set KETTLE_CHANGELOG_INITIAL_SHA to override."
  )
  "HEAD^"
end

#detect_previous_version(after_text) ⇒ String?

Detect previous version from after text

Parameters:

  • after_text (String)

Returns:

  • (String, nil)


30
31
32
33
34
35
36
# File 'sig/kettle/dev/changelog_cli.rbs', line 30

def detect_previous_version(after_text)
  # after_text begins with the first released section following Unreleased
  m = after_text.match(/^## \[(\d+\.\d+\.\d+)\]/)
  return m[1] if m

  nil
end

#detect_version ⇒ String

Detect version from lib/**/version.rb

Returns:

  • (String)


24
25
26
# File 'sig/kettle/dev/changelog_cli.rbs', line 24

def detect_version
  Kettle::Dev::Versioning.detect_version(@root)
end

Ensure proper footer spacing

Parameters:

  • text (String)

Returns:

  • (String)


57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'sig/kettle/dev/changelog_cli.rbs', line 57

def ensure_footer_spacing(text)
  lines = text.split("\n", -1)
  # Find the Unreleased link-ref which denotes start of footer refs
  idx = lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
  return text unless idx
  head = lines[0...idx]
  tail = lines[idx..-1]
  # Ensure exactly one blank line between body and refs
  if head.any? && head.last.to_s.strip != ""
    head << ""
  elsif head.any? && head.last.to_s.strip == "" && head[-2].to_s.strip == ""
    # Collapse multiple blanks before footer to a single
    head.pop while head.any? && head.last.to_s.strip == ""
    head << ""
  end
  (head + tail).join("\n")
end

#extract_unreleased(content) ⇒ [String?, String?, String?]

Extract unreleased section from changelog

Parameters:

  • content (String)

Returns:

  • ([String?, String?, String?])


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
# File 'sig/kettle/dev/changelog_cli.rbs', line 27

def extract_unreleased(content)
  lines = content.lines
  start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
  return [nil, nil, nil] unless start_i

  # Find the next version heading after Unreleased
  next_i = (start_i + 1)
  while next_i < lines.length && !lines[next_i].start_with?("## [")
    next_i += 1
  end
  # Now next_i points to the next section heading or EOF
  before = lines[0..(start_i - 1)].join
  unreleased_body = lines[(start_i + 1)..(next_i - 1)] || []
  after_lines = lines[next_i..-1] || []

  # When this is the very first release there is no `## [X.Y.Z]` heading to act
  # as a boundary, so the footer link-ref block ([Unreleased]: ...) sits at the
  # end of the unreleased body.  Move everything from the [Unreleased]: line
  # onward into `after` so those refs are not mistaken for section content.
  if next_i == lines.length
    footer_i = unreleased_body.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
    if footer_i
      after_lines = unreleased_body[footer_i..-1] + after_lines
      unreleased_body = unreleased_body[0...footer_i]
    end
  end

  unreleased_block = unreleased_body.join
  after = after_lines.join
  [unreleased_block, before, after]
end

#filter_unreleased_sections(unreleased_block) ⇒ String

Filter unreleased sections keeping only those with content

Parameters:

  • unreleased_block (String)

Returns:

  • (String)


459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# File 'lib/kettle/dev/changelog_cli.rb', line 459

def filter_unreleased_sections(unreleased_block)
  lines = unreleased_block.lines
  out = []
  i = 0
  while i < lines.length
    line = lines[i]
    if line.start_with?("### ")
      header = line
      i += 1
      chunk = []
      while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
        chunk << lines[i]
        i += 1
      end
      # A section has real content only if it contains at least one non-blank line that is
      # neither a link-reference definition ([key]: url) nor a deeper heading (H4+).
      # Link-ref defs and H4+ headings alone are not meaningful section content.
      content_present = chunk.any? { |l| l.strip != "" && l !~ LINK_REF_DEF_RE && l !~ DEEP_HEADING_RE }
      if content_present
        # Trim leading blank lines so there is no blank line after the header
        while chunk.any? && chunk.first.strip == ""
          chunk.shift
        end
        # Trim trailing blank lines
        while chunk.any? && chunk.last.strip == ""
          chunk.pop
        end
        out << header
        out.concat(chunk)
        out << "\n" unless out.last&.end_with?("\n")
      end
      next
    else
      # Lines outside sections are ignored for released sections
      i += 1
    end
  end
  out.join
end

#normalize_heading_spacing(text) ⇒ String

Normalize spacing around headings

Parameters:

  • text (String)

Returns:

  • (String)


882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
# File 'lib/kettle/dev/changelog_cli.rb', line 882

def normalize_heading_spacing(text)
  lines = text.split("\n", -1)
  out = []
  in_fence = false
  fence_re = /^\s*```/
  heading_re = /^\s*#+\s+.+/
  lines.each_with_index do |ln, idx|
    if fence_re.match?(ln)
      in_fence = !in_fence
      out << ln
      next
    end
    if !in_fence && heading_re.match?(ln)
      # Ensure previous line is blank (unless start of file or already blank)
      prev_blank = out.empty? ? false : out.last.to_s.strip == ""
      out << "" unless out.empty? || prev_blank
      out << ln
      # Peek at next line in source to decide if we need to inject a blank now.
      nxt = lines[idx + 1]
      out << "" unless nxt.to_s.strip == ""
    else
      out << ln
    end
  end
  # Collapse multiple consecutive blank lines down to a single between regions that our logic might have doubled
  collapsed = []
  lines_enum = out
  lines_enum.each do |l|
    if l.strip == "" && collapsed.last.to_s.strip == ""
      next
    end
    collapsed << l
  end
  collapsed.join("\n")
end

#run ⇒ void

This method returns an undefined value.

Main entry point that updates CHANGELOG.md



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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/kettle/dev/changelog_cli.rb', line 42

def run
  version = Kettle::Dev::Versioning.detect_version(@root)
  today = Time.now.strftime("%Y-%m-%d")
  owner, repo = Kettle::Dev::CIHelpers.repo_info
  unless owner && repo
    warn("Could not determine GitHub owner/repo from origin remote.")
    warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
  end

  changelog = File.read(@changelog_path)
  plan = @update_prep ? explicit_update_prep_plan(changelog) : detect_plan(changelog, version)
  confirm_plan!(plan)

  if plan.fetch(:action) == :reformat_only
    reformat_changelog!(changelog)
    return
  end

  line_cov_line, branch_cov_line = coverage_lines
  yard_line = yard_percent_documented

  if plan.fetch(:action) == :update_prepared_release
    update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
    return
  end

  unreleased_block, before, after = extract_unreleased(changelog)
  if unreleased_block.nil?
    abort("Could not find '## [Unreleased]' section in CHANGELOG.md")
  end

  if unreleased_block.strip.empty?
    warn("No entries found under Unreleased. Creating an empty version section anyway.")
  end

  prev_version = detect_previous_version(after)

  new_section = +""
  new_section << "## [#{version}] - #{today}\n"
  new_section << "- TAG: [v#{version}][#{version}t]\n"
  new_section << "- #{line_cov_line}\n" if line_cov_line
  new_section << "- #{branch_cov_line}\n" if branch_cov_line
  new_section << "- #{yard_line}\n" if yard_line
  new_section << filter_unreleased_sections(unreleased_block)
  # Ensure exactly one blank line separates this new section from the next section
  new_section.rstrip!
  new_section << "\n\n"

  # Reset the Unreleased section to empty category headings
  unreleased_reset = <<~MD
    ## [Unreleased]
    ### Added
    ### Changed
    ### Deprecated
    ### Removed
    ### Fixed
    ### Security
  MD

  # Preserve everything from the first released section down to the line containing the [Unreleased] link ref.
  # Many real-world changelogs intersperse stray link refs between sections; we should keep them.
  updated = before + unreleased_reset + "\n" + new_section
  # Find the [Unreleased]: link-ref line and append everything from the start of the first released section
  # through to the end of the file, but if a [Unreleased]: ref exists, ensure we do not duplicate the
  # section content above it.
  if after && !after.empty?
    # Split 'after' by lines so we can locate the first link-ref to Unreleased
    after_lines = after.lines
    unreleased_ref_idx = after_lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
    if unreleased_ref_idx
      # Keep all content prior to the link-ref (older releases and interspersed refs)
      preserved_body = after_lines[0...unreleased_ref_idx].join
      # Then append the tail starting from the Unreleased link-ref line to preserve the footer refs
      preserved_footer = after_lines[unreleased_ref_idx..-1].join
      updated << preserved_body << preserved_footer
    else
      # No Unreleased ref found; just append the remainder as-is
      updated << after
    end
  end

  updated = update_link_refs(updated, owner, repo, prev_version, version)

  # Transform legacy heading suffix tags into list items under headings
  updated = convert_heading_tag_suffix_to_list(updated)

  # Normalize spacing around headings to aid Markdown renderers
  updated = normalize_heading_spacing(updated)

  # Ensure exactly one trailing newline at EOF
  updated = updated.rstrip + "\n"

  File.write(@changelog_path, updated)
  puts "CHANGELOG.md updated with v#{version} section."
end

Update link references in changelog



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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'sig/kettle/dev/changelog_cli.rbs', line 45

def update_link_refs(content, owner, repo, prev_version, new_version)
  # Convert any GitLab links to GitHub
  content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^.]+)\.\.\.([^\s]+)}) do
    o = owner || Regexp.last_match(1)
    r = repo || Regexp.last_match(2)
    from = Regexp.last_match(3)
    to = Regexp.last_match(4)
    "https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
  end
  content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
    o = owner || Regexp.last_match(1)
    r = repo || Regexp.last_match(2)
    tag = Regexp.last_match(3)
    "https://github.com/#{o}/#{r}/releases/tag/#{tag}"
  end

  # Append or update the bottom reference links
  lines = content.lines

  # Identify the true start of the footer reference block: the line with the [Unreleased] link-ref.
  # Do NOT assume the first link-ref after the Unreleased heading starts the footer, because
  # some changelogs contain interspersed link-refs within section bodies.
  unreleased_ref_idx = lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
  # If no [Unreleased]: ref is present, consider the reference block to start at EOF
  first_ref = unreleased_ref_idx || lines.length

  # Ensure Unreleased points to GitHub compare from new tag to HEAD
  if owner && repo
    unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
    # Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
    idx = nil
    lines.each_with_index do |l, i|
      if l.start_with?(UNRELEASED_SECTION_HEADING) && i >= first_ref
        idx = i
        break
      end
    end
    if idx
      lines[idx] = unreleased_ref
    else
      lines << unreleased_ref
    end
  end

  if owner && repo
    # Add compare link for the new version
    from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
    new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
    unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
      lines << new_compare
    end
    # Add tag link for the new version
    new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
    unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
      lines << new_tag
    end
  end

  # Rebuild and sort the reference block so Unreleased is first, then newest to oldest versions, preserving everything above first_ref
  ref_lines = lines[first_ref..-1].select { |l| /^\[[^\]]+\]:\s+http/.match?(l) }
  # Deduplicate by key (text inside the square brackets)
  by_key = {}
  ref_lines.each do |l|
    if l =~ /^\[([^\]]+)\]:\s+/
      by_key[$1] = l
    end
  end
  unreleased_line = by_key.delete("Unreleased")
  # Separate version compare and tag links
  compares = {}
  tags = {}
  by_key.each do |k, v|
    if k =~ /^(\d+\.\d+\.\d+)$/
      compares[$1] = v
    elsif k =~ /^(\d+\.\d+\.\d+)t$/
      tags[$1] = v
    end
  end
  # Build a unified set of versions that appear in either compares or tags
  version_keys = (compares.keys | tags.keys)
  # Sort versions descending (newest to oldest)
  sorted_versions = version_keys.map { |s| Gem::Version.new(s) }.sort.reverse.map(&:to_s)

  new_ref_block = []
  new_ref_block << unreleased_line if unreleased_line
  sorted_versions.each do |v|
    new_ref_block << compares[v] if compares[v]
    new_ref_block << tags[v] if tags[v]
  end
  # Replace the old block
  head = lines[0...first_ref]
  # Ensure exactly one blank line separating body content from the reference block
  if head.any? && head.last.to_s.strip != ""
    head << "\n"
  end
  rebuilt = head + new_ref_block + ["\n"]
  rebuilt.join
end

#yard_percent_documented ⇒ String?

Get YARD documentation percentage

Returns:

  • (String, nil)


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
# File 'sig/kettle/dev/changelog_cli.rbs', line 39

def yard_percent_documented
  commands = yard_documentation_commands
  if commands.empty?
    if @strict
      raise "bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler"
    else
      warn("bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler")
      return
    end
  end

  begin
    # Run the canonical docs task to get the documentation percentage.
    out = +""
    commands.each do |command|
      prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
      output, _status = Open3.capture2(*command, {chdir: @root})
      out << output
      line = documented_percent_line(output)
      return line if line
    end

    if @strict
      raise "Could not find documented percentage in bin/rake yard output"
    else
      warn("Could not find documented percentage in bin/rake yard output.")
      nil
    end
  rescue => e
    if @strict
      raise "Failed to run bin/rake yard: #{e.class}: #{e.message}"
    else
      warn("Failed to run bin/rake yard: #{e.class}: #{e.message}")
      nil
    end
  end
end