Class: Kettle::Dev::ChangelogCLI

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/dev/changelog_cli.rb,
sig/kettle/dev.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, refresh_cache: 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

  • 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
39
# 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, refresh_cache: false)
  @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)
  @refresh_cache = refresh_cache
end

Instance Method Details

#abort(msg) ⇒ void

This method returns an undefined value.

Abort with error message

Parameters:

  • msg (String)


111
112
113
# File 'sig/kettle/dev.rbs', line 111

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)


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
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
# File 'lib/kettle/dev/changelog_cli.rb', line 834

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


126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
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
197
198
199
200
201
202
203
204
205
# File 'sig/kettle/dev.rbs', line 126

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)


1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
# File 'lib/kettle/dev/changelog_cli.rb', line 1097

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)


120
121
122
123
124
125
126
# File 'sig/kettle/dev.rbs', line 120

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)


114
115
116
# File 'sig/kettle/dev.rbs', line 114

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

Ensure proper footer spacing

Parameters:

  • text (String)

Returns:

  • (String)


147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'sig/kettle/dev.rbs', line 147

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


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

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)


609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
# File 'lib/kettle/dev/changelog_cli.rb', line 609

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)


1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
# File 'lib/kettle/dev/changelog_cli.rb', line 1032

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


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

def pending_release_status
  release_state
end

#release_stateHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


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
197
198
199
200
201
# File 'lib/kettle/dev/changelog_cli.rb', line 147

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)
    ahead = commits_ahead_of_release(latest_target || latest_overall)
    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,
      ahead: ahead
    }
  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
  ahead = commits_ahead_of_release(latest_target || latest_overall)

  {
    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,
    ahead: ahead
  }
end

#release_state_table(state = release_state) ⇒ String

Parameters:

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

Returns:

  • (String)


203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/kettle/dev/changelog_cli.rb', line 203

def release_state_table(state = release_state)
  rows = [
    ["gem", "version.rb", "latest released", "latest changelog", "ahead", "unreleased", "prepared", "pending"],
    [
      state.fetch(:gem_name),
      state.fetch(:version),
      state.fetch(:latest_released) || "unknown",
      state.fetch(:latest_changelog_version) || "none",
      state.fetch(:ahead, nil).nil? ? "unknown" : state.fetch(:ahead).to_s,
      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



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

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



135
136
137
138
139
140
141
142
143
144
145
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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'sig/kettle/dev.rbs', line 135

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)


129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'sig/kettle/dev.rbs', line 129

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