kettle-dev 1.0.10 → 1.0.11

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.
@@ -47,42 +47,28 @@
47
47
  # rake yard # Generate YARD Documentation
48
48
  #
49
49
 
50
- # External gems
51
50
  require "bundler/gem_tasks" if !Dir[File.join(__dir__, "*.gemspec")].empty?
52
51
 
52
+ # External gems - add here!
53
+ require "kettle/dev"
54
+
53
55
  # Define a base default task early so other files can enhance it.
54
56
  desc "Default tasks aggregator"
55
57
  task :default do
56
58
  puts "Default task complete."
57
59
  end
58
60
 
59
- # Detect if the invoked task is spec/test to avoid eagerly requiring the library,
60
- # which would load code before SimpleCov can start (when running `rake spec`).
61
- invoked_tasks = Rake.application.top_level_tasks
62
- running_specs = invoked_tasks.any? { |t| t == "spec" || t == "test" || t == "coverage" }
61
+ Kettle::Dev.install_tasks
63
62
 
64
- if running_specs
65
- # Define minimal rspec tasks locally to keep coverage accurate
66
- begin
67
- require "rspec/core/rake_task"
68
- desc("Run RSpec code examples")
69
- RSpec::Core::RakeTask.new(:spec)
70
- desc("Run tests")
71
- task(test: :spec)
72
- rescue LoadError
73
- # If rspec isn't available, let it fail when the task is invoked
74
- end
75
- else
76
- require "kettle-dev"
63
+ ### RELEASE TASKS
64
+ # Setup stone_checksums
65
+ begin
66
+ require "stone_checksums"
77
67
 
