kettle-dev 2.0.0 → 2.0.1

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.
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
4
7
 
5
8
  module Kettle
6
9
  module Dev
@@ -10,15 +13,23 @@ module Kettle
10
13
  # includes coverage and YARD stats, and updates link references.
11
14
  class ChangelogCLI
12
15
  UNRELEASED_SECTION_HEADING = "[Unreleased]:"
16
+ # Matches a Markdown link-reference definition line, e.g. `[key]: https://...`
17
+ LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s+\S+/
18
+ # Matches an ATX heading at H4 or deeper (####, #####, ...)
19
+ DEEP_HEADING_RE = /^[#]{4,}\s/
13
20
 
14
21
  # Initialize the changelog CLI
15
22
  # Sets up paths for CHANGELOG.md and coverage.json
16
23
  # @param strict [Boolean] when true (default), require coverage and yard data; raise errors if unavailable
17
- def initialize(strict: true)
24
+ # @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
25
+ # @param update_prep [Boolean] when true, update the most recent prepared release section in place
26
+ def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false)
18
27
  @root = Kettle::Dev::CIHelpers.project_root
19
28
  @changelog_path = File.join(@root, "CHANGELOG.md")
20
29
  @coverage_path = File.join(@root, "coverage", "coverage.json")
21
30
  @strict = strict
31
+ @enforce_coverage_thresholds = enforce_coverage_thresholds
32
+ @update_prep = update_prep
22
33
  end
23
34
 
24
35
  # Main entry point to update CHANGELOG.md
@@ -36,28 +47,21 @@ module Kettle
36
47
  warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
37
48
  end
38
49
 
50
+ changelog = File.read(@changelog_path)
51
+ plan = @update_prep ? explicit_update_prep_plan(changelog) : detect_plan(changelog, version)
52
+ confirm_plan!(plan)
53
+
54
+ if plan.fetch(:action) == :reformat_only
55
+ reformat_changelog!(changelog)
56
+ return
57
+ end
58
+
39
59
  line_cov_line, branch_cov_line = coverage_lines
40
60
  yard_line = yard_percent_documented
41
61
 
42
- changelog = File.read(@changelog_path)
43
-
44
- # If the detected version already exists in the changelog, offer reformat-only mode
45
- if changelog =~ /^## \[#{Regexp.escape(version)}\]/
46
- warn("CHANGELOG.md already has a section for version #{version}.")
47
- warn("It appears the version has not been bumped. You can reformat CHANGELOG.md without adding a new release section.")
48
- print("Proceed with reformat only? [y/N]: ")
49
- ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
50
- if ans == "y" || ans == "yes"
51
- updated = convert_heading_tag_suffix_to_list(changelog)
52
- updated = normalize_heading_spacing(updated)
53
- updated = ensure_footer_spacing(updated)
54
- updated = updated.rstrip + "\n"
55
- File.write(@changelog_path, updated)
56
- puts "CHANGELOG.md reformatted. No new version section added."
57
- return
58
- else
59
- abort("Aborting: version not bumped. Re-run after bumping version or answer 'y' to reformat-only.")
60
- end
62
+ if plan.fetch(:action) == :update_prepared_release
63
+ update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
64
+ return
61
65
  end
62
66
 
63
67
  unreleased_block, before, after = extract_unreleased(changelog)
@@ -136,6 +140,160 @@ module Kettle
136
140
  Kettle::Dev::ExitAdapter.abort(msg)
137
141
  end
138
142
 
