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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +74 -1
- data/CITATION.cff +6 -6
- data/CONTRIBUTING.md +55 -31
- data/FUNDING.md +1 -1
- data/LICENSE.md +12 -0
- data/README.md +127 -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 +373 -62
- data/lib/kettle/dev/ci_monitor.rb +2 -2
- 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 +27 -24
- 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 +57 -30
- metadata.gz.sig +0 -0
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/bin/setup +0 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
#
|
|
198
|
-
|
|
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:
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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:
|
|
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", "
|
|
601
|
+
cmd = File.join(@root, "bin", "rake")
|
|
315
602
|
unless File.executable?(cmd)
|
|
316
603
|
if @strict
|
|
317
|
-
raise "bin/
|
|
604
|
+
raise "bin/rake not found or not executable; ensure rake is installed via bundler"
|
|
318
605
|
else
|
|
319
|
-
warn("bin/
|
|
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
|
|
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|
|
|
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|
|
|
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
|
|
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
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/kettle/dev/dvcs_cli.rb
CHANGED
|
@@ -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
|
|
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
|