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]:"
CHANGELOG_VERSION_PATTERN =
/\d+\.\d+\.\d+(?:[.-][0-9A-Za-z]+)*/
CHANGELOG_VERSION_PATTERN_SOURCE =
CHANGELOG_VERSION_PATTERN.source
/^\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, version: nil, root: Kettle::Dev::CIHelpers.project_root) ⇒ 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

  • version (String, nil) (defaults to: nil)

    explicit version override for gems without a literal VERSION constant

  • strict: (Boolean) (defaults to: true)
  • enforce_coverage_thresholds: (Boolean) (defaults to: true)
  • update_prep: (Boolean) (defaults to: false)
  • root: (String) (defaults to: Kettle::Dev::CIHelpers.project_root)


30
31
32
33
34
35
36
37
38
# File 'lib/kettle/dev/changelog_cli.rb', line 30

def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, version: nil, root: Kettle::Dev::CIHelpers.project_root)
  @root = 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
  @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
end

Instance Method Details

#abort(msg) ⇒ void

This method returns an undefined value.

Abort with error message

Parameters:

  • msg (String)


27
28
29
# File 'sig/kettle/dev/changelog_cli.rbs', line 27

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)


800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
# File 'lib/kettle/dev/changelog_cli.rb', line 800

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(/^\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})t\]:\s+(\S+)/o))
      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(/^\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]:\s+(\S+)/o))
      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(#{CHANGELOG_VERSION_PATTERN_SOURCE})}io))
        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(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\](.*)\(\[tag\]\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})t\]\)\s*$/io)
    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(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\](.*)$/o))
      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(/^\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]:\s+(\S+)/o))
        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?])


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

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)


1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
# File 'lib/kettle/dev/changelog_cli.rb', line 1063

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)


36
37
38
39
40
41
42
# File 'sig/kettle/dev/changelog_cli.rbs', line 36

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

  nil
end

#detect_versionString

Detect version from lib/**/version.rb

Returns:

  • (String)


30
31
32
# File 'sig/kettle/dev/changelog_cli.rbs', line 30

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

Ensure proper footer spacing

Parameters:

  • text (String)

Returns:

  • (String)


63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'sig/kettle/dev/changelog_cli.rbs', line 63

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?])


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

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)


575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/kettle/dev/changelog_cli.rb', line 575

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)


998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
# File 'lib/kettle/dev/changelog_cli.rb', line 998

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

#pending_release_statusHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


142
143
144
# File 'lib/kettle/dev/changelog_cli.rb', line 142

def pending_release_status
  release_state
end

#release_stateHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/kettle/dev/changelog_cli.rb', line 146

def release_state
  changelog_present = ensure_changelog_for_release_state!
  version = detect_version
  gem_name = detect_gem_name
  unless changelog_present
    latest_overall, latest_for_series, latest_for_major = latest_released_versions(gem_name, version)
    latest_target = latest_release_target(version, latest_overall, latest_for_series, latest_for_major)
    return {
      root: @root,
      gem_name: gem_name,
      version: version,
      changelog_present: false,
      pending: false,
      pending_release: false,
      unreleased_entries: false,
      prepared_release_pending: false,
      latest_changelog_version: nil,
      latest_released: latest_target || latest_overall,
      latest_released_overall: latest_overall,
      latest_released_for_current_major: latest_for_major,
      latest_released_for_current_series: latest_for_series,
      latest_release_target: latest_target
    }
  end

  changelog = File.read(@changelog_path)
  unreleased_block, _before, after = extract_unreleased(changelog)
  unreleased_entries = unreleased_block_has_entries?(unreleased_block)
  latest_changelog_version = detect_previous_version(after.to_s)
  release_lookup_version = latest_changelog_version || version
  latest_overall, latest_for_series, latest_for_major = latest_released_versions(gem_name, release_lookup_version)
  latest_target = latest_release_target(release_lookup_version, latest_overall, latest_for_series, latest_for_major)
  prepared_release_pending = !!latest_changelog_version && latest_target != latest_changelog_version

  {
    root: @root,
    gem_name: gem_name,
    version: version,
    changelog_present: true,
    pending: unreleased_entries || prepared_release_pending,
    pending_release: unreleased_entries || prepared_release_pending,
    unreleased_entries: unreleased_entries,
    prepared_release_pending: prepared_release_pending,
    latest_changelog_version: latest_changelog_version,
    latest_released: latest_target || latest_overall,
    latest_released_overall: latest_overall,
    latest_released_for_current_major: latest_for_major,
    latest_released_for_current_series: latest_for_series,
    latest_release_target: latest_target
  }
end

#release_state_table(state = release_state) ⇒ String

Parameters:

  • state (Hash[Symbol, untyped]) (defaults to: release_state)

Returns:

  • (String)


198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/kettle/dev/changelog_cli.rb', line 198

def release_state_table(state = release_state)
  rows = [
    ["gem", "version.rb", "latest released", "latest changelog", "unreleased", "prepared", "pending"],
    [
      state.fetch(:gem_name),
      state.fetch(:version),
      state.fetch(:latest_released) || "unknown",
      state.fetch(:latest_changelog_version) || "none",
      yes_no(state.fetch(:unreleased_entries)),
      yes_no(state.fetch(:prepared_release_pending)),
      yes_no(state.fetch(:pending_release))
    ]
  ]
  widths = rows.transpose.map { |column| column.map(&:length).max }
  rows.map.with_index do |row, index|
    line = row.each_with_index.map { |value, i| value.ljust(widths.fetch(i)) }.join("  ").rstrip
    if index == 0
      [line, widths.map { |width| "-" * width }.join("  ")].join("\n")
    else
      line
    end
  end.join("\n")
end

#runvoid

This method returns an undefined value.

Main entry point that updates CHANGELOG.md



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
# File 'lib/kettle/dev/changelog_cli.rb', line 46

def run
  version = detect_version
  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



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
143
144
145
146
147
148
# File 'sig/kettle/dev/changelog_cli.rbs', line 51

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 =~ /^(#{CHANGELOG_VERSION_PATTERN_SOURCE})$/o
      compares[$1] = v
    elsif k =~ /^(#{CHANGELOG_VERSION_PATTERN_SOURCE})t$/o
      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_documentedString?

Get YARD documentation percentage

Returns:

  • (String, nil)


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

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