143
+ def detect_plan(changelog, version)
144
+ latest_overall = nil
145
+ latest_for_series = nil
146
+ gem_name = nil
147
+ begin
148
+ gem_name = detect_gem_name
149
+ latest_overall, latest_for_series = latest_released_versions(gem_name, version)
150
+ rescue StandardError => e
151
+ warn("[kettle-changelog] gem.coop release check failed: #{e.class}: #{e.message}")
152
+ warn("Proceeding without live release info.")
153
+ end
154
+
155
+ _unreleased_block, _before, after = extract_unreleased(changelog)
156
+ latest_changelog_version = detect_previous_version(after.to_s)
157
+ section_exists = release_section_exists?(changelog, version)
158
+ latest_target = latest_release_target(version, latest_overall, latest_for_series)
159
+
160
+ if latest_target && Gem::Version.new(version) < Gem::Version.new(latest_target)
161
+ abort("Aborting: version.rb (#{version}) is lower than the latest released version for this release line (#{latest_target}).")
162
+ end
163
+
164
+ if section_exists && latest_changelog_version != version
165
+ abort("Aborting: CHANGELOG.md already contains a #{version} section, but the most recent release section is #{latest_changelog_version || "missing"}.")
166
+ end
167
+
168
+ action = if section_exists && latest_target == version
169
+ :reformat_only
170
+ elsif section_exists
171
+ :update_prepared_release
172
+ elsif latest_target == version
173
+ abort("Aborting: version.rb (#{version}) matches the latest released version, but CHANGELOG.md does not have #{version} as the most recent release section.")
174
+ else
175
+ :new_release
176
+ end
177
+
178
+ {
179
+ action: action,
180
+ version: version,
181
+ gem_name: gem_name,
182
+ latest_overall: latest_overall,
183
+ latest_for_series: latest_for_series,
184
+ latest_target: latest_target,
185
+ latest_changelog_version: latest_changelog_version,
186
+ }
187
+ end
188
+
189
+ def explicit_update_prep_plan(changelog)
190
+ _unreleased_block, _before, after = extract_unreleased(changelog)
191
+ prepared_version = detect_previous_version(after.to_s)
192
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless prepared_version
193
+
194
+ {
195
+ action: :update_prepared_release,
196
+ version: prepared_version,
197
+ gem_name: nil,
198
+ latest_overall: nil,
199
+ latest_for_series: nil,
200
+ latest_target: nil,
201
+ latest_changelog_version: prepared_version,
202
+ explicit: true,
203
+ }
204
+ end
205
+
206
+ def confirm_plan!(plan)
207
+ puts "kettle-changelog selected plan: #{plan_label(plan.fetch(:action))}"
208
+ puts " version.rb: #{plan.fetch(:version)}"
209
+ puts " latest released: #{plan.fetch(:latest_overall) || "unknown"}"
210
+ puts " latest released for current series: #{plan.fetch(:latest_for_series) || "unknown"}"
211
+ puts " latest CHANGELOG.md release: #{plan.fetch(:latest_changelog_version) || "none"}"
212
+ puts " gem: #{plan.fetch(:gem_name) || "unknown"}"
213
+ print("Continue with this plan? [y/N]: ")
214
+ ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
215
+ return if ans == "y" || ans == "yes"
216
+
217
+ abort("Aborting: changelog plan was not confirmed.")
218
+ end
219
+
220
+ def plan_label(action)
221
+ case action
222
+ when :new_release
223
+ "create a new release section"
224
+ when :update_prepared_release
225
+ "update the prepared release section in place"
226
+ when :reformat_only
227
+ "reformat CHANGELOG.md without adding a release section"
228
+ else
229
+ action.to_s
230
+ end
231
+ end
232
+
233
+ def reformat_changelog!(changelog)
234
+ updated = convert_heading_tag_suffix_to_list(changelog)
235
+ updated = normalize_heading_spacing(updated)
236
+ updated = ensure_footer_spacing(updated)
237
+ updated = updated.rstrip + "\n"
238
+ File.write(@changelog_path, updated)
239
+ puts "CHANGELOG.md reformatted. No new version section added."
240
+ end
241
+
242
+ def release_section_exists?(changelog, version)
243
+ changelog.match?(/^## \[#{Regexp.escape(version)}\]/)
244
+ end
245
+
246
+ def detect_gem_name
247
+ gemspecs = Dir[File.join(@root, "*.gemspec")]
248
+ abort("Could not find a .gemspec in project root.") if gemspecs.empty?
249
+ path = gemspecs.min
250
+ content = File.read(path)
251
+ m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
252
+ abort("Could not determine gem name from #{Kettle::Dev.display_path(path)}.") unless m
253
+
254
+ m[2]
255
+ end
256
+
257
+ def latest_released_versions(gem_name, current_version)
258
+ uri = URI("https://gem.coop/api/v1/versions/#{gem_name}.json")
259
+ res = Net::HTTP.get_response(uri)
260
+ return [nil, nil] unless res.is_a?(Net::HTTPSuccess)
261
+
262
+ data = JSON.parse(res.body)
263
+ versions = data.map { |h| h["number"] }.compact
264
+ versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s.match?(/[a-zA-Z]/) }
265
+ gversions = versions.map { |s| Gem::Version.new(s) }.sort
266
+ latest_overall = gversions.last&.to_s
267
+
268
+ cur = Gem::Version.new(current_version)
269
+ series = cur.segments[0, 2]
270
+ latest_series = gversions.reverse.find { |gv| gv.segments[0, 2] == series }&.to_s
271
+ [latest_overall, latest_series]
272
+ rescue StandardError => e
273
+ Kettle::Dev.debug_error(e, __method__)
274
+ [nil, nil]
275
+ end
276
+
277
+ def latest_release_target(version, latest_overall, latest_for_series)
278
+ return unless latest_overall
279
+
280
+ cur = Gem::Version.new(version)
281
+ overall = Gem::Version.new(latest_overall)
282
+ cur_series = cur.segments[0, 2]
283
+ overall_series = overall.segments[0, 2]
284
+
285
+ if latest_for_series
286
+ lfs_series = Gem::Version.new(latest_for_series).segments[0, 2]
287
+ latest_for_series = nil unless lfs_series == cur_series
288
+ end
289
+
290
+ if (cur_series <=> overall_series) == -1
291
+ latest_for_series
292
+ else
293
+ latest_overall
294
+ end
295
+ end
296
+
139
297
  def detect_version
