kettle-changelog 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1384 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+ require "fileutils"
8
+ require "yaml"
9
+ require "kettle/ndjson"
10
+
11
+ module Kettle
12
+ module Changelog
13
+ # CLI for updating CHANGELOG.md with new version sections
14
+ #
15
+ # Automatically extracts unreleased changes, formats them into a new version section,
16
+ # includes coverage and YARD stats, and updates link references.
17
+ class CLI
18
+ UNRELEASED_SECTION_HEADING = "[Unreleased]:"
19
+ CHANGELOG_VERSION_PATTERN = /\d+\.\d+\.\d+(?:[.-][0-9A-Za-z]+)*/
20
+ CHANGELOG_VERSION_PATTERN_SOURCE = CHANGELOG_VERSION_PATTERN.source
21
+ # Matches a Markdown link-reference definition line, e.g. `[key]: https://...`
22
+ LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s+\S+/
23
+ # Matches an ATX heading at H4 or deeper (####, #####, ...)
24
+ DEEP_HEADING_RE = /^\#{4,}\s/
25
+
26
+ # Initialize the changelog CLI
27
+ # Sets up paths for CHANGELOG.md and coverage.json
28
+ # @param strict [Boolean] when true (default), require coverage and yard data; raise errors if unavailable
29
+ # @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
30
+ # @param update_prep [Boolean] when true, update the most recent prepared release section in place
31
+ # @param reformat_only [Boolean] when true, normalize structure without release-state planning
32
+ # @param historical_backfill_version [String, nil] tagged release to document without moving Unreleased entries
33
+ # @param version [String, nil] explicit version override for gems without a literal VERSION constant
34
+ # @param yes [Boolean] when true, approve the selected release plan without prompting
35
+ def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, reformat_only: false, historical_backfill_version: nil, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false, yes: false, event_stream: nil)
36
+ @root = root
37
+ @changelog_path = resolved_changelog_path
38
+ @coverage_root = resolved_coverage_root
39
+ @coverage_path = File.join(@coverage_root, "coverage", "coverage.json")
40
+ @strict = strict
41
+ @enforce_coverage_thresholds = enforce_coverage_thresholds
42
+ @update_prep = update_prep
43
+ @reformat_only = reformat_only
44
+ @historical_backfill_version = Kettle::Dev::Versioning.normalize_explicit_version(historical_backfill_version)
45
+ @version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
46
+ @refresh_cache = refresh_cache
47
+ @yes = !!yes
48
+ @event_recorder = Kettle::Ndjson.event_recorder(event_stream, phase_timings: [])
49
+ end
50
+
51
+ # Main entry point to update CHANGELOG.md
52
+ #
53
+ # Detects current version, extracts unreleased changes, formats them into
54
+ # a new version section with coverage/YARD stats, and updates all link references.
55
+ #
56
+ # @return [void]
57
+ def run
58
+ if @reformat_only
59
+ reformat_changelog!(File.read(@changelog_path))
60
+ emit_changelog_event(action: "reformat", status: "ok", plan: "reformat_only")
61
+ return
62
+ end
63
+
64
+ if @historical_backfill_version
65
+ backfill_historical_release!(@historical_backfill_version)
66
+ return
67
+ end
68
+
69
+ version = detect_version
70
+ today = Time.now.strftime("%Y-%m-%d")
71
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
72
+ unless owner && repo
73
+ warn("Could not determine GitHub owner/repo from origin remote.")
74
+ warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
75
+ end
76
+
77
+ changelog = File.read(@changelog_path)
78
+ plan = @update_prep ? explicit_update_prep_plan(changelog) : detect_plan(changelog, version)
79
+ confirm_plan!(plan)
80
+
81
+ if plan.fetch(:action) == :reformat_only
82
+ reformat_changelog!(changelog)
83
+ return
84
+ end
85
+
86
+ if plan.fetch(:action) == :rollback_prepared_release
87
+ rollback_prepared_release!(changelog, owner, repo, version)
88
+ emit_changelog_event(action: "rollback", status: "ok", version: plan.fetch(:latest_changelog_version), plan: "rollback_prepared_release")
89
+ return
90
+ end
91
+
92
+ line_cov_line, branch_cov_line = coverage_lines
93
+ yard_line = yard_percent_documented
94
+
95
+ if plan.fetch(:action) == :update_prepared_release
96
+ update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
97
+ emit_changelog_event(action: "update", status: "ok", version: version, plan: plan.fetch(:action).to_s)
98
+ return
99
+ end
100
+
101
+ unreleased_block, before, after = extract_unreleased(changelog)
102
+ if unreleased_block.nil?
103
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md")
104
+ end
105
+
106
+ if unreleased_block.strip.empty?
107
+ warn("No entries found under Unreleased. Creating an empty version section anyway.")
108
+ end
109
+
110
+ prev_version = detect_previous_version(after)
111
+
112
+ new_section = +""
113
+ new_section << "## [#{version}] - #{today}\n"
114
+ new_section << "- TAG: [v#{version}][#{version}t]\n"
115
+ new_section << "- #{line_cov_line}\n" if line_cov_line
116
+ new_section << "- #{branch_cov_line}\n" if branch_cov_line
117
+ new_section << "- #{yard_line}\n" if yard_line
118
+ new_section << filter_unreleased_sections(unreleased_block)
119
+ # Ensure exactly one blank line separates this new section from the next section
120
+ new_section.rstrip!
121
+ new_section << "\n\n"
122
+
123
+ # Reset the Unreleased section to empty category headings
124
+ unreleased_reset = <<~MD
125
+ ## [Unreleased]
126
+ ### Added
127
+ ### Changed
128
+ ### Deprecated
129
+ ### Removed
130
+ ### Fixed
131
+ ### Security
132
+ MD
133
+
134
+ # Preserve everything from the first released section down to the line containing the [Unreleased] link ref.
135
+ # Many real-world changelogs intersperse stray link refs between sections; we should keep them.
136
+ updated = before + unreleased_reset + "\n" + new_section
137
+ # Find the [Unreleased]: link-ref line and append everything from the start of the first released section
138
+ # through to the end of the file, but if a [Unreleased]: ref exists, ensure we do not duplicate the
139
+ # section content above it.
140
+ if after && !after.empty?
141
+ # Split 'after' by lines so we can locate the first link-ref to Unreleased
142
+ after_lines = after.lines
143
+ unreleased_ref_idx = after_lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
144
+ if unreleased_ref_idx
145
+ # Keep all content prior to the link-ref (older releases and interspersed refs)
146
+ preserved_body = after_lines[0...unreleased_ref_idx].join
147
+ # Then append the tail starting from the Unreleased link-ref line to preserve the footer refs
148
+ preserved_footer = after_lines[unreleased_ref_idx..-1].join
149
+ updated << preserved_body << preserved_footer
150
+ else
151
+ # No Unreleased ref found; just append the remainder as-is
152
+ updated << after
153
+ end
154
+ end
155
+
156
+ updated = update_link_refs(updated, owner, repo, prev_version, version)
157
+
158
+ # Transform legacy heading suffix tags into list items under headings
159
+ updated = convert_heading_tag_suffix_to_list(updated)
160
+
161
+ # Normalize spacing around headings to aid Markdown renderers
162
+ updated = normalize_heading_spacing(updated)
163
+
164
+ # Ensure exactly one trailing newline at EOF
165
+ updated = updated.rstrip + "\n"
166
+
167
+ File.write(@changelog_path, updated)
168
+ emit_changelog_event(action: "update", status: "ok", version: version, plan: plan.fetch(:action).to_s)
169
+ puts "CHANGELOG.md updated with v#{version} section."
170
+ end
171
+
172
+ def pending_release_status
173
+ release_state
174
+ end
175
+
176
+ def release_state
177
+ changelog_present = ensure_changelog_for_release_state!
178
+ version = detect_version
179
+ gem_name = detect_gem_name
180
+ unless changelog_present
181
+ latest_overall, latest_for_series, latest_for_major = latest_released_versions(gem_name, version)
182
+ latest_target = latest_release_target(version, latest_overall, latest_for_series, latest_for_major)
183
+ ahead = commits_ahead_of_release(latest_target || latest_overall)
184
+ return {
185
+ root: @root,
186
+ gem_name: gem_name,
187
+ version: version,
188
+ changelog_present: false,
189
+ pending: false,
190
+ pending_release: false,
191
+ unreleased_entries: false,
192
+ prepared_release_pending: false,
193
+ latest_changelog_version: nil,
194
+ latest_released: latest_target || latest_overall,
195
+ latest_released_overall: latest_overall,
196
+ latest_released_for_current_major: latest_for_major,
197
+ latest_released_for_current_series: latest_for_series,
198
+ latest_release_target: latest_target,
199
+ ahead: ahead
200
+ }
201
+ end
202
+
203
+ changelog = File.read(@changelog_path)
204
+ unreleased_block, _before, after = extract_unreleased(changelog)
205
+ unreleased_entries = unreleased_block_has_entries?(unreleased_block)
206
+ latest_changelog_version = detect_previous_version(after.to_s)
207
+ release_lookup_version = latest_changelog_version || version
208
+ latest_overall, latest_for_series, latest_for_major = latest_released_versions(gem_name, release_lookup_version)
209
+ latest_target = latest_release_target(release_lookup_version, latest_overall, latest_for_series, latest_for_major)
210
+ prepared_release_pending = !!latest_changelog_version && latest_target != latest_changelog_version
211
+ ahead = commits_ahead_of_release(latest_target || latest_overall)
212
+
213
+ {
214
+ root: @root,
215
+ gem_name: gem_name,
216
+ version: version,
217
+ changelog_present: true,
218
+ pending: unreleased_entries || prepared_release_pending,
219
+ pending_release: unreleased_entries || prepared_release_pending,
220
+ unreleased_entries: unreleased_entries,
221
+ prepared_release_pending: prepared_release_pending,
222
+ latest_changelog_version: latest_changelog_version,
223
+ latest_released: latest_target || latest_overall,
224
+ latest_released_overall: latest_overall,
225
+ latest_released_for_current_major: latest_for_major,
226
+ latest_released_for_current_series: latest_for_series,
227
+ latest_release_target: latest_target,
228
+ ahead: ahead
229
+ }
230
+ end
231
+
232
+ def release_state_table(state = release_state)
233
+ rows = [
234
+ ["gem", "version.rb", "latest released", "latest changelog", "ahead", "unreleased", "prepared", "pending"],
235
+ [
236
+ state.fetch(:gem_name),
237
+ state.fetch(:version),
238
+ state.fetch(:latest_released) || "unknown",
239
+ state.fetch(:latest_changelog_version) || "none",
240
+ state.fetch(:ahead, nil).nil? ? "unknown" : state.fetch(:ahead).to_s,
241
+ yes_no(state.fetch(:unreleased_entries)),
242
+ yes_no(state.fetch(:prepared_release_pending)),
243
+ yes_no(state.fetch(:pending_release))
244
+ ]
245
+ ]
246
+ widths = rows.transpose.map { |column| column.map(&:length).max }
247
+ rows.map.with_index do |row, index|
248
+ line = row.each_with_index.map { |value, i| value.ljust(widths.fetch(i)) }.join(" ").rstrip
249
+ if index == 0
250
+ [line, widths.map { |width| "-" * width }.join(" ")].join("\n")
251
+ else
252
+ line
253
+ end
254
+ end.join("\n")
255
+ end
256
+
257
+ private
258
+
259
+ def abort(msg)
260
+ Kettle::Dev::ExitAdapter.abort(msg)
261
+ end
262
+
263
+ def ensure_changelog_for_release_state!
264
+ return true if File.file?(@changelog_path)
265
+
266
+ if File.file?(File.join(@root, "Gemfile")) && Dir.glob(File.join(@root, "*", "*.gemspec")).any?
267
+ abort("Could not find CHANGELOG.md in #{Kettle::Dev.display_path(@root)}. This looks like a gem-family root; run `kettle-family release-state` from this directory, or run `kettle-changelog --release-state` from an individual gem directory.")
268
+ end
269
+
270
+ false
271
+ end
272
+
273
+ def yes_no(value)
274
+ value ? "yes" : "no"
275
+ end
276
+
277
+ def commits_ahead_of_release(version)
278
+ tag = release_tag_for_version(version)
279
+ branch = default_branch_ref
280
+ return nil unless tag && branch
281
+
282
+ stdout, ok = git_capture(["rev-list", "--count", "#{tag}..#{branch}"])
283
+ ok ? stdout.to_i : nil
284
+ end
285
+
286
+ def release_tag_for_version(version)
287
+ return nil if version.to_s.empty?
288
+
289
+ ["v#{version}", version.to_s].find { |tag| git_ref_exists?("refs/tags/#{tag}^{commit}") }
290
+ end
291
+
292
+ def default_branch_ref
293
+ stdout, ok = git_capture(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])
294
+ return stdout.strip if ok && !stdout.strip.empty?
295
+
296
+ %w[main master HEAD].find { |ref| git_ref_exists?(ref) }
297
+ end
298
+
299
+ def git_ref_exists?(ref)
300
+ _stdout, ok = git_capture(["rev-parse", "--verify", "--quiet", ref])
301
+ ok
302
+ end
303
+
304
+ def detect_plan(changelog, version)
305
+ latest_overall = nil
306
+ latest_for_series = nil
307
+ gem_name = nil
308
+ begin
309
+ gem_name = detect_gem_name
310
+ latest_overall, latest_for_series = latest_released_versions(gem_name, version)
311
+ rescue => e
312
+ warn("[kettle-changelog] RubyGems.org release check failed: #{e.class}: #{e.message}")
313
+ warn("Proceeding without live release info.")
314
+ end
315
+
316
+ unreleased_block, _before, after = extract_unreleased(changelog)
317
+ latest_changelog_version = detect_previous_version(after.to_s)
318
+ section_exists = release_section_exists?(changelog, version)
319
+ latest_target = latest_release_target(version, latest_overall, latest_for_series)
320
+
321
+ if latest_target && Gem::Version.new(version) < Gem::Version.new(latest_target)
322
+ abort("Aborting: version.rb (#{version}) is lower than the latest released version for this release line (#{latest_target}).")
323
+ end
324
+
325
+ if latest_target == version && latest_changelog_version && Gem::Version.new(latest_changelog_version) > Gem::Version.new(version)
326
+ action = :rollback_prepared_release
327
+ elsif section_exists && latest_changelog_version != version
328
+ abort("Aborting: CHANGELOG.md already contains a #{version} section, but the most recent release section is #{latest_changelog_version || "missing"}.")
329
+ else
330
+ action = if section_exists && latest_target == version
331
+ if unreleased_block_has_entries?(unreleased_block)
332
+ abort("Aborting: version.rb (#{version}) matches the latest released version for this release line (#{latest_target}); bump version.rb before moving Unreleased entries into a release section.")
333
+ end
334
+ :reformat_only
335
+ elsif section_exists
336
+ :update_prepared_release
337
+ elsif latest_target == version
338
+ abort("Aborting: version.rb (#{version}) matches the latest released version, but CHANGELOG.md does not have #{version} as the most recent release section.")
339
+ else
340
+ :new_release
341
+ end
342
+ end
343
+
344
+ {
345
+ action: action,
346
+ version: version,
347
+ gem_name: gem_name,
348
+ latest_overall: latest_overall,
349
+ latest_for_series: latest_for_series,
350
+ latest_target: latest_target,
351
+ latest_changelog_version: latest_changelog_version
352
+ }
353
+ end
354
+
355
+ def explicit_update_prep_plan(changelog)
356
+ _unreleased_block, _before, after = extract_unreleased(changelog)
357
+ prepared_version = detect_previous_version(after.to_s)
358
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless prepared_version
359
+
360
+ {
361
+ action: :update_prepared_release,
362
+ version: prepared_version,
363
+ gem_name: nil,
364
+ latest_overall: nil,
365
+ latest_for_series: nil,
366
+ latest_target: nil,
367
+ latest_changelog_version: prepared_version,
368
+ explicit: true
369
+ }
370
+ end
371
+
372
+ def confirm_plan!(plan)
373
+ puts "kettle-changelog selected plan: #{plan_label(plan.fetch(:action))}"
374
+ puts " #{version_source_label(plan)}: #{plan.fetch(:version)}"
375
+ puts " latest released: #{plan.fetch(:latest_overall) || "unknown"}"
376
+ puts " latest released for current series: #{plan.fetch(:latest_for_series) || "unknown"}"
377
+ puts " latest CHANGELOG.md release: #{plan.fetch(:latest_changelog_version) || "none"}"
378
+ puts " gem: #{plan.fetch(:gem_name) || "unknown"}"
379
+ emit_changelog_event(
380
+ action: "plan",
381
+ status: "ok",
382
+ plan: plan.fetch(:action).to_s,
383
+ version: plan.fetch(:version),
384
+ gem_name: plan.fetch(:gem_name),
385
+ latest_overall: plan.fetch(:latest_overall),
386
+ latest_for_series: plan.fetch(:latest_for_series),
387
+ latest_changelog_version: plan.fetch(:latest_changelog_version)
388
+ )
389
+ if @yes
390
+ puts("Continue with this plan? [y/N]: y")
391
+ return
392
+ end
393
+
394
+ print("Continue with this plan? [y/N]: ")
395
+ ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
396
+ return if ans == "y" || ans == "yes"
397
+
398
+ abort("Aborting: changelog plan was not confirmed.")
399
+ end
400
+
401
+ def version_source_label(plan)
402
+ return "prepared release" if plan.fetch(:explicit, false)
403
+ return "version override" if @version_override
404
+
405
+ "version.rb"
406
+ end
407
+
408
+ def plan_label(action)
409
+ case action
410
+ when :new_release
411
+ "create a new release section"
412
+ when :update_prepared_release
413
+ "update the prepared release section in place"
414
+ when :reformat_only
415
+ "reformat CHANGELOG.md without adding a release section"
416
+ else
417
+ action.to_s
418
+ end
419
+ end
420
+
421
+ def reformat_changelog!(changelog)
422
+ updated = convert_heading_tag_suffix_to_list(changelog)
423
+ updated = normalize_legacy_release_headings(updated)
424
+ updated = remove_redundant_historical_placeholders(updated)
425
+ updated = normalize_heading_spacing(updated)
426
+ updated = ensure_footer_spacing(updated)
427
+ updated = updated.rstrip + "\n"
428
+ File.write(@changelog_path, updated)
429
+ puts "CHANGELOG.md reformatted. No new version section added."
430
+ end
431
+
432
+ def normalize_legacy_release_headings(changelog)
433
+ changelog.to_s.lines.map do |line|
434
+ match = line.match(/^##\s+(?!\[)(v?\d+(?:\.\d+)+(?:[.-][0-9A-Za-z]+)?)(\s+-\s+.*)?\s*$/)
435
+ next line unless match
436
+
437
+ version = match[1].delete_prefix("v")
438
+ next line unless Gem::Version.correct?(version)
439
+
440
+ "## [#{version}]#{match[2]}\n"
441
+ end.join
442
+ end
443
+
444
+ def remove_redundant_historical_placeholders(changelog)
445
+ lines = changelog.to_s.lines
446
+ sections = changelog_release_sections(lines)
447
+ removals = sections.group_by { |section| section.fetch(:version) }.values.flat_map do |same_version|
448
+ next [] unless same_version.size > 1
449
+ next [] unless same_version.any? { |section| !section.fetch(:placeholder) }
450
+
451
+ same_version.filter_map do |section|
452
+ (section.fetch(:start)...section.fetch(:finish)) if section.fetch(:placeholder)
453
+ end
454
+ end
455
+ return changelog if removals.empty?
456
+
457
+ lines.each_with_index.reject { |_line, index| removals.any? { |range| range.cover?(index) } }.map(&:first).join
458
+ end
459
+
460
+ def changelog_release_sections(lines)
461
+ headings = lines.each_index.filter_map do |index|
462
+ match = lines[index].match(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]/o)
463
+ {version: match[1], start: index} if match
464
+ end
465
+ headings.each_with_index.map do |heading, index|
466
+ next_heading = headings[index + 1]
467
+ footer = lines.each_index.find do |line_index|
468
+ line_index > heading.fetch(:start) && lines[line_index].start_with?(UNRELEASED_SECTION_HEADING)
469
+ end
470
+ finish = [next_heading&.fetch(:start), footer, lines.length].compact.min
471
+ body = lines[heading.fetch(:start)...finish].join
472
+ heading.merge(finish: finish, placeholder: body.include?("Historical release notes are unavailable in this changelog."))
473
+ end
474
+ end
475
+
476
+ def backfill_historical_release!(version)
477
+ changelog = File.read(@changelog_path)
478
+ abort("CHANGELOG.md already contains a section for #{version}") if release_section_exists?(changelog, version)
479
+
480
+ unreleased_block, before, after = extract_unreleased(changelog)
481
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md") if unreleased_block.nil?
482
+
483
+ date = tagged_release_date(version)
484
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
485
+ abort("Could not determine GitHub owner/repo from origin remote") unless owner && repo
486
+
487
+ section = <<~MD
488
+ ## [#{version}] - #{date}
489
+ - TAG: [v#{version}][#{version}t]
490
+
491
+ ### Changed
492
+
493
+ - Historical release notes are unavailable in this changelog.
494
+
495
+ MD
496
+ updated = before + "## [Unreleased]\n" + unreleased_block.rstrip + "\n\n" + section + after.to_s
497
+ updated = update_link_refs(updated, owner, repo, nil, version)
498
+ updated = normalize_heading_spacing(updated).rstrip + "\n"
499
+ File.write(@changelog_path, updated)
500
+ emit_changelog_event(action: "backfill", status: "ok", version: version, plan: "historical_release")
501
+ puts "CHANGELOG.md backfilled with historical v#{version} section. Unreleased entries were preserved."
502
+ end
503
+
504
+ def tagged_release_date(version)
505
+ tag = "v#{version}"
506
+ output, status = Open3.capture2("git", "log", "-1", "--format=%as", tag, chdir: @root)
507
+ abort("Local git tag #{tag} is required to backfill a historical release section") unless status.success? && !output.strip.empty?
508
+
509
+ output.strip
510
+ end
511
+
512
+ def release_section_exists?(changelog, version)
513
+ changelog.match?(/^## \[#{Regexp.escape(version)}\]/)
514
+ end
515
+
516
+ def detect_gem_name
517
+ env_gem_name = ENV.fetch("K_CHANGELOG_GEM_NAME", "").to_s.strip
518
+ return env_gem_name unless env_gem_name.empty?
519
+
520
+ gemspecs = Dir[File.join(@root, "*.gemspec")]
521
+ abort("Could not find a .gemspec in project root.") if gemspecs.empty?
522
+ path = gemspecs.min
523
+ content = File.read(path)
524
+ m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
525
+ abort("Could not determine gem name from #{Kettle::Dev.display_path(path)}.") unless m
526
+
527
+ m[2]
528
+ end
529
+
530
+ def latest_released_versions(gem_name, current_version)
531
+ data = Kettle::Dev::RubyGemsVersions.fetch(gem_name, version_hint: current_version, refresh: @refresh_cache)
532
+ return [nil, nil, nil] unless data.is_a?(Array)
533
+
534
+ versions = data.map { |h| h["number"] }.compact
535
+ versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s.match?(/[a-zA-Z]/) }
536
+ gversions = versions.map { |s| Gem::Version.new(s) }.sort
537
+ latest_overall = gversions.last&.to_s
538
+
539
+ cur = Gem::Version.new(current_version)
540
+ series = cur.segments[0, 2]
541
+ major = cur.segments.fetch(0)
542
+ latest_series = gversions.reverse.find { |gv| gv.segments[0, 2] == series }&.to_s
543
+ latest_major = gversions.reverse.find { |gv| gv.segments.fetch(0, nil) == major }&.to_s
544
+ [latest_overall, latest_series, latest_major]
545
+ rescue => e
546
+ Kettle::Dev.debug_error(e, __method__)
547
+ [nil, nil, nil]
548
+ end
549
+
550
+ def latest_release_target(version, latest_overall, latest_for_series, latest_for_major = nil)
551
+ return unless latest_overall
552
+
553
+ cur = Gem::Version.new(version)
554
+ overall = Gem::Version.new(latest_overall)
555
+ cur_series = cur.segments[0, 2]
556
+ overall_series = overall.segments[0, 2]
557
+ cur_major = cur.segments.fetch(0)
558
+ overall_major = overall.segments.fetch(0)
559
+
560
+ if latest_for_series
561
+ lfs_series = Gem::Version.new(latest_for_series).segments[0, 2]
562
+ latest_for_series = nil unless lfs_series == cur_series
563
+ end
564
+
565
+ if latest_for_major
566
+ lfm_major = Gem::Version.new(latest_for_major).segments.fetch(0, nil)
567
+ latest_for_major = nil unless lfm_major == cur_major
568
+ end
569
+
570
+ return latest_for_major || latest_for_series if cur_major < overall_major
571
+
572
+ if (cur_series <=> overall_series) == -1
573
+ latest_for_series
574
+ else
575
+ latest_overall
576
+ end
577
+ end
578
+
579
+ def detect_version
580
+ Kettle::Dev::Versioning.detect_version(@root, override: @version_override)
581
+ end
582
+
583
+ def extract_unreleased(content)
584
+ lines = content.lines
585
+ start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
586
+ return [nil, nil, nil] unless start_i
587
+
588
+ # Find the next version heading after Unreleased
589
+ next_i = (start_i + 1)
590
+ while next_i < lines.length && !lines[next_i].start_with?("## [")
591
+ next_i += 1
592
+ end
593
+ # Now next_i points to the next section heading or EOF
594
+ before = lines[0..(start_i - 1)].join
595
+ unreleased_body = lines[(start_i + 1)..(next_i - 1)] || []
596
+ after_lines = lines[next_i..-1] || []
597
+
598
+ # When this is the very first release there is no `## [X.Y.Z]` heading to act
599
+ # as a boundary, so the footer link-ref block ([Unreleased]: ...) sits at the
600
+ # end of the unreleased body. Move everything from the [Unreleased]: line
601
+ # onward into `after` so those refs are not mistaken for section content.
602
+ if next_i == lines.length
603
+ footer_i = unreleased_body.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
604
+ if footer_i
605
+ after_lines = unreleased_body[footer_i..-1] + after_lines
606
+ unreleased_body = unreleased_body[0...footer_i]
607
+ end
608
+ end
609
+
610
+ unreleased_block = unreleased_body.join
611
+ after = after_lines.join
612
+ [unreleased_block, before, after]
613
+ end
614
+
615
+ def detect_previous_version(after_text)
616
+ # after_text begins with the first released section following Unreleased
617
+ m = after_text.match(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]/o)
618
+ return m[1] if m
619
+
620
+ nil
621
+ end
622
+
623
+ def update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
624
+ unreleased_block, before, after = extract_unreleased(changelog)
625
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md") if unreleased_block.nil?
626
+
627
+ release_heading = after.to_s.match(/\A## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\][^\n]*\n/o)
628
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless release_heading
629
+
630
+ prepared_version = release_heading[1]
631
+ release_and_tail = after.lines
632
+ next_release_index = release_and_tail[1..-1].to_a.index { |line| line.start_with?("## [") }
633
+ release_line_count = next_release_index ? next_release_index + 1 : release_and_tail.length
634
+ release_lines = release_and_tail[0...release_line_count]
635
+ tail = release_and_tail[release_line_count..-1].to_a.join
636
+
637
+ release_body = release_lines[1..-1].to_a.join
638
+ merged_body = merge_release_body_with_unreleased(release_body, unreleased_block)
639
+
640
+ release_section = +""
641
+ release_section << "## [#{prepared_version}] - #{today}\n"
642
+ release_section << "- TAG: [v#{prepared_version}][#{prepared_version}t]\n"
643
+ release_section << "- #{line_cov_line}\n" if line_cov_line
644
+ release_section << "- #{branch_cov_line}\n" if branch_cov_line
645
+ release_section << "- #{yard_line}\n" if yard_line
646
+ release_section << merged_body
647
+ release_section.rstrip!
648
+ release_section << "\n\n"
649
+
650
+ unreleased_reset = <<~MD
651
+ ## [Unreleased]
652
+ ### Added
653
+ ### Changed
654
+ ### Deprecated
655
+ ### Removed
656
+ ### Fixed
657
+ ### Security
658
+ MD
659
+
660
+ previous_version = detect_previous_version(tail)
661
+ updated = before + unreleased_reset + "\n" + release_section + tail
662
+ updated = update_link_refs(updated, owner, repo, previous_version, prepared_version)
663
+ updated = convert_heading_tag_suffix_to_list(updated)
664
+ updated = normalize_heading_spacing(updated)
665
+ updated = updated.rstrip + "\n"
666
+
667
+ File.write(@changelog_path, updated)
668
+ puts "CHANGELOG.md updated in place for v#{prepared_version}."
669
+ end
670
+
671
+ def rollback_prepared_release!(changelog, owner, repo, published_version)
672
+ unreleased_block, before, after = extract_unreleased(changelog)
673
+ release_lines = after.to_s.lines
674
+ heading = release_lines.first.to_s.match(/\A## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]/o)
675
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless heading
676
+
677
+ finish = release_lines.each_index.drop(1).find do |index|
678
+ release_lines[index].start_with?("## [", UNRELEASED_SECTION_HEADING)
679
+ end || release_lines.length
680
+ prepared_version = heading[1]
681
+ merged = merge_release_body_with_unreleased(release_lines[1...finish].join, unreleased_block)
682
+ unreleased = "## [Unreleased]\n" + merged.rstrip + "\n\n"
683
+ tail = release_lines[finish, release_lines.length].to_a.reject { |line| line.start_with?("[#{prepared_version}]:", "[#{prepared_version}t]:") }.join
684
+ updated = before + unreleased + tail
685
+ updated = update_link_refs(updated, owner, repo, nil, published_version)
686
+ File.write(@changelog_path, normalize_heading_spacing(updated).rstrip + "\n")
687
+ puts "CHANGELOG.md rolled back prepared v#{prepared_version} into Unreleased."
688
+ end
689
+
690
+ def merge_release_body_with_unreleased(release_body, unreleased_block)
691
+ existing = strip_release_metadata(release_body)
692
+ incoming = filter_unreleased_sections(unreleased_block)
693
+ return existing if incoming.strip.empty?
694
+ return incoming if existing.strip.empty?
695
+
696
+ leading, sections = split_h3_sections(existing)
697
+ _incoming_leading, incoming_sections = split_h3_sections(incoming)
698
+ return [existing.rstrip, incoming.rstrip, ""].join("\n\n") if sections.empty? || incoming_sections.empty?
699
+
700
+ incoming_sections.each do |incoming_section|
701
+ section = sections.find { |candidate| candidate.fetch(:heading) == incoming_section.fetch(:heading) }
702
+ if section
703
+ section.fetch(:lines) << "\n" unless section.fetch(:lines).empty? || section.fetch(:lines).last.to_s.strip.empty?
704
+ section.fetch(:lines).concat(trim_blank_lines(incoming_section.fetch(:lines)))
705
+ else
706
+ sections << incoming_section
707
+ end
708
+ end
709
+
710
+ ([leading] + sections.map { |section| section.fetch(:heading) + trim_blank_lines(section.fetch(:lines)).join }).join.rstrip + "\n"
711
+ end
712
+
713
+ def strip_release_metadata(release_body)
714
+ lines = release_body.lines
715
+ while lines.any? && lines.first.strip.empty?
716
+ lines.shift
717
+ end
718
+ while lines.any?
719
+ stripped = lines.first.strip
720
+ break unless stripped.start_with?("- TAG:", "- COVERAGE:", "- BRANCH COVERAGE:") || stripped.match?(/\A- \d+(?:\.\d+)?%\s+documented\z/)
721
+
722
+ lines.shift
723
+ end
724
+ lines.join
725
+ end
726
+
727
+ def split_h3_sections(text)
728
+ leading = +""
729
+ sections = []
730
+ current = nil
731
+ text.lines.each do |line|
732
+ if line.start_with?("### ")
733
+ current = {heading: line, lines: []}
734
+ sections << current
735
+ elsif current
736
+ current.fetch(:lines) << line
737
+ else
738
+ leading << line
739
+ end
740
+ end
741
+ [leading, sections]
742
+ end
743
+
744
+ def trim_blank_lines(lines)
745
+ trimmed = lines.dup
746
+ trimmed.shift while trimmed.any? && trimmed.first.to_s.strip.empty?
747
+ trimmed.pop while trimmed.any? && trimmed.last.to_s.strip.empty?
748
+ trimmed << "\n" if trimmed.any? && !trimmed.last.end_with?("\n")
749
+ trimmed
750
+ end
751
+
752
+ # From the Unreleased block, keep only sections that have content.
753
+ # We detect sections as lines starting with '### '. A section has content if there is at least
754
+ # one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
755
+ # Returns a string that includes only the non-empty sections with their content.
756
+ def filter_unreleased_sections(unreleased_block)
757
+ lines = unreleased_block.lines
758
+ out = []
759
+ i = 0
760
+ while i < lines.length
761
+ line = lines[i]
762
+ if line.start_with?("### ")
763
+ header = line
764
+ i += 1
765
+ chunk = []
766
+ while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
767
+ chunk << lines[i]
768
+ i += 1
769
+ end
770
+ # A section has real content only if it contains at least one non-blank line that is
771
+ # neither a link-reference definition ([key]: url) nor a deeper heading (H4+).
772
+ # Link-ref defs and H4+ headings alone are not meaningful section content.
773
+ content_present = chunk.any? { |l| l.strip != "" && l !~ LINK_REF_DEF_RE && l !~ DEEP_HEADING_RE }
774
+ if content_present
775
+ # Trim leading blank lines so there is no blank line after the header
776
+ while chunk.any? && chunk.first.strip == ""
777
+ chunk.shift
778
+ end
779
+ # Trim trailing blank lines
780
+ while chunk.any? && chunk.last.strip == ""
781
+ chunk.pop
782
+ end
783
+ out << header
784
+ out.concat(chunk)
785
+ out << "\n" unless out.last&.end_with?("\n")
786
+ end
787
+ next
788
+ else
789
+ # Lines outside sections are ignored for released sections
790
+ i += 1
791
+ end
792
+ end
793
+ out.join
794
+ end
795
+
796
+ def unreleased_block_has_entries?(unreleased_block)
797
+ !filter_unreleased_sections(unreleased_block.to_s).strip.empty?
798
+ end
799
+
800
+ def coverage_lines
801
+ if @strict
802
+ # Always generate fresh coverage data in strict mode
803
+ # Delete old coverage files to ensure we get current data
804
+ coverage_dir = File.dirname(@coverage_path)
805
+ if Dir.exist?(coverage_dir)
806
+ puts "Cleaning old coverage data from #{Kettle::Dev.display_path(coverage_dir)}..."
807
+ emit_changelog_event(action: "coverage_clean", status: "started", path: Kettle::Dev.display_path(coverage_dir))
808
+ Dir.glob(File.join(coverage_dir, "*")).each do |file|
809
+ File.delete(file) if File.file?(file)
810
+ end
811
+ emit_changelog_event(action: "coverage_clean", status: "ok", path: Kettle::Dev.display_path(coverage_dir))
812
+ end
813
+
814
+ puts "Generating fresh coverage data by running: bundle exec kettle-test"
815
+ emit_changelog_event(action: "coverage", status: "started", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root))
816
+
817
+ success = system(changelog_coverage_env, "bundle", "exec", "kettle-test", chdir: @coverage_root)
818
+
819
+ unless success
820
+ emit_changelog_event(action: "coverage", status: "failed", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root), reason: "exit status #{$?.exitstatus || "unknown"}")
821
+ raise "bundle exec kettle-test failed with exit status #{$?.exitstatus || "unknown"}"
822
+ end
823
+
824
+ puts "Coverage generation complete."
825
+ emit_changelog_event(action: "coverage", status: "ok", command: "bundle exec kettle-test", root: Kettle::Dev.display_path(@coverage_root))
826
+
827
+ ensure_changelog_coverage_json!
828
+ else
829
+ # Non-strict mode: check if coverage.json exists, warn if not
830
+ unless File.file?(@coverage_path)
831
+ warn(coverage_json_missing_message)
832
+ warn("Run: K_SOUP_COV_FORMATTERS=json bundle exec kettle-test to generate it")
833
+ return [nil, nil]
834
+ end
835
+ end
836
+
837
+ # Parse the coverage data
838
+ data = JSON.parse(File.read(@coverage_path))
839
+ files = data["coverage"] || {}
840
+ file_count = 0
841
+ total_lines = 0
842
+ covered_lines = 0
843
+ total_branches = 0
844
+ covered_branches = 0
845
+ files.each_value do |h|
846
+ lines = h["lines"] || []
847
+ line_relevant = lines.count { |x| x.is_a?(Integer) }
848
+ line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
849
+ if line_relevant > 0
850
+ file_count += 1
851
+ total_lines += line_relevant
852
+ covered_lines += line_covered
853
+ end
854
+ branches = h["branches"] || []
855
+ branches.each do |b|
856
+ next unless b.is_a?(Hash)
857
+
858
+ cov = b["coverage"]
859
+ next unless cov.is_a?(Numeric)
860
+
861
+ total_branches += 1
862
+ covered_branches += 1 if cov > 0
863
+ end
864
+ end
865
+ line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
866
+ branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
867
+ line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
868
+ branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
869
+ [line_str, branch_str]
870
+ rescue JSON::ParserError => e
871
+ if @strict
872
+ raise "Failed to parse coverage JSON at #{@coverage_path}: #{e.class}: #{e.message}"
873
+ else
874
+ warn("Failed to parse coverage: #{e.class}: #{e.message}")
875
+ [nil, nil]
876
+ end
877
+ rescue => e
878
+ if @strict
879
+ raise "Failed to get coverage data: #{e.class}: #{e.message}"
880
+ else
881
+ warn("Failed to get coverage data: #{e.class}: #{e.message}")
882
+ [nil, nil]
883
+ end
884
+ end
885
+
886
+ def emit_changelog_event(action:, status:, **payload)
887
+ mark = case status.to_s
888
+ when "started"
889
+ ">"
890
+ when "ok", "skipped"
891
+ "."
892
+ when "failed"
893
+ "!"
894
+ else
895
+ "?"
896
+ end
897
+ Kettle::Ndjson.emit_event(
898
+ @event_recorder,
899
+ "changelog",
900
+ payload.merge(
901
+ action: action,
902
+ phase: "release",
903
+ status: status,
904
+ mark: mark
905
+ )
906
+ )
907
+ end
908
+
909
+ def changelog_coverage_env
910
+ env = Kettle::Dev::BundlerEnvGuard.unbundled_env.merge(
911
+ "PATH" => ENV.fetch("PATH", ""),
912
+ "RUBYOPT" => nil,
913
+ "KETTLE_CHANGELOG_DEV_ROOT" => nil,
914
+ "K_RELEASE_CI_WORKFLOWS" => nil,
915
+ "KETTLE_RELEASE_SKIP_GITHUB_RELEASE" => nil,
916
+ "K_SOUP_COV_DO" => "true",
917
+ "K_SOUP_COV_FORMATTERS" => "json",
918
+ "K_SOUP_COV_MIN_HARD" => @enforce_coverage_thresholds ? "true" : "false",
919
+ "K_SOUP_COV_MULTI_FORMATTERS" => "false",
920
+ "K_SOUP_COV_OPEN_BIN" => ""
921
+ )
922
+ env.merge!(changelog_coverage_workflow_thresholds)
923
+ gemfile = File.join(@coverage_root, "Gemfile")
924
+ env["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
925
+ env
926
+ end
927
+
928
+ # The changelog coverage run is the final local test gate before a
929
+ # release commit is pushed. Reuse the checked-in CI workflow thresholds
930
+ # rather than silently falling back to kettle-soup-cover defaults.
931
+ def changelog_coverage_workflow_thresholds
932
+ path = ["coverage.yml", "coverage.yaml"]
933
+ .map { |name| File.join(@coverage_root, ".github", "workflows", name) }
934
+ .find { |candidate| File.file?(candidate) }
935
+ return {} unless path
936
+
937
+ workflow = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
938
+ workflow_env = (workflow.is_a?(Hash) && workflow["env"].is_a?(Hash)) ? workflow["env"] : {}
939
+ %w[K_SOUP_COV_MIN_LINE K_SOUP_COV_MIN_BRANCH].each_with_object({}) do |key, thresholds|
940
+ value = workflow_env[key]
941
+ thresholds[key] = value.to_s unless value.nil? || value.to_s.empty?
942
+ end
943
+ rescue Psych::Exception => error
944
+ raise "Unable to read coverage thresholds from #{path}: #{error.message}"
945
+ end
946
+
947
+ def ensure_changelog_coverage_json!
948
+ return if File.file?(@coverage_path)
949
+
950
+ raise coverage_json_missing_message
951
+ end
952
+
953
+ def coverage_json_missing_message
954
+ [
955
+ "Coverage JSON not found at #{Kettle::Dev.display_path(@coverage_path)} after running bundle exec kettle-test.",
956
+ "kettle-test runs specs in parallel and is expected to collate parallel SimpleCov results into this canonical file.",
957
+ "If it is missing, coverage was not enabled in ENV config or the rake/task hooks did not load the coverage integration."
958
+ ].join(" ")
959
+ end
960
+
961
+ def yard_percent_documented
962
+ commands = yard_documentation_commands
963
+ if commands.empty?
964
+ if @strict
965
+ raise "bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler"
966
+ else
967
+ warn("bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler")
968
+ return
969
+ end
970
+ end
971
+
972
+ # Run the canonical docs task to get the documentation percentage.
973
+ commands.each do |command|
974
+ prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
975
+ output, status = capture_yard_command(command)
976
+ unless command_successful?(status)
977
+ return handle_yard_documentation_failure(yard_command_failure_message(command, output, status))
978
+ end
979
+ line = documented_percent_line(output)
980
+ return line if line
981
+ end
982
+
983
+ handle_yard_documentation_failure("Could not find documented percentage in YARD output")
984
+ end
985
+
986
+ def capture_yard_command(command)
987
+ Open3.capture2e(yard_command_env, *command, {chdir: @root})
988
+ rescue => e
989
+ ["#{e.class}: #{e.message}", false]
990
+ end
991
+
992
+ def yard_command_env
993
+ env = Kettle::Dev::BundlerEnvGuard.unbundled_env.merge(
994
+ "PATH" => ENV.fetch("PATH", ""),
995
+ "RUBYOPT" => nil,
996
+ "KETTLE_CHANGELOG_DEV_ROOT" => nil,
997
+ "K_RELEASE_CI_WORKFLOWS" => nil,
998
+ "KETTLE_RELEASE_SKIP_GITHUB_RELEASE" => nil
999
+ )
1000
+ gemfile = File.join(@root, "Gemfile")
1001
+ env["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
1002
+ env
1003
+ end
1004
+
1005
+ def handle_yard_documentation_failure(message)
1006
+ if @strict
1007
+ raise message
1008
+ else
1009
+ warn(message)
1010
+ nil
1011
+ end
1012
+ end
1013
+
1014
+ def command_successful?(status)
1015
+ return status if status == true || status == false
1016
+
1017
+ !status.respond_to?(:success?) || status.success?
1018
+ end
1019
+
1020
+ def yard_command_failure_message(command, output, status)
1021
+ exit_status = status.respond_to?(:exitstatus) ? status.exitstatus : nil
1022
+ message = "Failed to run #{yard_command_label(command)}"
1023
+ message = "#{message} (exit #{exit_status})" if exit_status
1024
+ output = output.to_s.strip
1025
+ message = "#{message}: #{output}" unless output.empty?
1026
+ message
1027
+ end
1028
+
1029
+ def yard_command_label(command)
1030
+ command = Array(command)
1031
+ bin = command.first.to_s
1032
+ if bin == File.join(@root, "bin", "rake")
1033
+ "bin/rake #{command.drop(1).join(" ")}".strip
1034
+ elsif bin == File.join(@root, "bin", "yard")
1035
+ "bin/yard"
1036
+ else
1037
+ command.join(" ")
1038
+ end
1039
+ end
1040
+
1041
+ def yard_documentation_commands
1042
+ commands = []
1043
+ rake = File.join(@root, "bin", "rake")
1044
+ commands << [rake, "yard"] if File.executable?(rake)
1045
+ yard = File.join(@root, "bin", "yard")
1046
+ commands << [yard] if File.executable?(yard)
1047
+ commands
1048
+ end
1049
+
1050
+ def resolved_changelog_path
1051
+ path = ENV.fetch("K_CHANGELOG_PATH", "").to_s.strip
1052
+ path = "CHANGELOG.md" if path.empty?
1053
+ File.expand_path(path, @root)
1054
+ end
1055
+
1056
+ def resolved_coverage_root
1057
+ path = ENV.fetch("K_CHANGELOG_COVERAGE_ROOT", "").to_s.strip
1058
+ return @root if path.empty?
1059
+
1060
+ File.expand_path(path, @root)
1061
+ end
1062
+
1063
+ def prepare_yard_fence_tmp_files
1064
+ yardopts = File.join(@root, ".yardopts")
1065
+ return unless File.file?(yardopts)
1066
+ return unless File.read(yardopts).include?("tmp/yard-fence")
1067
+
1068
+ require "yard/fence"
1069
+ outdir = File.join(@root, "tmp", "yard-fence")
1070
+ FileUtils.rm_rf(outdir)
1071
+ FileUtils.mkdir_p(outdir)
1072
+ Dir.glob(File.join(@root, Yard::Fence::GLOB_PATTERN)).each do |src|
1073
+ next unless File.file?(src)
1074
+
1075
+ content = File.read(src)
1076
+ sanitized = Yard::Fence.sanitize_text(content)
1077
+ File.write(File.join(outdir, File.basename(src)), sanitized)
1078
+ end
1079
+ end
1080
+
1081
+ def documented_percent_line(output)
1082
+ line = output.lines.find { |l| /\d+(?:\.\d+)?%\s+documented/.match?(l) }
1083
+ line&.strip
1084
+ end
1085
+
1086
+ # Transform legacy release headings that include a tag suffix, e.g.:
1087
+ # "## [1.2.3] 2022-08-29 ([tag][1.2.3t])"
1088
+ # into a heading followed by a list item:
1089
+ # "## [1.2.3] 2022-08-29\n\n- TAG: [v1.2.3][1.2.3t]"
1090
+ # The method is idempotent: if the next non-blank line already starts with "- TAG:",
1091
+ # no new list item is inserted. Case-insensitive match for [tag].
1092
+ def convert_heading_tag_suffix_to_list(text)
1093
+ lines = text.lines
1094
+ # Build a set of versions that have a tag reference (e.g., "[1.2.3t]: ...").
1095
+ # IMPORTANT: Only scan the footer link-ref block (starting at the [Unreleased]: line)
1096
+ # to avoid accidentally picking up body content.
1097
+ scan_start = lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) } || lines.length
1098
+ t_versions = {}
1099
+ non_t_tag_refs = {}
1100
+ lines[scan_start..-1].to_a.each do |l|
1101
+ # Case A: explicit tag ref key like [1.2.3t]: ...
1102
+ if (m = l.match(/^\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})t\]:\s+(\S+)/o))
1103
+ t_versions[m[1]] = true
1104
+ next
1105
+ end
1106
+ # Case B: non-t ref that nevertheless points to a tag URL (GitHub or GitLab)
1107
+ if (m2 = l.match(/^\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]:\s+(\S+)/o))
1108
+ url = m2[2]
1109
+ # Accept only when the URL clearly points to a tag for the SAME version
1110
+ # Support both GitHub and GitLab style tag URLs
1111
+ if (murl = url.match(%r{/(?:releases/)?tags?/v(#{CHANGELOG_VERSION_PATTERN_SOURCE})}io))
1112
+ version_in_url = murl[1]
1113
+ if version_in_url == m2[1]
1114
+ non_t_tag_refs[m2[1]] = url
1115
+ end
1116
+ end
1117
+ end
1118
+ end
1119
+ # Any version that has either explicit t-ref or a non-t tag-ref is considered tagged
1120
+ tag_ref_versions = {}
1121
+ t_versions.keys.each { |v| tag_ref_versions[v] = true }
1122
+ non_t_tag_refs.keys.each { |v| tag_ref_versions[v] = true }
1123
+
1124
+ out = []
1125
+ i = 0
1126
+ while i < lines.length
1127
+ line = lines[i]
1128
+ # Case 1: Heading contains legacy tag suffix we should convert
1129
+ m = line.match(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\](.*)\(\[tag\]\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})t\]\)\s*$/io)
1130
+ if m && m[1] == m[3]
1131
+ ver = m[1]
1132
+ middle = m[2]
1133
+ new_heading = ("## [#{ver}]" + middle).rstrip + "\n"
1134
+ out << new_heading
1135
+ # If the next non-blank line is already a TAG list item, don't add another
1136
+ k = i + 1
1137
+ k += 1 while k < lines.length && lines[k].strip == ""
1138
+ unless k < lines.length && lines[k].lstrip.start_with?("- TAG:")
1139
+ out << "\n"
1140
+ out << "- TAG: [v#{ver}][#{ver}t]\n"
1141
+ out << "\n"
1142
+ end
1143
+ # Skip any existing blank lines following the heading to avoid duplicate spacing
1144
+ i = k
1145
+ next
1146
+ end
1147
+
1148
+ # Case 2: Heading does NOT contain suffix, but a matching tag ref exists; ensure a TAG list item
1149
+ if (m2 = line.match(/^## \[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\](.*)$/o))
1150
+ ver2 = m2[1]
1151
+ # Skip Unreleased heading and non-release headings
1152
+ unless ver2.nil?
1153
+ k = i + 1
1154
+ k += 1 while k < lines.length && lines[k].strip == ""
1155
+ needs_tag = tag_ref_versions[ver2] && !(k < lines.length && lines[k].lstrip.start_with?("- TAG:"))
1156
+ if needs_tag
1157
+ out << (line.end_with?("\n") ? line : line + "\n")
1158
+ out << "\n"
1159
+ out << "- TAG: [v#{ver2}][#{ver2}t]\n"
1160
+ out << "\n"
1161
+ i = k
1162
+ next
1163
+ end
1164
+ end
1165
+ end
1166
+
1167
+ # Footer duplication: if we are in the footer block and encounter a non-t tag-ref
1168
+ # without a matching t-ref, emit the t-ref immediately after with the same URL.
1169
+ if i >= scan_start
1170
+ if (mref = line.match(/^\[(#{CHANGELOG_VERSION_PATTERN_SOURCE})\]:\s+(\S+)/o))
1171
+ vref = mref[1]
1172
+ mref[2]
1173
+ if non_t_tag_refs[vref] && !t_versions[vref]
1174
+ out << line
1175
+ out << "[#{vref}t]: #{non_t_tag_refs[vref]}\n"
1176
+ t_versions[vref] = true
1177
+ i += 1
1178
+ next
1179
+ end
1180
+ end
1181
+ end
1182
+
1183
+ out << line
1184
+ i += 1
1185
+ end
1186
+ out.join
1187
+ end
1188
+
1189
+ def update_link_refs(content, owner, repo, prev_version, new_version)
1190
+ # Convert any GitLab links to GitHub
1191
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^.]+)\.\.\.([^\s]+)}) do
1192
+ o = owner || Regexp.last_match(1)
1193
+ r = repo || Regexp.last_match(2)
1194
+ from = Regexp.last_match(3)
1195
+ to = Regexp.last_match(4)
1196
+ "https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
1197
+ end
1198
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
1199
+ o = owner || Regexp.last_match(1)
1200
+ r = repo || Regexp.last_match(2)
1201
+ tag = Regexp.last_match(3)
1202
+ "https://github.com/#{o}/#{r}/releases/tag/#{tag}"
1203
+ end
1204
+
1205
+ # Append or update the bottom reference links
1206
+ lines = content.lines
1207
+
1208
+ # Identify the true start of the footer reference block: the line with the [Unreleased] link-ref.
1209
+ # Do NOT assume the first link-ref after the Unreleased heading starts the footer, because
1210
+ # some changelogs contain interspersed link-refs within section bodies.
1211
+ unreleased_ref_idx = lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
1212
+ # If no [Unreleased]: ref is present, consider the reference block to start at EOF
1213
+ first_ref = unreleased_ref_idx || lines.length
1214
+
1215
+ # Ensure Unreleased points to GitHub compare from new tag to HEAD
1216
+ if owner && repo
1217
+ unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
1218
+ # Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
1219
+ idx = nil
1220
+ lines.each_with_index do |l, i|
1221
+ if l.start_with?(UNRELEASED_SECTION_HEADING) && i >= first_ref
1222
+ idx = i
1223
+ break
1224
+ end
1225
+ end
1226
+ if idx
1227
+ lines[idx] = unreleased_ref
1228
+ else
1229
+ lines << unreleased_ref
1230
+ end
1231
+ end
1232
+
1233
+ if owner && repo
1234
+ # Add compare link for the new version
1235
+ from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
1236
+ new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
1237
+ unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
1238
+ lines << new_compare
1239
+ end
1240
+ # Add tag link for the new version
1241
+ new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
1242
+ unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
1243
+ lines << new_tag
1244
+ end
1245
+ end
1246
+
1247
+ # Rebuild and sort the reference block so Unreleased is first, then newest to oldest versions, preserving everything above first_ref
1248
+ ref_lines = lines[first_ref..-1].select { |l| /^\[[^\]]+\]:\s+http/.match?(l) }
1249
+ # Deduplicate by key (text inside the square brackets)
1250
+ by_key = {}
1251
+ ref_lines.each do |l|
1252
+ if l =~ /^\[([^\]]+)\]:\s+/
1253
+ by_key[$1] = l
1254
+ end
1255
+ end
1256
+ unreleased_line = by_key.delete("Unreleased")
1257
+ # Separate version compare and tag links
1258
+ compares = {}
1259
+ tags = {}
1260
+ by_key.each do |k, v|
1261
+ if k =~ /^(#{CHANGELOG_VERSION_PATTERN_SOURCE})$/o
1262
+ compares[$1] = v
1263
+ elsif k =~ /^(#{CHANGELOG_VERSION_PATTERN_SOURCE})t$/o
1264
+ tags[$1] = v
1265
+ end
1266
+ end
1267
+ # Build a unified set of versions that appear in either compares or tags
1268
+ version_keys = (compares.keys | tags.keys)
1269
+ # Sort versions descending (newest to oldest)
1270
+ sorted_versions = version_keys.map { |s| Gem::Version.new(s) }.sort.reverse.map(&:to_s)
1271
+
1272
+ new_ref_block = []
1273
+ new_ref_block << unreleased_line if unreleased_line
1274
+ sorted_versions.each do |v|
1275
+ new_ref_block << compares[v] if compares[v]
1276
+ new_ref_block << tags[v] if tags[v]
1277
+ end
1278
+ # Replace the old block
1279
+ head = lines[0...first_ref]
1280
+ # Ensure exactly one blank line separating body content from the reference block
1281
+ if head.any? && head.last.to_s.strip != ""
1282
+ head << "\n"
1283
+ end
1284
+ rebuilt = head + new_ref_block + ["\n"]
1285
+ rebuilt.join
1286
+ end
1287
+
1288
+ # Ensure every Markdown atx-style heading line (e.g., "# ", "## ") has exactly one blank line
1289
+ # before and after it, skipping content inside fenced code blocks.
1290
+ def normalize_heading_spacing(text)
1291
+ lines = text.split("\n", -1)
1292
+ out = []
1293
+ in_fence = false
1294
+ fence_re = /^\s*```/
1295
+ heading_re = /^\s*#+\s+.+/
1296
+ lines.each_with_index do |ln, idx|
1297
+ if fence_re.match?(ln)
1298
+ in_fence = !in_fence
1299
+ out << ln
1300
+ next
1301
+ end
1302
+ if !in_fence && heading_re.match?(ln)
1303
+ # Ensure previous line is blank (unless start of file or already blank)
1304
+ prev_blank = out.empty? ? false : out.last.to_s.strip == ""
1305
+ out << "" unless out.empty? || prev_blank
1306
+ out << ln
1307
+ # Peek at next line in source to decide if we need to inject a blank now.
1308
+ nxt = lines[idx + 1]
1309
+ out << "" unless nxt.to_s.strip == ""
1310
+ else
1311
+ out << ln
1312
+ end
1313
+ end
1314
+ # Collapse multiple consecutive blank lines down to a single between regions that our logic might have doubled
1315
+ collapsed = []
1316
+ lines_enum = out
1317
+ lines_enum.each do |l|
1318
+ if l.strip == "" && collapsed.last.to_s.strip == ""
1319
+ next
1320
+ end
1321
+ collapsed << l
1322
+ end
1323
+ collapsed.join("\n")
1324
+ end
1325
+
1326
+ def ensure_footer_spacing(text)
1327
+ lines = text.split("\n", -1)
1328
+ # Find the Unreleased link-ref which denotes start of footer refs
1329
+ idx = lines.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
1330
+ return text unless idx
1331
+ head = lines[0...idx]
1332
+ tail = lines[idx..-1]
1333
+ # Ensure exactly one blank line between body and refs
1334
+ if head.any? && head.last.to_s.strip != ""
1335
+ head << ""
1336
+ elsif head.any? && head.last.to_s.strip == "" && head[-2].to_s.strip == ""
1337
+ # Collapse multiple blanks before footer to a single
1338
+ head.pop while head.any? && head.last.to_s.strip == ""
1339
+ head << ""
1340
+ end
1341
+ (head + tail).join("\n")
1342
+ end
1343
+
1344
+ # Determine the "from" side of the compare URL for the very first release.
1345
+ #
1346
+ # Priority:
1347
+ # 1. KETTLE_CHANGELOG_INITIAL_SHA env var — explicit override, required for hard-forks
1348
+ # (e.g. turbo_tests2 which forked from an upstream commit SHA).
1349
+ # 2. `git rev-list --max-parents=0 HEAD` — the root commit of this repository.
1350
+ # Correct for the overwhelming majority of new gems.
1351
+ # 3. "HEAD^" — last-resort fallback when git is unavailable or the command fails.
1352
+ #
1353
+ # @param _lines [Array<String>] kept for API compatibility (no longer used)
1354
+ # @return [String] the compare base: a commit SHA, tag, or fallback string
1355
+ def detect_initial_compare_base(_lines = nil)
1356
+ env_sha = ENV.fetch("KETTLE_CHANGELOG_INITIAL_SHA", nil)
1357
+ return env_sha.strip if env_sha && !env_sha.strip.empty?
1358
+
1359
+ sha = git_root_commit
1360
+ return sha if sha
1361
+
1362
+ warn(
1363
+ "Could not determine initial git root commit; using HEAD^ as compare base. " \
1364
+ "Set KETTLE_CHANGELOG_INITIAL_SHA to override."
1365
+ )
1366
+ "HEAD^"
1367
+ end
1368
+
1369
+ # Return the root commit SHA of the current repository, or nil on failure.
1370
+ # Uses the generic GitAdapter#capture escape hatch so tests can stub it.
1371
+ def git_root_commit
1372
+ out, ok = git_capture(["rev-list", "--max-parents=0", "HEAD"])
1373
+ sha = out.to_s.lines.last&.strip # take last line in case of multiple root commits
1374
+ (ok && sha && !sha.empty?) ? sha : nil
1375
+ rescue
1376
+ nil
1377
+ end
1378
+
1379
+ def git_capture(args)
1380
+ Kettle::Dev::GitAdapter.new(@root).capture(args)
1381
+ end
1382
+ end
1383
+ end
1384
+ end