78
- ### RELEASE TASKS
79
- # Setup stone_checksums
80
- begin
81
- require "stone_checksums"
82
- rescue LoadError
83
- desc("(stub) build:generate_checksums is unavailable")
84
- task("build:generate_checksums") do
85
- warn("NOTE: stone_checksums isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
86
- end
68
+ GemChecksums.install_tasks
69
+ rescue LoadError
70
+ desc("(stub) build:generate_checksums is unavailable")
71
+ task("build:generate_checksums") do
72
+ warn("NOTE: stone_checksums isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
87
73
  end
88
74
  end
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # vim: set syntax=ruby
5
+
6
+ # kettle-changelog: Generate a CHANGELOG.md entry for the current VERSION.
7
+ # - Reads VERSION from lib/**/version.rb (must be unique across files)
8
+ # - Moves entries from the "Unreleased" section into a new versioned section
9
+ # - Prepends 4 heading lines:
10
+ # - TAG
11
+ # - COVERAGE (line coverage)
12
+ # - BRANCH COVERAGE (branch coverage)
13
+ # - percent documented (parsed from `bin/yard` output)
14
+ # - Updates bottom link references to GitHub style, converts any existing
15
+ # GitLab links to GitHub links, and appends the new [X.Y.Z] and [X.Y.Zt] links.
16
+ #
17
+ # Notes:
18
+ # - Expects a JSON coverage report at coverage/coverage.json. If missing,
19
+ # it will instruct you to run: K_SOUP_COV_FORMATTERS="json" bin/rspec
20
+ # - Expects bin/yard to be available via Bundler.
21
+
22
+ $stdout.sync = true
23
+
24
+ # Depending library or project must be using bundler
25
+ require "bundler/setup"
26
+
27
+ require "json"
28
+ require "time"
29
+ require "open3"
30
+ require "shellwords"
31
+
32
+ begin
33
+ require "kettle/dev"
34
+ rescue LoadError => e
35
+ warn("kettle/dev: failed to load: #{e.message}")
36
+ warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
37
+ exit(1)
38
+ end
39
+
40
+ require "kettle/dev/versioning"
41
+
42
+ puts "== kettle-changelog v#{Kettle::Dev::Version::VERSION} =="
43
+
44
+ module Kettle
45
+ module Dev
46
+ class ChangelogCLI
47
+ def initialize
48
+ @root = Kettle::Dev::CIHelpers.project_root
49
+ @changelog_path = File.join(@root, "CHANGELOG.md")
50
+ @coverage_path = File.join(@root, "coverage", "coverage.json")
51
+ end
52
+
53
+ def run
54
+ version = Kettle::Dev::Versioning.detect_version(@root)
55
+ today = Time.now.strftime("%Y-%m-%d")
56
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
57
+ unless owner && repo
58
+ warn("Could not determine GitHub owner/repo from origin remote.")
59
+ warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
60
+ end
61
+
62
+ line_cov_line, branch_cov_line = coverage_lines
63
+ yard_line = yard_percent_documented
64
+
65
+ changelog = File.read(@changelog_path)
66
+
67
+ # If the detected version already exists in the changelog, abort to avoid duplicates
68
+ if changelog =~ /^## \[#{Regexp.escape(version)}\]/
69
+ abort("CHANGELOG.md already has a section for version #{version}. Bump version.rb or remove the duplicate.")
70
+ end
71
+
72
+ unreleased_block, before, after = extract_unreleased(changelog)
73
+ if unreleased_block.nil?
74
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md")
75
+ end
76
+
77
+ if unreleased_block.strip.empty?
78
+ warn("No entries found under Unreleased. Creating an empty version section anyway.")
79
+ end
80
+
81
+ prev_version = detect_previous_version(after)
82
+
83
+ new_section = +""
84
+ new_section << "## [#{version}] - #{today}\n"
85
+ new_section << "- TAG: [v#{version}][#{version}t]\n"
86
+ new_section << "- #{line_cov_line}\n" if line_cov_line
87
+ new_section << "- #{branch_cov_line}\n" if branch_cov_line
88
+ new_section << "- #{yard_line}\n" if yard_line
89
+ new_section << filter_unreleased_sections(unreleased_block)
90
+ # Ensure exactly one blank line separates this new section from the next section
91
+ new_section.rstrip!
92
+ new_section << "\n\n"
93
+
94
+ # Reset the Unreleased section to empty category headings
95
+ unreleased_reset = <<~MD
96
+ ## [Unreleased]
97
+ ### Added
98
+ ### Changed
99
+ ### Deprecated
100
+ ### Removed
101
+ ### Fixed
102
+ ### Security
103
+ MD
104
+
105
+ updated = before + unreleased_reset + "\n" + new_section + after
106
+
107
+ updated = update_link_refs(updated, owner, repo, prev_version, version)
108
+
109
+ File.write(@changelog_path, updated)
110
+ puts "CHANGELOG.md updated with v#{version} section."
111
+ end
112
+
113
+ private
114
+
115
+ def abort(msg)
116
+ Kettle::Dev::ExitAdapter.abort(msg)
117
+ rescue NameError
118
+ Kernel.abort(msg)
119
+ end
120
+
121
+ def detect_version
122
+ candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
123
+ abort("Could not find version.rb under lib/**.") if candidates.empty?
124
+ versions = candidates.map do |path|
125
+ content = File.read(path)
126
+ m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
127
+ next unless m
128
+ m[2]
129
+ end.compact
130
+ abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
131
+ abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
132
+ versions.first
133
+ end
134
+
135
+ def extract_unreleased(content)
136
+ lines = content.lines
137
+ start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
138
+ return [nil, nil, nil] unless start_i
139
+ # Find the next version heading after Unreleased
140
+ next_i = (start_i + 1)
141
+ while next_i < lines.length && !lines[next_i].start_with?("## [")
142
+ next_i += 1
143
+ end
144
+ # Now next_i points to the next section heading or EOF
145
+ before = lines[0..(start_i - 1)].join
146
+ unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
147
+ after = lines[next_i..-1]&.join || ""
148
+ [unreleased_block, before, after]
149
+ end
150
+
151
+ def detect_previous_version(after_text)
152
+ # after_text begins with the first released section following Unreleased
153
+ m = after_text.match(/^## \[(\d+\.\d+\.\d+)\]/)
154
+ return m[1] if m
155
+ nil
156
+ end
157
+
158
+ # From the Unreleased block, keep only sections that have content.
159
+ # We detect sections as lines starting with '### '. A section has content if there is at least
160
+ # one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
161
+ # Returns a string that includes only the non-empty sections with their content.
162
+ def filter_unreleased_sections(unreleased_block)
163
+ lines = unreleased_block.lines
164
+ out = []
165
+ i = 0
166
+ while i < lines.length
167
+ line = lines[i]
168
+ if line.start_with?("### ")
169
+ header = line
170
+ i += 1
171
+ chunk = []
172
+ while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
173
+ chunk << lines[i]
174
+ i += 1
175
+ end
176
+ # Determine if chunk has any content (non-blank)
177
+ content_present = chunk.any? { |l| l.strip != "" }
178
+ if content_present
179
+ # Trim trailing blank lines
180
+ while chunk.any? && chunk.last.strip == ""
181
+ chunk.pop
182
+ end
183
+ out << header
184
+ out.concat(chunk)
185
+ out << "\n" unless out.last&.end_with?("\n")
186
+ end
187
+ next
188
+ else
189
+ # Lines outside sections are ignored for released sections
190
+ i += 1
191
+ end
192
+ end
193
+ out.join
194
+ end
195
+
196
+ def coverage_lines
197
+ unless File.file?(@coverage_path)
198
+ warn("Coverage JSON not found at #{@coverage_path}.")
199
+ warn("Run: K_SOUP_COV_FORMATTERS=\"json\" bin/rspec")
200
+ return [nil, nil]
201
+ end
202
+ data = JSON.parse(File.read(@coverage_path))
203
+ files = data["coverage"] || {}
204
+ file_count = 0
205
+ total_lines = 0
206
+ covered_lines = 0
207
+ total_branches = 0
208
+ covered_branches = 0
209
+ files.each_value do |h|
210
+ lines = h["lines"] || []
211
+ line_relevant = lines.count { |x| x.is_a?(Integer) }
212
+ line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
213
+ if line_relevant > 0
214
+ file_count += 1
215
+ total_lines += line_relevant
216
+ covered_lines += line_covered
217
+ end
218
+ branches = h["branches"] || []
219
+ branches.each do |b|
220
+ next unless b.is_a?(Hash)
221
+ cov = b["coverage"]
222
+ next unless cov.is_a?(Numeric)
223
+ total_branches += 1
224
+ covered_branches += 1 if cov > 0
225
+ end
226
+ end
227
+ line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
228
+ branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
229
+ line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
230
+ branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
231
+ [line_str, branch_str]
232
+ rescue StandardError => e
233
+ warn("Failed to parse coverage: #{e.class}: #{e.message}")
234
+ [nil, nil]
235
+ end
236
+
237
+ def yard_percent_documented
238
+ cmd = File.join(@root, "bin", "yard")
239
+ unless File.executable?(cmd)
240
+ warn("bin/yard not found or not executable; ensure yard is installed via bundler")
241
+ return
242
+ end
243
+ out, _ = Open3.capture2(cmd)
244
+ # Look for a line containing e.g., "95.35% documented"
245
+ line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
246
+ if line
247
+ line = line.strip
248
+ # Return exactly as requested: e.g. "95.35% documented"
249
+ line
250
+ else
251
+ warn("Could not find documented percentage in bin/yard output.")
252
+ nil
253
+ end
254
+ rescue StandardError => e
255
+ warn("Failed to run bin/yard: #{e.class}: #{e.message}")
256
+ nil
257
+ end
258
+
259
+ def update_link_refs(content, owner, repo, prev_version, new_version)
260
+ # Convert any GitLab links to GitHub
261
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^\.]+)\.\.\.([^\s]+)}) do
262
+ o = owner || Regexp.last_match(1)
263
+ r = repo || Regexp.last_match(2)
264
+ from = Regexp.last_match(3)
265
+ to = Regexp.last_match(4)
266
+ "https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
267
+ end
268
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
269
+ o = owner || Regexp.last_match(1)
270
+ r = repo || Regexp.last_match(2)
271
+ tag = Regexp.last_match(3)
272
+ "https://github.com/#{o}/#{r}/releases/tag/#{tag}"
273
+ end
274
+
275
+ # Append or update the bottom reference links
276
+ lines = content.lines
277
+
278
+ # Find the index of the Unreleased heading; only manipulate refs after this point
279
+ unreleased_idx = lines.index { |l| l.start_with?("## [Unreleased]") } || -1
280
+
281
+ # Find the first link-ref line (e.g., "[Unreleased]: http...") AFTER Unreleased
282
+ first_ref = nil
283
+ lines.each_with_index do |l, i|
284
+ if l =~ /^\[[^\]]+\]:\s+http/ && i > unreleased_idx
285
+ first_ref = i
286
+ break
287
+ end
288
+ end
289
+ unless first_ref
290
+ # Append at end if no ref block after Unreleased
291
+ first_ref = lines.length
292
+ lines << "\n"
293
+ end
294
+
295
+ # Ensure Unreleased points to GitHub compare from new tag to HEAD
296
+ if owner && repo
297
+ unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
298
+ # Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
299
+ idx = nil
300
+ lines.each_with_index do |l, i|
301
+ if l.start_with?("[Unreleased]:") && i >= first_ref
302
+ idx = i
303
+ break
304
+ end
305
+ end
306
+ if idx
307
+ lines[idx] = unreleased_ref
308
+ else
309
+ lines << unreleased_ref
310
+ end
311
+ end
312
+
313
+ if owner && repo
314
+ # Add compare link for the new version
315
+ from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
316
+ new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
317
+ unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
318
+ lines << new_compare
319
+ end
320
+ # Add tag link for the new version
321
+ new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
322
+ unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
323
+ lines << new_tag
324
+ end
325
+ end
326
+
327
+ # Rebuild and sort the reference block so newest is at the bottom, preserving everything above first_ref
328
+ ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
329
+ # Deduplicate by key (text inside the square brackets)
330
+ by_key = {}
331
+ ref_lines.each do |l|
332
+ if l =~ /^\[([^\]]+)\]:\s+/
333
+ by_key[$1] = l
334
+ end
335
+ end
336
+ unreleased_line = by_key.delete("Unreleased")
337
+ # Separate version compare and tag links
338
+ compares = {}
339
+ tags = {}
340
+ by_key.each do |k, v|
341
+ if k =~ /^(\d+\.\d+\.\d+)$/
342
+ compares[$1] = v
343
+ elsif k =~ /^(\d+\.\d+\.\d+)t$/
344
+ tags[$1] = v
345
+ end
346
+ end
347
+ # Sort versions ascending so newest at bottom
348
+ sorted_versions = compares.keys.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
349
+ # In case some versions only have tags or only compares, include them as well
350
+ (tags.keys - compares.keys).each { |s| sorted_versions |= [s] }
351
+ sorted_versions = sorted_versions.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
352
+
353
+ new_ref_block = []
354
+ new_ref_block << unreleased_line if unreleased_line
355
+ sorted_versions.each do |v|
356
+ new_ref_block << compares[v] if compares[v]
357
+ new_ref_block << tags[v] if tags[v]
358
+ end
359
+ # Replace the old block
360
+ rebuilt = lines[0...first_ref] + new_ref_block + ["\n"]
361
+ rebuilt.join
362
+ end
363
+
364
+ def detect_initial_compare_base(lines)
365
+ # Fallback when prev_version is unknown: try to find the first compare base used historically
366
+ # e.g., for 1.0.0 it may be a commit SHA instead of a tag
367
+ ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
368
+ if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
369
+ m[1]
370
+ else
371
+ # Default to previous tag name if none found (unlikely to be correct, but better than empty)
372
+ "HEAD^"
373
+ end
374
+ end
375
+ end
376
+ end
377
+ end
378
+
379
+ begin
380
+ if ARGV.include?("-h") || ARGV.include?("--help")
381
+ puts <<~USAGE
382
+ Usage: kettle-changelog
383
+
384
+ Generates a new CHANGELOG.md entry for the current version detected from lib/**/version.rb.
385
+ Moves entries from [Unreleased] into the new section, adds coverage and documentation stats,
386
+ and updates bottom link references to GitHub style, adding new compare/tag links.
387
+
388
+ Prerequisites:
389
+ - coverage/coverage.json present (run: K_SOUP_COV_FORMATTERS="json" bin/rspec)
390
+ - yard installed and available via bin/yard
391
+ USAGE
392
+ exit(0)
393
+ end
394
+ Kettle::Dev::ChangelogCLI.new.run
395
+ rescue SystemExit
396
+ raise
397
+ rescue StandardError => e
398
+ warn("kettle-changelog: unexpected error: #{e.class}: #{e.message}")
399
+ warn(e.backtrace.join("\n")) if ENV["DEBUG"]
400
+ exit(1)
401
+ end
@@ -13,6 +13,8 @@ require "erb"
13
13
 