140
298
  candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
141
299
  abort("Could not find version.rb under lib/**.") if candidates.empty?
@@ -163,8 +321,23 @@ module Kettle
163
321
  end
164
322
  # Now next_i points to the next section heading or EOF
165
323
  before = lines[0..(start_i - 1)].join
166
- unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
167
- after = lines[next_i..-1]&.join || ""
324
+ unreleased_body = lines[(start_i + 1)..(next_i - 1)] || []
325
+ after_lines = lines[next_i..-1] || []
326
+
327
+ # When this is the very first release there is no `## [X.Y.Z]` heading to act
328
+ # as a boundary, so the footer link-ref block ([Unreleased]: ...) sits at the
329
+ # end of the unreleased body. Move everything from the [Unreleased]: line
330
+ # onward into `after` so those refs are not mistaken for section content.
331
+ if next_i == lines.length
332
+ footer_i = unreleased_body.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
333
+ if footer_i
334
+ after_lines = unreleased_body[footer_i..-1] + after_lines
335
+ unreleased_body = unreleased_body[0...footer_i]
336
+ end
337
+ end
338
+
339
+ unreleased_block = unreleased_body.join
340
+ after = after_lines.join
168
341
  [unreleased_block, before, after]
169
342
  end
170
343
 
@@ -176,6 +349,116 @@ module Kettle
176
349
  nil
177
350
  end
178
351
 
