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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +119 -0
- data/LICENSE.md +12 -0
- data/README.md +429 -0
- data/exe/kettle-changelog +244 -0
- data/lib/kettle/changelog/cli.rb +1384 -0
- data/lib/kettle/changelog/entry_adder.rb +115 -0
- data/lib/kettle/changelog/version.rb +13 -0
- data/lib/kettle/changelog.rb +18 -0
- data/sig/kettle/changelog.rbs +9 -0
- data.tar.gz.sig +0 -0
- metadata +341 -0
- metadata.gz.sig +0 -0
|
@@ -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
|