14
14
  require "kettle/dev"
15
15
 
16
+ puts "== kettle-commit-msg v#{Kettle::Dev::Version::VERSION} =="
17
+
16
18
  # ENV variable control (set in .envrc, or .env.local)
17
19
  # BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
18
20
  # FOOTER_APPEND = true/false append commit message footer
@@ -10,4 +10,6 @@ require "bundler/setup"
10
10
 
11
11
  require "kettle/dev"
12
12
 
13
+ puts "== kettle-readme-backers v#{Kettle::Dev::Version::VERSION} =="
14
+
13
15
  Kettle::Dev::ReadmeBackers.new.run!
data/exe/kettle-release CHANGED
@@ -19,13 +19,7 @@ $stdout.sync = true
19
19
  # Depending library or project must be using bundler
20
20
  require "bundler/setup"
21
21
 
22
- begin
23
- require "kettle/dev"
24
- rescue LoadError => e
25
- warn("kettle/dev: failed to load: #{e.message}")
26
- warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
27
- exit(1)
28
- end
22
+ require "kettle/dev"
29
23
 
30
24
  # Always execute when this file is loaded (e.g., via a Bundler binstub).
31
25
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
@@ -51,6 +45,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
51
45
  exit 0
52
46
  end
53
47
 
48
+ puts "== kettle-release v#{Kettle::Dev::Version::VERSION} =="
54
49
  begin
55
50
  Kettle::Dev::ReleaseCLI.new.run
56
51
  rescue LoadError => e
@@ -0,0 +1,5 @@
1
+ # Optional dependencies are not dependended on directly, but will be used if present.
2
+ # git gem is not a direct dependency for two reasons:
3
+ # 1. it is incompatible with Truffleruby v23
4
+ # 2. it depends on activesupport, which is too heavy
5
+ gem "git", ">= 1.19.1" # ruby >= 2.3