352
+ def update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
353
+ unreleased_block, before, after = extract_unreleased(changelog)
354
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md") if unreleased_block.nil?
355
+
356
+ release_heading = after.to_s.match(/\A## \[(\d+\.\d+\.\d+)\][^\n]*\n/)
357
+ abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless release_heading
358
+
359
+ prepared_version = release_heading[1]
360
+ release_and_tail = after.lines
361
+ next_release_index = release_and_tail[1..-1].to_a.index { |line| line.start_with?("## [") }
362
+ release_line_count = next_release_index ? next_release_index + 1 : release_and_tail.length
363
+ release_lines = release_and_tail[0...release_line_count]
364
+ tail = release_and_tail[release_line_count..-1].to_a.join
365
+
366
+ release_body = release_lines[1..-1].to_a.join
367
+ merged_body = merge_release_body_with_unreleased(release_body, unreleased_block)
368
+
369
+ release_section = +""
370
+ release_section << "## [#{prepared_version}] - #{today}\n"
371
+ release_section << "- TAG: [v#{prepared_version}][#{prepared_version}t]\n"
372
+ release_section << "- #{line_cov_line}\n" if line_cov_line
373
+ release_section << "- #{branch_cov_line}\n" if branch_cov_line
374
+ release_section << "- #{yard_line}\n" if yard_line
375
+ release_section << merged_body
376
+ release_section.rstrip!
377
+ release_section << "\n\n"
378
+
379
+ unreleased_reset = <<~MD
380
+ ## [Unreleased]
381
+ ### Added
382
+ ### Changed
383
+ ### Deprecated
384
+ ### Removed
385
+ ### Fixed
386
+ ### Security
387
+ MD
388
+
389
+ previous_version = detect_previous_version(tail)
390
+ updated = before + unreleased_reset + "\n" + release_section + tail
391
+ updated = update_link_refs(updated, owner, repo, previous_version, prepared_version)
392
+ updated = convert_heading_tag_suffix_to_list(updated)
393
+ updated = normalize_heading_spacing(updated)
394
+ updated = updated.rstrip + "\n"
395
+
396
+ File.write(@changelog_path, updated)
397
+ puts "CHANGELOG.md updated in place for v#{prepared_version}."
398
+ end
399
+
400
+ def merge_release_body_with_unreleased(release_body, unreleased_block)
401
+ existing = strip_release_metadata(release_body)
402
+ incoming = filter_unreleased_sections(unreleased_block)
403
+ return existing if incoming.strip.empty?
404
+ return incoming if existing.strip.empty?
405
+
406
+ leading, sections = split_h3_sections(existing)
407
+ _incoming_leading, incoming_sections = split_h3_sections(incoming)
408
+ return [existing.rstrip, incoming.rstrip, ""].join("\n\n") if sections.empty? || incoming_sections.empty?
409
+
410
+ incoming_sections.each do |incoming_section|
411
+ section = sections.find { |candidate| candidate.fetch(:heading) == incoming_section.fetch(:heading) }
412
+ if section
413
+ section.fetch(:lines) << "\n" unless section.fetch(:lines).empty? || section.fetch(:lines).last.to_s.strip.empty?
414
+ section.fetch(:lines).concat(trim_blank_lines(incoming_section.fetch(:lines)))
415
+ else
416
+ sections << incoming_section
417
+ end
418
+ end
419
+
420
+ ([leading] + sections.map { |section| section.fetch(:heading) + trim_blank_lines(section.fetch(:lines)).join }).join.rstrip + "\n"
421
+ end
422
+
423
+ def strip_release_metadata(release_body)
424
+ lines = release_body.lines
425
+ while lines.any? && lines.first.strip.empty?
426
+ lines.shift
427
+ end
428
+ while lines.any?
429
+ stripped = lines.first.strip
430
+ break unless stripped.start_with?("- TAG:", "- COVERAGE:", "- BRANCH COVERAGE:") || stripped.match?(/\A- \d+(?:\.\d+)?%\s+documented\z/)
431
+
432
+ lines.shift
433
+ end
434
+ lines.join
435
+ end
436
+
437
+ def split_h3_sections(text)
438
+ leading = +""
439
+ sections = []
440
+ current = nil
441
+ text.lines.each do |line|
442
+ if line.start_with?("### ")
443
+ current = {heading: line, lines: []}
444
+ sections << current
445
+ elsif current
446
+ current.fetch(:lines) << line
447
+ else
448
+ leading << line
449
+ end
450
+ end
451
+ [leading, sections]
452
+ end
453
+
454
+ def trim_blank_lines(lines)
455
+ trimmed = lines.dup
456
+ trimmed.shift while trimmed.any? && trimmed.first.to_s.strip.empty?
457
+ trimmed.pop while trimmed.any? && trimmed.last.to_s.strip.empty?
458
+ trimmed << "\n" if trimmed.any? && !trimmed.last.end_with?("\n")
459
+ trimmed
460
+ end
461
+
179
462
  # From the Unreleased block, keep only sections that have content.
180
463
  # We detect sections as lines starting with '### '. A section has content if there is at least
181
464
  # one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
@@ -194,8 +477,10 @@ module Kettle
194
477
  chunk << lines[i]
195
478
  i += 1
196
479
  end
197
- # Determine if chunk has any content (non-blank)
198
- content_present = chunk.any? { |l| l.strip != "" }
480
+ # A section has real content only if it contains at least one non-blank line that is
481
+ # neither a link-reference definition ([key]: url) nor a deeper heading (H4+).
482
+ # Link-ref defs and H4+ headings alone are not meaningful section content.
483
+ content_present = chunk.any? { |l| l.strip != "" && l !~ LINK_REF_DEF_RE && l !~ DEEP_HEADING_RE }
199
484
  if content_present
200
485
  # Trim leading blank lines so there is no blank line after the header
201
486
  while chunk.any? && chunk.first.strip == ""
@@ -224,39 +509,31 @@ module Kettle
224
509
  # Delete old coverage files to ensure we get current data
225
510
  coverage_dir = File.dirname(@coverage_path)
226
511
  if Dir.exist?(coverage_dir)
227
- puts "Cleaning old coverage data from #{coverage_dir}..."
512
+ puts "Cleaning old coverage data from #{Kettle::Dev.display_path(coverage_dir)}..."
228
513
  Dir.glob(File.join(coverage_dir, "*")).each do |file|
229
514
  File.delete(file) if File.file?(file)
230
515
  end
231
516
  end
232
517
 
233
- puts "Generating fresh coverage data by running: bin/rake coverage"
234
-
235
- # Run bin/rake coverage to generate coverage.json
236
- rake_cmd = File.join(@root, "bin", "rake")
237
- unless File.executable?(rake_cmd)
238
- raise "bin/rake not found or not executable; cannot generate coverage data"
239
- end
518
+ puts "Generating fresh coverage data by running: bundle exec kettle-test"
240
519
 
241
- # Run the command exactly as the user would run it manually
242
- # The coverage task knows how to configure itself properly
243
- success = system(rake_cmd, "coverage", chdir: @root)
520
+ success = system(changelog_coverage_env, "bundle", "exec", "kettle-test", chdir: @root)
244
521
 
245
522
  unless success
246
- raise "bin/rake coverage failed with exit status #{$?.exitstatus || "unknown"}"
523
+ raise "bundle exec kettle-test failed with exit status #{$?.exitstatus || "unknown"}"
247
524
  end
248
525
 
249
526
  puts "Coverage generation complete."
250
527
 
251
528
  # Check if coverage.json was generated
252
529
  unless File.file?(@coverage_path)
253
- raise "Coverage JSON not found at #{@coverage_path} after running bin/rake coverage"
530
+ raise "Coverage JSON not found at #{Kettle::Dev.display_path(@coverage_path)} after running bundle exec kettle-test"
254
531
  end
255
532
  else
256
533
  # Non-strict mode: check if coverage.json exists, warn if not
257
534
  unless File.file?(@coverage_path)
258
- warn("Coverage JSON not found at #{@coverage_path}.")
259
- warn("Run: bin/rake coverage to generate it")
535
+ warn("Coverage JSON not found at #{Kettle::Dev.display_path(@coverage_path)}.")
536
+ warn("Run: K_SOUP_COV_FORMATTERS=json bundle exec kettle-test to generate it")
260
537
  return [nil, nil]
261
538
  end
262
539
  end
@@ -310,36 +587,46 @@ module Kettle
310
587
  end
311
588
  end
312
589
 
590
+ def changelog_coverage_env
591
+ {
592
+ "K_SOUP_COV_DO" => "true",
593
+ "K_SOUP_COV_FORMATTERS" => "json",
594
+ "K_SOUP_COV_MIN_HARD" => @enforce_coverage_thresholds ? "true" : "false",
595
+ "K_SOUP_COV_MULTI_FORMATTERS" => "true",
596
+ "K_SOUP_COV_OPEN_BIN" => "",
597
+ }
598
+ end
599
+
313
600
  def yard_percent_documented
314
- cmd = File.join(@root, "bin", "yard")
601
+ cmd = File.join(@root, "bin", "rake")
315
602
  unless File.executable?(cmd)
316
603
  if @strict
317
- raise "bin/yard not found or not executable; ensure yard is installed via bundler"
604
+ raise "bin/rake not found or not executable; ensure rake is installed via bundler"
318
605
  else
319
- warn("bin/yard not found or not executable; ensure yard is installed via bundler")
606
+ warn("bin/rake not found or not executable; ensure rake is installed via bundler")
320
607
  return
321
608
  end
322
609
  end
323
610
 
324
611
  begin
325
- # Run bin/yard to get documentation percentage
326
- out, _ = Open3.capture2(cmd, {chdir: @root})
612
+ # Run the canonical docs task to get the documentation percentage.
613
+ out, _ = Open3.capture2(cmd, "yard", {chdir: @root})
327
614
  # Look for a line containing e.g., "95.35% documented"
328
- line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
615
+ line = out.lines.find { |l| /\d+(?:\.\d+)?%\s+documented/.match?(l) }
329
616
 
330
617
  if line
331
618
  line.strip
332
619
  elsif @strict
333
- raise "Could not find documented percentage in bin/yard output"
620
+ raise "Could not find documented percentage in bin/rake yard output"
334
621
  else
335
- warn("Could not find documented percentage in bin/yard output.")
622
+ warn("Could not find documented percentage in bin/rake yard output.")
336
623
  nil
337
624
  end
338
625
  rescue StandardError => e
339
626
  if @strict
340
- raise "Failed to run bin/yard: #{e.class}: #{e.message}"
627
+ raise "Failed to run bin/rake yard: #{e.class}: #{e.message}"
341
628
  else
342
- warn("Failed to run bin/yard: #{e.class}: #{e.message}")
629
+ warn("Failed to run bin/rake yard: #{e.class}: #{e.message}")
343
630
  nil
344
631
  end
345
632
  end
@@ -511,7 +798,7 @@ module Kettle
511
798
  end
512
799
 
513
800
  # Rebuild and sort the reference block so Unreleased is first, then newest to oldest versions, preserving everything above first_ref
514
- ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
801
+ ref_lines = lines[first_ref..-1].select { |l| /^\[[^\]]+\]:\s+http/.match?(l) }
515
802
  # Deduplicate by key (text inside the square brackets)
