kettle-dev 2.0.0 → 2.0.2
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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +94 -1
- data/CITATION.cff +6 -6
- data/CONTRIBUTING.md +60 -31
- data/FUNDING.md +1 -1
- data/LICENSE.md +12 -0
- data/README.md +132 -207
- data/certs/pboling.pem +27 -0
- data/exe/kettle-changelog +24 -13
- data/exe/kettle-check-eof +7 -0
- data/exe/kettle-check-eof.sh +118 -0
- data/lib/kettle/dev/changelog_cli.rb +429 -71
- data/lib/kettle/dev/ci_helpers.rb +26 -13
- data/lib/kettle/dev/ci_monitor.rb +3 -11
- data/lib/kettle/dev/dvcs_cli.rb +1 -1
- data/lib/kettle/dev/gem_spec_reader.rb +98 -8
- data/lib/kettle/dev/git_adapter.rb +37 -1
- data/lib/kettle/dev/open_collective_config.rb +3 -3
- data/lib/kettle/dev/pre_release_cli.rb +4 -4
- data/lib/kettle/dev/rakelib/reek.rake +9 -6
- data/lib/kettle/dev/rakelib/spec_test.rake +25 -25
- data/lib/kettle/dev/rakelib/yard.rake +15 -30
- data/lib/kettle/dev/readme_backers.rb +4 -4
- data/lib/kettle/dev/release_cli.rb +28 -33
- data/lib/kettle/dev/version.rb +2 -87
- data/lib/kettle/dev.rb +28 -6
- data/sig/kettle/dev/version.rbs +8 -0
- data.tar.gz.sig +0 -0
- metadata +61 -34
- metadata.gz.sig +0 -0
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/bin/setup +0 -8
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "fileutils"
|
|
4
8
|
|
|
5
9
|
module Kettle
|
|
6
10
|
module Dev
|
|
@@ -10,15 +14,23 @@ module Kettle
|
|
|
10
14
|
# includes coverage and YARD stats, and updates link references.
|
|
11
15
|
class ChangelogCLI
|
|
12
16
|
UNRELEASED_SECTION_HEADING = "[Unreleased]:"
|
|
17
|
+
# Matches a Markdown link-reference definition line, e.g. `[key]: https://...`
|
|
18
|
+
LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s+\S+/
|
|
19
|
+
# Matches an ATX heading at H4 or deeper (####, #####, ...)
|
|
20
|
+
DEEP_HEADING_RE = /^[#]{4,}\s/
|
|
13
21
|
|
|
14
22
|
# Initialize the changelog CLI
|
|
15
23
|
# Sets up paths for CHANGELOG.md and coverage.json
|
|
16
24
|
# @param strict [Boolean] when true (default), require coverage and yard data; raise errors if unavailable
|
|
17
|
-
|
|
25
|
+
# @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
|
|
26
|
+
# @param update_prep [Boolean] when true, update the most recent prepared release section in place
|
|
27
|
+
def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false)
|
|
18
28
|
@root = Kettle::Dev::CIHelpers.project_root
|
|
19
29
|
@changelog_path = File.join(@root, "CHANGELOG.md")
|
|
20
30
|
@coverage_path = File.join(@root, "coverage", "coverage.json")
|
|
21
31
|
@strict = strict
|
|
32
|
+
@enforce_coverage_thresholds = enforce_coverage_thresholds
|
|
33
|
+
@update_prep = update_prep
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
# Main entry point to update CHANGELOG.md
|
|
@@ -36,28 +48,21 @@ module Kettle
|
|
|
36
48
|
warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
|
|
37
49
|
end
|
|
38
50
|
|
|
51
|
+
changelog = File.read(@changelog_path)
|
|
52
|
+
plan = @update_prep ? explicit_update_prep_plan(changelog) : detect_plan(changelog, version)
|
|
53
|
+
confirm_plan!(plan)
|
|
54
|
+
|
|
55
|
+
if plan.fetch(:action) == :reformat_only
|
|
56
|
+
reformat_changelog!(changelog)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
39
60
|
line_cov_line, branch_cov_line = coverage_lines
|
|
40
61
|
yard_line = yard_percent_documented
|
|
41
62
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
63
|
+
if plan.fetch(:action) == :update_prepared_release
|
|
64
|
+
update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
|
|
65
|
+
return
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
unreleased_block, before, after = extract_unreleased(changelog)
|
|
@@ -136,6 +141,160 @@ module Kettle
|
|
|
136
141
|
Kettle::Dev::ExitAdapter.abort(msg)
|
|
137
142
|
end
|
|
138
143
|
|
|
144
|
+
def detect_plan(changelog, version)
|
|
145
|
+
latest_overall = nil
|
|
146
|
+
latest_for_series = nil
|
|
147
|
+
gem_name = nil
|
|
148
|
+
begin
|
|
149
|
+
gem_name = detect_gem_name
|
|
150
|
+
latest_overall, latest_for_series = latest_released_versions(gem_name, version)
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
warn("[kettle-changelog] gem.coop release check failed: #{e.class}: #{e.message}")
|
|
153
|
+
warn("Proceeding without live release info.")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
_unreleased_block, _before, after = extract_unreleased(changelog)
|
|
157
|
+
latest_changelog_version = detect_previous_version(after.to_s)
|
|
158
|
+
section_exists = release_section_exists?(changelog, version)
|
|
159
|
+
latest_target = latest_release_target(version, latest_overall, latest_for_series)
|
|
160
|
+
|
|
161
|
+
if latest_target && Gem::Version.new(version) < Gem::Version.new(latest_target)
|
|
162
|
+
abort("Aborting: version.rb (#{version}) is lower than the latest released version for this release line (#{latest_target}).")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if section_exists && latest_changelog_version != version
|
|
166
|
+
abort("Aborting: CHANGELOG.md already contains a #{version} section, but the most recent release section is #{latest_changelog_version || "missing"}.")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
action = if section_exists && latest_target == version
|
|
170
|
+
:reformat_only
|
|
171
|
+
elsif section_exists
|
|
172
|
+
:update_prepared_release
|
|
173
|
+
elsif latest_target == version
|
|
174
|
+
abort("Aborting: version.rb (#{version}) matches the latest released version, but CHANGELOG.md does not have #{version} as the most recent release section.")
|
|
175
|
+
else
|
|
176
|
+
:new_release
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
action: action,
|
|
181
|
+
version: version,
|
|
182
|
+
gem_name: gem_name,
|
|
183
|
+
latest_overall: latest_overall,
|
|
184
|
+
latest_for_series: latest_for_series,
|
|
185
|
+
latest_target: latest_target,
|
|
186
|
+
latest_changelog_version: latest_changelog_version,
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def explicit_update_prep_plan(changelog)
|
|
191
|
+
_unreleased_block, _before, after = extract_unreleased(changelog)
|
|
192
|
+
prepared_version = detect_previous_version(after.to_s)
|
|
193
|
+
abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless prepared_version
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
action: :update_prepared_release,
|
|
197
|
+
version: prepared_version,
|
|
198
|
+
gem_name: nil,
|
|
199
|
+
latest_overall: nil,
|
|
200
|
+
latest_for_series: nil,
|
|
201
|
+
latest_target: nil,
|
|
202
|
+
latest_changelog_version: prepared_version,
|
|
203
|
+
explicit: true,
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def confirm_plan!(plan)
|
|
208
|
+
puts "kettle-changelog selected plan: #{plan_label(plan.fetch(:action))}"
|
|
209
|
+
puts " version.rb: #{plan.fetch(:version)}"
|
|
210
|
+
puts " latest released: #{plan.fetch(:latest_overall) || "unknown"}"
|
|
211
|
+
puts " latest released for current series: #{plan.fetch(:latest_for_series) || "unknown"}"
|
|
212
|
+
puts " latest CHANGELOG.md release: #{plan.fetch(:latest_changelog_version) || "none"}"
|
|
213
|
+
puts " gem: #{plan.fetch(:gem_name) || "unknown"}"
|
|
214
|
+
print("Continue with this plan? [y/N]: ")
|
|
215
|
+
ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
|
|
216
|
+
return if ans == "y" || ans == "yes"
|
|
217
|
+
|
|
218
|
+
abort("Aborting: changelog plan was not confirmed.")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def plan_label(action)
|
|
222
|
+
case action
|
|
223
|
+
when :new_release
|
|
224
|
+
"create a new release section"
|
|
225
|
+
when :update_prepared_release
|
|
226
|
+
"update the prepared release section in place"
|
|
227
|
+
when :reformat_only
|
|
228
|
+
"reformat CHANGELOG.md without adding a release section"
|
|
229
|
+
else
|
|
230
|
+
action.to_s
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def reformat_changelog!(changelog)
|
|
235
|
+
updated = convert_heading_tag_suffix_to_list(changelog)
|
|
236
|
+
updated = normalize_heading_spacing(updated)
|
|
237
|
+
updated = ensure_footer_spacing(updated)
|
|
238
|
+
updated = updated.rstrip + "\n"
|
|
239
|
+
File.write(@changelog_path, updated)
|
|
240
|
+
puts "CHANGELOG.md reformatted. No new version section added."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def release_section_exists?(changelog, version)
|
|
244
|
+
changelog.match?(/^## \[#{Regexp.escape(version)}\]/)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def detect_gem_name
|
|
248
|
+
gemspecs = Dir[File.join(@root, "*.gemspec")]
|
|
249
|
+
abort("Could not find a .gemspec in project root.") if gemspecs.empty?
|
|
250
|
+
path = gemspecs.min
|
|
251
|
+
content = File.read(path)
|
|
252
|
+
m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
|
|
253
|
+
abort("Could not determine gem name from #{Kettle::Dev.display_path(path)}.") unless m
|
|
254
|
+
|
|
255
|
+
m[2]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def latest_released_versions(gem_name, current_version)
|
|
259
|
+
uri = URI("https://gem.coop/api/v1/versions/#{gem_name}.json")
|
|
260
|
+
res = Net::HTTP.get_response(uri)
|
|
261
|
+
return [nil, nil] unless res.is_a?(Net::HTTPSuccess)
|
|
262
|
+
|
|
263
|
+
data = JSON.parse(res.body)
|
|
264
|
+
versions = data.map { |h| h["number"] }.compact
|
|
265
|
+
versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s.match?(/[a-zA-Z]/) }
|
|
266
|
+
gversions = versions.map { |s| Gem::Version.new(s) }.sort
|
|
267
|
+
latest_overall = gversions.last&.to_s
|
|
268
|
+
|
|
269
|
+
cur = Gem::Version.new(current_version)
|
|
270
|
+
series = cur.segments[0, 2]
|
|
271
|
+
latest_series = gversions.reverse.find { |gv| gv.segments[0, 2] == series }&.to_s
|
|
272
|
+
[latest_overall, latest_series]
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
275
|
+
[nil, nil]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def latest_release_target(version, latest_overall, latest_for_series)
|
|
279
|
+
return unless latest_overall
|
|
280
|
+
|
|
281
|
+
cur = Gem::Version.new(version)
|
|
282
|
+
overall = Gem::Version.new(latest_overall)
|
|
283
|
+
cur_series = cur.segments[0, 2]
|
|
284
|
+
overall_series = overall.segments[0, 2]
|
|
285
|
+
|
|
286
|
+
if latest_for_series
|
|
287
|
+
lfs_series = Gem::Version.new(latest_for_series).segments[0, 2]
|
|
288
|
+
latest_for_series = nil unless lfs_series == cur_series
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if (cur_series <=> overall_series) == -1
|
|
292
|
+
latest_for_series
|
|
293
|
+
else
|
|
294
|
+
latest_overall
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
139
298
|
def detect_version
|
|
140
299
|
candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
|
|
141
300
|
abort("Could not find version.rb under lib/**.") if candidates.empty?
|
|
@@ -163,8 +322,23 @@ module Kettle
|
|
|
163
322
|
end
|
|
164
323
|
# Now next_i points to the next section heading or EOF
|
|
165
324
|
before = lines[0..(start_i - 1)].join
|
|
166
|
-
|
|
167
|
-
|
|
325
|
+
unreleased_body = lines[(start_i + 1)..(next_i - 1)] || []
|
|
326
|
+
after_lines = lines[next_i..-1] || []
|
|
327
|
+
|
|
328
|
+
# When this is the very first release there is no `## [X.Y.Z]` heading to act
|
|
329
|
+
# as a boundary, so the footer link-ref block ([Unreleased]: ...) sits at the
|
|
330
|
+
# end of the unreleased body. Move everything from the [Unreleased]: line
|
|
331
|
+
# onward into `after` so those refs are not mistaken for section content.
|
|
332
|
+
if next_i == lines.length
|
|
333
|
+
footer_i = unreleased_body.index { |l| l.start_with?(UNRELEASED_SECTION_HEADING) }
|
|
334
|
+
if footer_i
|
|
335
|
+
after_lines = unreleased_body[footer_i..-1] + after_lines
|
|
336
|
+
unreleased_body = unreleased_body[0...footer_i]
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
unreleased_block = unreleased_body.join
|
|
341
|
+
after = after_lines.join
|
|
168
342
|
[unreleased_block, before, after]
|
|
169
343
|
end
|
|
170
344
|
|
|
@@ -176,6 +350,116 @@ module Kettle
|
|
|
176
350
|
nil
|
|
177
351
|
end
|
|
178
352
|
|
|
353
|
+
def update_prepared_release!(changelog, today, owner, repo, line_cov_line, branch_cov_line, yard_line)
|
|
354
|
+
unreleased_block, before, after = extract_unreleased(changelog)
|
|
355
|
+
abort("Could not find '## [Unreleased]' section in CHANGELOG.md") if unreleased_block.nil?
|
|
356
|
+
|
|
357
|
+
release_heading = after.to_s.match(/\A## \[(\d+\.\d+\.\d+)\][^\n]*\n/)
|
|
358
|
+
abort("Could not find a prepared release section after '## [Unreleased]' in CHANGELOG.md") unless release_heading
|
|
359
|
+
|
|
360
|
+
prepared_version = release_heading[1]
|
|
361
|
+
release_and_tail = after.lines
|
|
362
|
+
next_release_index = release_and_tail[1..-1].to_a.index { |line| line.start_with?("## [") }
|
|
363
|
+
release_line_count = next_release_index ? next_release_index + 1 : release_and_tail.length
|
|
364
|
+
release_lines = release_and_tail[0...release_line_count]
|
|
365
|
+
tail = release_and_tail[release_line_count..-1].to_a.join
|
|
366
|
+
|
|
367
|
+
release_body = release_lines[1..-1].to_a.join
|
|
368
|
+
merged_body = merge_release_body_with_unreleased(release_body, unreleased_block)
|
|
369
|
+
|
|
370
|
+
release_section = +""
|
|
371
|
+
release_section << "## [#{prepared_version}] - #{today}\n"
|
|
372
|
+
release_section << "- TAG: [v#{prepared_version}][#{prepared_version}t]\n"
|
|
373
|
+
release_section << "- #{line_cov_line}\n" if line_cov_line
|
|
374
|
+
release_section << "- #{branch_cov_line}\n" if branch_cov_line
|
|
375
|
+
release_section << "- #{yard_line}\n" if yard_line
|
|
376
|
+
release_section << merged_body
|
|
377
|
+
release_section.rstrip!
|
|
378
|
+
release_section << "\n\n"
|
|
379
|
+
|
|
380
|
+
unreleased_reset = <<~MD
|
|
381
|
+
## [Unreleased]
|
|
382
|
+
### Added
|
|
383
|
+
### Changed
|
|
384
|
+
### Deprecated
|
|
385
|
+
### Removed
|
|
386
|
+
### Fixed
|
|
387
|
+
### Security
|
|
388
|
+
MD
|
|
389
|
+
|
|
390
|
+
previous_version = detect_previous_version(tail)
|
|
391
|
+
updated = before + unreleased_reset + "\n" + release_section + tail
|
|
392
|
+
updated = update_link_refs(updated, owner, repo, previous_version, prepared_version)
|
|
393
|
+
updated = convert_heading_tag_suffix_to_list(updated)
|
|
394
|
+
updated = normalize_heading_spacing(updated)
|
|
395
|
+
updated = updated.rstrip + "\n"
|
|
396
|
+
|
|
397
|
+
File.write(@changelog_path, updated)
|
|
398
|
+
puts "CHANGELOG.md updated in place for v#{prepared_version}."
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def merge_release_body_with_unreleased(release_body, unreleased_block)
|
|
402
|
+
existing = strip_release_metadata(release_body)
|
|
403
|
+
incoming = filter_unreleased_sections(unreleased_block)
|
|
404
|
+
return existing if incoming.strip.empty?
|
|
405
|
+
return incoming if existing.strip.empty?
|
|
406
|
+
|
|
407
|
+
leading, sections = split_h3_sections(existing)
|
|
408
|
+
_incoming_leading, incoming_sections = split_h3_sections(incoming)
|
|
409
|
+
return [existing.rstrip, incoming.rstrip, ""].join("\n\n") if sections.empty? || incoming_sections.empty?
|
|
410
|
+
|
|
411
|
+
incoming_sections.each do |incoming_section|
|
|
412
|
+
section = sections.find { |candidate| candidate.fetch(:heading) == incoming_section.fetch(:heading) }
|
|
413
|
+
if section
|
|
414
|
+
section.fetch(:lines) << "\n" unless section.fetch(:lines).empty? || section.fetch(:lines).last.to_s.strip.empty?
|
|
415
|
+
section.fetch(:lines).concat(trim_blank_lines(incoming_section.fetch(:lines)))
|
|
416
|
+
else
|
|
417
|
+
sections << incoming_section
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
([leading] + sections.map { |section| section.fetch(:heading) + trim_blank_lines(section.fetch(:lines)).join }).join.rstrip + "\n"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def strip_release_metadata(release_body)
|
|
425
|
+
lines = release_body.lines
|
|
426
|
+
while lines.any? && lines.first.strip.empty?
|
|
427
|
+
lines.shift
|
|
428
|
+
end
|
|
429
|
+
while lines.any?
|
|
430
|
+
stripped = lines.first.strip
|
|
431
|
+
break unless stripped.start_with?("- TAG:", "- COVERAGE:", "- BRANCH COVERAGE:") || stripped.match?(/\A- \d+(?:\.\d+)?%\s+documented\z/)
|
|
432
|
+
|
|
433
|
+
lines.shift
|
|
434
|
+
end
|
|
435
|
+
lines.join
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def split_h3_sections(text)
|
|
439
|
+
leading = +""
|
|
440
|
+
sections = []
|
|
441
|
+
current = nil
|
|
442
|
+
text.lines.each do |line|
|
|
443
|
+
if line.start_with?("### ")
|
|
444
|
+
current = {heading: line, lines: []}
|
|
445
|
+
sections << current
|
|
446
|
+
elsif current
|
|
447
|
+
current.fetch(:lines) << line
|
|
448
|
+
else
|
|
449
|
+
leading << line
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
[leading, sections]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def trim_blank_lines(lines)
|
|
456
|
+
trimmed = lines.dup
|
|
457
|
+
trimmed.shift while trimmed.any? && trimmed.first.to_s.strip.empty?
|
|
458
|
+
trimmed.pop while trimmed.any? && trimmed.last.to_s.strip.empty?
|
|
459
|
+
trimmed << "\n" if trimmed.any? && !trimmed.last.end_with?("\n")
|
|
460
|
+
trimmed
|
|
461
|
+
end
|
|
462
|
+
|
|
179
463
|
# From the Unreleased block, keep only sections that have content.
|
|
180
464
|
# We detect sections as lines starting with '### '. A section has content if there is at least
|
|
181
465
|
# one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
|
|
@@ -194,8 +478,10 @@ module Kettle
|
|
|
194
478
|
chunk << lines[i]
|
|
195
479
|
i += 1
|
|
196
480
|
end
|
|
197
|
-
#
|
|
198
|
-
|
|
481
|
+
# A section has real content only if it contains at least one non-blank line that is
|
|
482
|
+
# neither a link-reference definition ([key]: url) nor a deeper heading (H4+).
|
|
483
|
+
# Link-ref defs and H4+ headings alone are not meaningful section content.
|
|
484
|
+
content_present = chunk.any? { |l| l.strip != "" && l !~ LINK_REF_DEF_RE && l !~ DEEP_HEADING_RE }
|
|
199
485
|
if content_present
|
|
200
486
|
# Trim leading blank lines so there is no blank line after the header
|
|
201
487
|
while chunk.any? && chunk.first.strip == ""
|
|
@@ -224,39 +510,28 @@ module Kettle
|
|
|
224
510
|
# Delete old coverage files to ensure we get current data
|
|
225
511
|
coverage_dir = File.dirname(@coverage_path)
|
|
226
512
|
if Dir.exist?(coverage_dir)
|
|
227
|
-
puts "Cleaning old coverage data from #{coverage_dir}..."
|
|
513
|
+
puts "Cleaning old coverage data from #{Kettle::Dev.display_path(coverage_dir)}..."
|
|
228
514
|
Dir.glob(File.join(coverage_dir, "*")).each do |file|
|
|
229
515
|
File.delete(file) if File.file?(file)
|
|
230
516
|
end
|
|
231
517
|
end
|
|
232
518
|
|
|
233
|
-
puts "Generating fresh coverage data by running:
|
|
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
|
|
519
|
+
puts "Generating fresh coverage data by running: bundle exec kettle-test"
|
|
240
520
|
|
|
241
|
-
|
|
242
|
-
# The coverage task knows how to configure itself properly
|
|
243
|
-
success = system(rake_cmd, "coverage", chdir: @root)
|
|
521
|
+
success = system(changelog_coverage_env, "bundle", "exec", "kettle-test", chdir: @root)
|
|
244
522
|
|
|
245
523
|
unless success
|
|
246
|
-
raise "
|
|
524
|
+
raise "bundle exec kettle-test failed with exit status #{$?.exitstatus || "unknown"}"
|
|
247
525
|
end
|
|
248
526
|
|
|
249
527
|
puts "Coverage generation complete."
|
|
250
528
|
|
|
251
|
-
|
|
252
|
-
unless File.file?(@coverage_path)
|
|
253
|
-
raise "Coverage JSON not found at #{@coverage_path} after running bin/rake coverage"
|
|
254
|
-
end
|
|
529
|
+
ensure_changelog_coverage_json!
|
|
255
530
|
else
|
|
256
531
|
# Non-strict mode: check if coverage.json exists, warn if not
|
|
257
532
|
unless File.file?(@coverage_path)
|
|
258
|
-
warn(
|
|
259
|
-
warn("Run:
|
|
533
|
+
warn(coverage_json_missing_message)
|
|
534
|
+
warn("Run: K_SOUP_COV_FORMATTERS=json bundle exec kettle-test to generate it")
|
|
260
535
|
return [nil, nil]
|
|
261
536
|
end
|
|
262
537
|
end
|
|
@@ -310,41 +585,100 @@ module Kettle
|
|
|
310
585
|
end
|
|
311
586
|
end
|
|
312
587
|
|
|
588
|
+
def changelog_coverage_env
|
|
589
|
+
{
|
|
590
|
+
"K_SOUP_COV_DO" => "true",
|
|
591
|
+
"K_SOUP_COV_FORMATTERS" => "json",
|
|
592
|
+
"K_SOUP_COV_MIN_HARD" => @enforce_coverage_thresholds ? "true" : "false",
|
|
593
|
+
"K_SOUP_COV_MULTI_FORMATTERS" => "true",
|
|
594
|
+
"K_SOUP_COV_OPEN_BIN" => "",
|
|
595
|
+
}
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def ensure_changelog_coverage_json!
|
|
599
|
+
return if File.file?(@coverage_path)
|
|
600
|
+
|
|
601
|
+
raise coverage_json_missing_message
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def coverage_json_missing_message
|
|
605
|
+
[
|
|
606
|
+
"Coverage JSON not found at #{Kettle::Dev.display_path(@coverage_path)} after running bundle exec kettle-test.",
|
|
607
|
+
"kettle-test runs specs in parallel and is expected to collate parallel SimpleCov results into this canonical file.",
|
|
608
|
+
"If it is missing, coverage was not enabled in ENV config or the rake/task hooks did not load the coverage integration.",
|
|
609
|
+
].join(" ")
|
|
610
|
+
end
|
|
611
|
+
|
|
313
612
|
def yard_percent_documented
|
|
314
|
-
|
|
315
|
-
|
|
613
|
+
commands = yard_documentation_commands
|
|
614
|
+
if commands.empty?
|
|
316
615
|
if @strict
|
|
317
|
-
raise "bin/yard not found or not executable; ensure yard
|
|
616
|
+
raise "bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler"
|
|
318
617
|
else
|
|
319
|
-
warn("bin/yard not found or not executable; ensure yard
|
|
618
|
+
warn("bin/rake and bin/yard not found or not executable; ensure rake and yard are installed via bundler")
|
|
320
619
|
return
|
|
321
620
|
end
|
|
322
621
|
end
|
|
323
622
|
|
|
324
623
|
begin
|
|
325
|
-
# Run
|
|
326
|
-
out
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
line
|
|
332
|
-
|
|
333
|
-
|
|
624
|
+
# Run the canonical docs task to get the documentation percentage.
|
|
625
|
+
out = +""
|
|
626
|
+
commands.each do |command|
|
|
627
|
+
prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
|
|
628
|
+
output, _status = Open3.capture2(*command, {chdir: @root})
|
|
629
|
+
out << output
|
|
630
|
+
line = documented_percent_line(output)
|
|
631
|
+
return line if line
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
if @strict
|
|
635
|
+
raise "Could not find documented percentage in bin/rake yard output"
|
|
334
636
|
else
|
|
335
|
-
warn("Could not find documented percentage in bin/yard output.")
|
|
637
|
+
warn("Could not find documented percentage in bin/rake yard output.")
|
|
336
638
|
nil
|
|
337
639
|
end
|
|
338
640
|
rescue StandardError => e
|
|
339
641
|
if @strict
|
|
340
|
-
raise "Failed to run bin/yard: #{e.class}: #{e.message}"
|
|
642
|
+
raise "Failed to run bin/rake yard: #{e.class}: #{e.message}"
|
|
341
643
|
else
|
|
342
|
-
warn("Failed to run bin/yard: #{e.class}: #{e.message}")
|
|
644
|
+
warn("Failed to run bin/rake yard: #{e.class}: #{e.message}")
|
|
343
645
|
nil
|
|
344
646
|
end
|
|
345
647
|
end
|
|
346
648
|
end
|
|
347
649
|
|
|
650
|
+
def yard_documentation_commands
|
|
651
|
+
commands = []
|
|
652
|
+
rake = File.join(@root, "bin", "rake")
|
|
653
|
+
commands << [rake, "yard"] if File.executable?(rake)
|
|
654
|
+
yard = File.join(@root, "bin", "yard")
|
|
655
|
+
commands << [yard] if File.executable?(yard)
|
|
656
|
+
commands
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def prepare_yard_fence_tmp_files
|
|
660
|
+
yardopts = File.join(@root, ".yardopts")
|
|
661
|
+
return unless File.file?(yardopts)
|
|
662
|
+
return unless File.read(yardopts).include?("tmp/yard-fence")
|
|
663
|
+
|
|
664
|
+
require "yard/fence"
|
|
665
|
+
outdir = File.join(@root, "tmp", "yard-fence")
|
|
666
|
+
FileUtils.rm_rf(outdir)
|
|
667
|
+
FileUtils.mkdir_p(outdir)
|
|
668
|
+
Dir.glob(File.join(@root, Yard::Fence::GLOB_PATTERN)).each do |src|
|
|
669
|
+
next unless File.file?(src)
|
|
670
|
+
|
|
671
|
+
content = File.read(src)
|
|
672
|
+
sanitized = Yard::Fence.sanitize_text(content)
|
|
673
|
+
File.write(File.join(outdir, File.basename(src)), sanitized)
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def documented_percent_line(output)
|
|
678
|
+
line = output.lines.find { |l| /\d+(?:\.\d+)?%\s+documented/.match?(l) }
|
|
679
|
+
line&.strip
|
|
680
|
+
end
|
|
681
|
+
|
|
348
682
|
# Transform legacy release headings that include a tag suffix, e.g.:
|
|
349
683
|
# "## [1.2.3] 2022-08-29 ([tag][1.2.3t])"
|
|
350
684
|
# into a heading followed by a list item:
|
|
@@ -511,7 +845,7 @@ module Kettle
|
|
|
511
845
|
end
|
|
512
846
|
|
|
513
847
|
# 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|
|
|
848
|
+
ref_lines = lines[first_ref..-1].select { |l| /^\[[^\]]+\]:\s+http/.match?(l) }
|
|
515
849
|
# Deduplicate by key (text inside the square brackets)
|
|
516
850
|
by_key = {}
|
|
517
851
|
ref_lines.each do |l|
|
|
@@ -560,12 +894,12 @@ module Kettle
|
|
|
560
894
|
fence_re = /^\s*```/
|
|
561
895
|
heading_re = /^\s*#+\s+.+/
|
|
562
896
|
lines.each_with_index do |ln, idx|
|
|
563
|
-
if ln
|
|
897
|
+
if fence_re.match?(ln)
|
|
564
898
|
in_fence = !in_fence
|
|
565
899
|
out << ln
|
|
566
900
|
next
|
|
567
901
|
end
|
|
568
|
-
if !in_fence && ln
|
|
902
|
+
if !in_fence && heading_re.match?(ln)
|
|
569
903
|
# Ensure previous line is blank (unless start of file or already blank)
|
|
570
904
|
prev_blank = out.empty? ? false : out.last.to_s.strip == ""
|
|
571
905
|
out << "" unless out.empty? || prev_blank
|
|
@@ -607,16 +941,40 @@ module Kettle
|
|
|
607
941
|
(head + tail).join("\n")
|
|
608
942
|
end
|
|
609
943
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
944
|
+
# Determine the "from" side of the compare URL for the very first release.
|
|
945
|
+
#
|
|
946
|
+
# Priority:
|
|
947
|
+
# 1. KETTLE_CHANGELOG_INITIAL_SHA env var — explicit override, required for hard-forks
|
|
948
|
+
# (e.g. turbo_tests2 which forked from an upstream commit SHA).
|
|
949
|
+
# 2. `git rev-list --max-parents=0 HEAD` — the root commit of this repository.
|
|
950
|
+
# Correct for the overwhelming majority of new gems.
|
|
951
|
+
# 3. "HEAD^" — last-resort fallback when git is unavailable or the command fails.
|
|
952
|
+
#
|
|
953
|
+
# @param _lines [Array<String>] kept for API compatibility (no longer used)
|
|
954
|
+
# @return [String] the compare base: a commit SHA, tag, or fallback string
|
|
955
|
+
def detect_initial_compare_base(_lines = nil)
|
|
956
|
+
env_sha = ENV.fetch("KETTLE_CHANGELOG_INITIAL_SHA", nil)
|
|
957
|
+
return env_sha.strip if env_sha && !env_sha.strip.empty?
|
|
958
|
+
|
|
959
|
+
sha = git_root_commit
|
|
960
|
+
return sha if sha
|
|
961
|
+
|
|
962
|
+
warn(
|
|
963
|
+
"Could not determine initial git root commit; using HEAD^ as compare base. " \
|
|
964
|
+
"Set KETTLE_CHANGELOG_INITIAL_SHA to override.",
|
|
965
|
+
)
|
|
966
|
+
"HEAD^"
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# Return the root commit SHA of the current repository, or nil on failure.
|
|
970
|
+
# Uses the generic GitAdapter#capture escape hatch so tests can stub it.
|
|
971
|
+
def git_root_commit
|
|
972
|
+
adapter = Kettle::Dev::GitAdapter.new
|
|
973
|
+
out, ok = adapter.capture(["rev-list", "--max-parents=0", "HEAD"])
|
|
974
|
+
sha = out.to_s.lines.last&.strip # take last line in case of multiple root commits
|
|
975
|
+
(ok && sha && !sha.empty?) ? sha : nil
|
|
976
|
+
rescue StandardError
|
|
977
|
+
nil
|
|
620
978
|
end
|
|
621
979
|
end
|
|
622
980
|
end
|