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, yes: false, event_stream: nil) ⇒ 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

  • yes (Boolean) (defaults to: false)

    when true, approve the selected release plan without prompting

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


32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/kettle/dev/changelog_cli.rb', line 32

def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false, yes: false, event_stream: nil)
  @root = root
  @changelog_path = resolved_changelog_path
  @coverage_root = resolved_coverage_root
  @coverage_path = File.join(@coverage_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
  @yes = !!yes
  @event_recorder = Kettle::Ndjson.event_recorder(event_stream, phase_timings: [])
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)


931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
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
# File 'lib/kettle/dev/changelog_cli.rb', line 931

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
206
207
208
209
210
# 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)}..."
      emit_changelog_event(action: "coverage_clean", status: "started", path: Kettle::Dev.display_path(coverage_dir))
      Dir.glob(File.join(coverage_dir, "*")).each do |file|
        File.delete(file) if File.file?(file)
      end
      emit_changelog_event(action: "coverage_clean", status: "ok", path: Kettle::Dev.display_path(coverage_dir))
    end

    puts "Generating fresh coverage data by running: bundle exec kettle-test"
    emit_changelog_event(action: "coverage", status: "started", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root))

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

    unless success
      emit_changelog_event(action: "coverage", status: "failed", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root), reason: "exit status #{$?.exitstatus || "unknown"}")
      raise "bundle exec kettle-test failed with exit status #{$?.exitstatus || "unknown"}"
    end

    puts "Coverage generation complete."
    emit_changelog_event(action: "coverage", status: "ok", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root))

    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)


1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
# File 'lib/kettle/dev/changelog_cli.rb', line 1194

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)


631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
# File 'lib/kettle/dev/changelog_cli.rb', line 631

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)


1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
# File 'lib/kettle/dev/changelog_cli.rb', line 1129

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


150
151
152
# File 'lib/kettle/dev/changelog_cli.rb', line 150

def pending_release_status
  release_state
end

#release_stateHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


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

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)


210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/kettle/dev/changelog_cli.rb', line 210

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



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

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)
    emit_changelog_event(action: "update", status: "ok", version: version, plan: plan.fetch(:action).to_s)
    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)
  emit_changelog_event(action: "update", status: "ok", version: version, plan: plan.fetch(:action).to_s)
  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
# 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

  # Run the canonical docs task to get the documentation percentage.
  commands.each do |command|
    prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
    output, status = capture_yard_command(command)
    unless command_successful?(status)
      return handle_yard_documentation_failure(yard_command_failure_message(command, output, status))
    end
    line = documented_percent_line(output)
    return line if line
  end

  handle_yard_documentation_failure("Could not find documented percentage in YARD output")
end