516
803
  by_key = {}
517
804
  ref_lines.each do |l|
@@ -560,12 +847,12 @@ module Kettle
560
847
  fence_re = /^\s*```/
561
848
  heading_re = /^\s*#+\s+.+/
562
849
  lines.each_with_index do |ln, idx|
563
- if ln =~ fence_re
850
+ if fence_re.match?(ln)
564
851
  in_fence = !in_fence
565
852
  out << ln
566
853
  next
567
854
  end
568
- if !in_fence && ln =~ heading_re
855
+ if !in_fence && heading_re.match?(ln)
569
856
  # Ensure previous line is blank (unless start of file or already blank)
570
857
  prev_blank = out.empty? ? false : out.last.to_s.strip == ""
571
858
  out << "" unless out.empty? || prev_blank
@@ -607,16 +894,40 @@ module Kettle
607
894
  (head + tail).join("\n")
608
895
  end
609
896
 
610
- def detect_initial_compare_base(lines)
611
- # Fallback when prev_version is unknown: try to find the first compare base used historically
612
- # e.g., for 1.0.0 it may be a commit SHA instead of a tag
613
- ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
614
- if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
615
- m[1]
616
- else
617
- # Default to previous tag name if none found (unlikely to be correct, but better than empty)
618
- "HEAD^"
619
- end
897
+ # Determine the "from" side of the compare URL for the very first release.
898
+ #
899
+ # Priority:
900
+ # 1. KETTLE_CHANGELOG_INITIAL_SHA env var explicit override, required for hard-forks
901
+ # (e.g. turbo_tests2 which forked from an upstream commit SHA).
902
+ # 2. `git rev-list --max-parents=0 HEAD` — the root commit of this repository.
903
+ # Correct for the overwhelming majority of new gems.
904
+ # 3. "HEAD^" last-resort fallback when git is unavailable or the command fails.
905
+ #
906
+ # @param _lines [Array<String>] kept for API compatibility (no longer used)
907
+ # @return [String] the compare base: a commit SHA, tag, or fallback string
908
+ def detect_initial_compare_base(_lines = nil)
909
+ env_sha = ENV.fetch("KETTLE_CHANGELOG_INITIAL_SHA", nil)
910
+ return env_sha.strip if env_sha && !env_sha.strip.empty?
911
+
912
+ sha = git_root_commit
913
+ return sha if sha
914
+
915
+ warn(
916
+ "Could not determine initial git root commit; using HEAD^ as compare base. " \
917
+ "Set KETTLE_CHANGELOG_INITIAL_SHA to override.",
918
+ )
919
+ "HEAD^"
920
+ end
921
+
922
+ # Return the root commit SHA of the current repository, or nil on failure.
923
+ # Uses the generic GitAdapter#capture escape hatch so tests can stub it.
924
+ def git_root_commit
925
+ adapter = Kettle::Dev::GitAdapter.new
926
+ out, ok = adapter.capture(["rev-list", "--max-parents=0", "HEAD"])
927
+ sha = out.to_s.lines.last&.strip # take last line in case of multiple root commits
928
+ (ok && sha && !sha.empty?) ? sha : nil
929
+ rescue StandardError
930
+ nil
620
931
  end
621
932
  end
622
933
  end
@@ -247,7 +247,7 @@ module Kettle
247
247
  pbar&.increment unless pbar&.finished?
248
248
  elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
249
249
  reason = (pipe["failure_reason"] || "").to_s
250
- if reason =~ /insufficient|quota|minute/i
250
+ if /insufficient|quota|minute/i.match?(reason)
251
251
  result[:status] = "unknown"
252
252
  pbar&.finish unless pbar&.finished?
253
253
  else
@@ -352,7 +352,7 @@ module Kettle
352
352
  elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
353
353
  # Special-case: if failure is due to exhausted minutes/insufficient quota, treat as unknown and continue
354
354
  reason = (pipe["failure_reason"] || "").to_s
355
- if reason =~ /insufficient|quota|minute/i
355
+ if /insufficient|quota|minute/i.match?(reason)
356
356
  puts "\nGitLab reports pipeline cannot run due to quota/minutes exhaustion. Result is unknown; continuing."
357
357
  pbar&.finish unless pbar&.finished?
358
358
  break
@@ -371,7 +371,7 @@ module Kettle
371
371
  new_content.gsub!(summary_line_with_cs, summary_line_no_cs)
372
372
  else
373
373
  # Ensure the line contains (Coming soon!) so readers know it's partial
374
- unless content =~ summary_line_with_cs
374
+ unless summary_line_with_cs.match?(content)
375
375
  new_content.gsub!("<summary>Find this repo on other forges</summary>", "<summary>Find this repo on other forges (Coming soon!)</summary>")
376
376
  end
377
377
  end