kettle-dev 1.0.23 → 1.0.24

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.
data/exe/kettle-changelog CHANGED
@@ -24,361 +24,24 @@ $stdout.sync = true
24
24
  # Depending library or project must be using bundler
25
25
  require "bundler/setup"
26
26
 
27
- require "json"
28
- require "time"
29
- require "open3"
30
- require "shellwords"
27
+ script_basename = File.basename(__FILE__)
31
28
 
32
29
  begin
30
+ # Standard library
31
+ require "json"
32
+ require "time"
33
+ require "open3"
34
+ require "shellwords"
35
+
36
+ # This library
33
37
  require "kettle/dev"
38
+ puts "== #{script_basename} v#{Kettle::Dev::Version::VERSION} =="
34
39
  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.")
40
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
41
+ warn("Hint: Ensure the host project has kettle-dev as a dependency and run bundle install.")
37
42
  exit(1)
38
43
  end
39
44
 
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
- # Ensure exactly one trailing newline at EOF
110
- updated = updated.rstrip + "\n"
111
-
112
- File.write(@changelog_path, updated)
113
- puts "CHANGELOG.md updated with v#{version} section."
114
- end
115
-
116
- private
117
-
118
- def abort(msg)
119
- Kettle::Dev::ExitAdapter.abort(msg)
120
- rescue NameError
121
- Kernel.abort(msg)
122
- end
123
-
124
- def detect_version
125
- candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
126
- abort("Could not find version.rb under lib/**.") if candidates.empty?
127
- versions = candidates.map do |path|
128
- content = File.read(path)
129
- m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
130
- next unless m
131
- m[2]
132
- end.compact
133
- abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
134
- abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
135
- versions.first
136
- end
137
-
138
- def extract_unreleased(content)
139
- lines = content.lines
140
- start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
141
- return [nil, nil, nil] unless start_i
142
- # Find the next version heading after Unreleased
143
- next_i = (start_i + 1)
144
- while next_i < lines.length && !lines[next_i].start_with?("## [")
145
- next_i += 1
146
- end
147
- # Now next_i points to the next section heading or EOF
148
- before = lines[0..(start_i - 1)].join
149
- unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
150
- after = lines[next_i..-1]&.join || ""
151
- [unreleased_block, before, after]
152
- end
153
-
154
- def detect_previous_version(after_text)
155
- # after_text begins with the first released section following Unreleased
156
- m = after_text.match(/^## \[(\d+\.\d+\.\d+)\]/)
157
- return m[1] if m
158
- nil
159
- end
160
-
161
- # From the Unreleased block, keep only sections that have content.
162
- # We detect sections as lines starting with '### '. A section has content if there is at least
163
- # one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
164
- # Returns a string that includes only the non-empty sections with their content.
165
- def filter_unreleased_sections(unreleased_block)
166
- lines = unreleased_block.lines
167
- out = []
168
- i = 0
169
- while i < lines.length
170
- line = lines[i]
171
- if line.start_with?("### ")
172
- header = line
173
- i += 1
174
- chunk = []
175
- while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
176
- chunk << lines[i]
177
- i += 1
178
- end
179
- # Determine if chunk has any content (non-blank)
180
- content_present = chunk.any? { |l| l.strip != "" }
181
- if content_present
182
- # Trim trailing blank lines
183
- while chunk.any? && chunk.last.strip == ""
184
- chunk.pop
185
- end
186
- out << header
187
- out.concat(chunk)
188
- out << "\n" unless out.last&.end_with?("\n")
189
- end
190
- next
191
- else
192
- # Lines outside sections are ignored for released sections
193
- i += 1
194
- end
195
- end
196
- out.join
197
- end
198
-
199
- def coverage_lines
200
- unless File.file?(@coverage_path)
201
- warn("Coverage JSON not found at #{@coverage_path}.")
202
- warn("Run: K_SOUP_COV_FORMATTERS=\"json\" bin/rspec")
203
- return [nil, nil]
204
- end
205
- data = JSON.parse(File.read(@coverage_path))
206
- files = data["coverage"] || {}
207
- file_count = 0
208
- total_lines = 0
209
- covered_lines = 0
210
- total_branches = 0
211
- covered_branches = 0
212
- files.each_value do |h|
213
- lines = h["lines"] || []
214
- line_relevant = lines.count { |x| x.is_a?(Integer) }
215
- line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
216
- if line_relevant > 0
217
- file_count += 1
218
- total_lines += line_relevant
219
- covered_lines += line_covered
220
- end
221
- branches = h["branches"] || []
222
- branches.each do |b|
223
- next unless b.is_a?(Hash)
224
- cov = b["coverage"]
225
- next unless cov.is_a?(Numeric)
226
- total_branches += 1
227
- covered_branches += 1 if cov > 0
228
- end
229
- end
230
- line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
231
- branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
232
- line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
233
- branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
234
- [line_str, branch_str]
235
- rescue StandardError => e
236
- warn("Failed to parse coverage: #{e.class}: #{e.message}")
237
- [nil, nil]
238
- end
239
-
240
- def yard_percent_documented
241
- cmd = File.join(@root, "bin", "yard")
242
- unless File.executable?(cmd)
243
- warn("bin/yard not found or not executable; ensure yard is installed via bundler")
244
- return
245
- end
246
- out, _ = Open3.capture2(cmd)
247
- # Look for a line containing e.g., "95.35% documented"
248
- line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
249
- if line
250
- line = line.strip
251
- # Return exactly as requested: e.g. "95.35% documented"
252
- line
253
- else
254
- warn("Could not find documented percentage in bin/yard output.")
255
- nil
256
- end
257
- rescue StandardError => e
258
- warn("Failed to run bin/yard: #{e.class}: #{e.message}")
259
- nil
260
- end
261
-
262
- def update_link_refs(content, owner, repo, prev_version, new_version)
263
- # Convert any GitLab links to GitHub
264
- content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^\.]+)\.\.\.([^\s]+)}) do
265
- o = owner || Regexp.last_match(1)
266
- r = repo || Regexp.last_match(2)
267
- from = Regexp.last_match(3)
268
- to = Regexp.last_match(4)
269
- "https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
270
- end
271
- content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
272
- o = owner || Regexp.last_match(1)
273
- r = repo || Regexp.last_match(2)
274
- tag = Regexp.last_match(3)
275
- "https://github.com/#{o}/#{r}/releases/tag/#{tag}"
276
- end
277
-
278
- # Append or update the bottom reference links
279
- lines = content.lines
280
-
281
- # Find the index of the Unreleased heading; only manipulate refs after this point
282
- unreleased_idx = lines.index { |l| l.start_with?("## [Unreleased]") } || -1
283
-
284
- # Find the first link-ref line (e.g., "[Unreleased]: http...") AFTER Unreleased
285
- first_ref = nil
286
- lines.each_with_index do |l, i|
287
- if l =~ /^\[[^\]]+\]:\s+http/ && i > unreleased_idx
288
- first_ref = i
289
- break
290
- end
291
- end
292
- unless first_ref
293
- # Append at end if no ref block after Unreleased
294
- first_ref = lines.length
295
- lines << "\n"
296
- end
297
-
298
- # Ensure Unreleased points to GitHub compare from new tag to HEAD
299
- if owner && repo
300
- unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
301
- # Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
302
- idx = nil
303
- lines.each_with_index do |l, i|
304
- if l.start_with?("[Unreleased]:") && i >= first_ref
305
- idx = i
306
- break
307
- end
308
- end
309
- if idx
310
- lines[idx] = unreleased_ref
311
- else
312
- lines << unreleased_ref
313
- end
314
- end
315
-
316
- if owner && repo
317
- # Add compare link for the new version
318
- from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
319
- new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
320
- unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
321
- lines << new_compare
322
- end
323
- # Add tag link for the new version
324
- new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
325
- unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
326
- lines << new_tag
327
- end
328
- end
329
-
330
- # Rebuild and sort the reference block so newest is at the bottom, preserving everything above first_ref
331
- ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
332
- # Deduplicate by key (text inside the square brackets)
333
- by_key = {}
334
- ref_lines.each do |l|
335
- if l =~ /^\[([^\]]+)\]:\s+/
336
- by_key[$1] = l
337
- end
338
- end
339
- unreleased_line = by_key.delete("Unreleased")
340
- # Separate version compare and tag links
341
- compares = {}
342
- tags = {}
343
- by_key.each do |k, v|
344
- if k =~ /^(\d+\.\d+\.\d+)$/
345
- compares[$1] = v
346
- elsif k =~ /^(\d+\.\d+\.\d+)t$/
347
- tags[$1] = v
348
- end
349
- end
350
- # Sort versions ascending so newest at bottom
351
- sorted_versions = compares.keys.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
352
- # In case some versions only have tags or only compares, include them as well
353
- (tags.keys - compares.keys).each { |s| sorted_versions |= [s] }
354
- sorted_versions = sorted_versions.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
355
-
356
- new_ref_block = []
357
- new_ref_block << unreleased_line if unreleased_line
358
- sorted_versions.each do |v|
359
- new_ref_block << compares[v] if compares[v]
360
- new_ref_block << tags[v] if tags[v]
361
- end
362
- # Replace the old block
363
- rebuilt = lines[0...first_ref] + new_ref_block + ["\n"]
364
- rebuilt.join
365
- end
366
-
367
- def detect_initial_compare_base(lines)
368
- # Fallback when prev_version is unknown: try to find the first compare base used historically
369
- # e.g., for 1.0.0 it may be a commit SHA instead of a tag
370
- ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
371
- if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
372
- m[1]
373
- else
374
- # Default to previous tag name if none found (unlikely to be correct, but better than empty)
375
- "HEAD^"
376
- end
377
- end
378
- end
379
- end
380
- end
381
-
382
45
  begin
383
46
  if ARGV.include?("-h") || ARGV.include?("--help")
384
47
  puts <<~USAGE
@@ -394,11 +57,20 @@ begin
394
57
  USAGE
395
58
  exit(0)
396
59
  end
60
+ end
61
+
62
+ begin
397
63
  Kettle::Dev::ChangelogCLI.new.run
398
- rescue SystemExit
64
+ rescue LoadError => e
65
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
66
+ warn(e.backtrace.join("\n")) if ENV["DEBUG"]
67
+ exit(1)
68
+ rescue SystemExit => e
69
+ # Preserve exit status, but ensure at least a newline so shells don't show an empty line only.
70
+ warn("#{script_basename}: exited (status=#{e.status}, msg=#{e.message})") if e.status != 0
399
71
  raise
400
72
  rescue StandardError => e
401
- warn("kettle-changelog: unexpected error: #{e.class}: #{e.message}")
402
- warn(e.backtrace.join("\n")) if ENV["DEBUG"]
73
+ warn("#{script_basename}: unexpected error: #{e.class}: #{e.message}")
74
+ warn(e.backtrace.join("\n"))
403
75
  exit(1)
404
76
  end
@@ -8,12 +8,19 @@ $stdout.sync = true
8
8
  # Depending library or project must be using bundler
9
9
  require "bundler/setup"
10
10
 
11
- # Standard library
12
- require "erb"
11
+ script_basename = File.basename(__FILE__)
13
12
 
14
- require "kettle/dev"
13
+ begin
14
+ # Standard library
15
+ require "erb"
15
16
 
16
- puts "== kettle-commit-msg v#{Kettle::Dev::Version::VERSION} =="
17
+ require "kettle/dev"
18
+ puts "== #{script_basename} v#{Kettle::Dev::Version::VERSION} =="
19
+ rescue LoadError => e
20
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
21
+ warn("Hint: Ensure the host project has kettle-dev as a dependency and run bundle install.")
22
+ exit(1)
23
+ end
17
24
 
18
25
  # ENV variable control (set in .envrc, or .env.local)
19
26
  # BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
@@ -52,4 +59,18 @@ else
52
59
  # puts "No branch rule configured (set GIT_HOOK_BRANCH_VALIDATE=jira to enforce rules for jira style branch names)"
53
60
  end
54
61
 
55
- Kettle::Dev::GitCommitFooter.render(*ARGV)
62
+ begin
63
+ Kettle::Dev::GitCommitFooter.render(*ARGV)
64
+ rescue LoadError => e
65
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
66
+ warn(e.backtrace.join("\n")) if ENV["DEBUG"]
67
+ exit(1)
68
+ rescue SystemExit => e
69
+ # Preserve exit status, but ensure at least a newline so shells don't show an empty line only.
70
+ warn("#{script_basename}: exited (status=#{e.status}, msg=#{e.message})") if e.status != 0
71
+ raise
72
+ rescue StandardError => e
73
+ warn("#{script_basename}: unexpected error: #{e.class}: #{e.message}")
74
+ warn(e.backtrace.join("\n"))
75
+ exit(1)
76
+ end
@@ -5,11 +5,32 @@
5
5
 
6
6
  # Immediate, unbuffered output
7
7
  $stdout.sync = true
8
- # Ensure bundler is set up by the host project
8
+ # Depending library or project must be using bundler
9
9
  require "bundler/setup"
10
10
 
11
- require "kettle/dev"
11
+ script_basename = File.basename(__FILE__)
12
12
 
13
- puts "== kettle-readme-backers v#{Kettle::Dev::Version::VERSION} =="
13
+ begin
14
+ require "kettle/dev"
15
+ puts "== #{script_basename} v#{Kettle::Dev::Version::VERSION} =="
16
+ rescue LoadError => e
17
+ warn("kettle/dev: failed to load: #{e.message}")
18
+ warn("Hint: Ensure the host project has kettle-dev as a dependency and run bundle install.")
19
+ exit(1)
20
+ end
14
21
 
15
- Kettle::Dev::ReadmeBackers.new.run!
22
+ begin
23
+ Kettle::Dev::ReadmeBackers.new.run!
24
+ rescue LoadError => e
25
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
26
+ warn(e.backtrace.join("\n")) if ENV["DEBUG"]
27
+ exit(1)
28
+ rescue SystemExit => e
29
+ # Preserve exit status, but ensure at least a newline so shells don't show an empty line only.
30
+ warn("#{script_basename}: exited (status=#{e.status}, msg=#{e.message})") if e.status != 0
31
+ raise
32
+ rescue StandardError => e
33
+ warn("#{script_basename}: unexpected error: #{e.class}: #{e.message}")
34
+ warn(e.backtrace.join("\n"))
35
+ exit(1)
36
+ end
data/exe/kettle-release CHANGED
@@ -19,7 +19,16 @@ $stdout.sync = true
19
19
  # Depending library or project must be using bundler
20
20
  require "bundler/setup"
21
21
 
22
- require "kettle/dev"
22
+ script_basename = File.basename(__FILE__)
23
+
24
+ begin
25
+ require "kettle/dev"
26
+ puts "== #{script_basename} v#{Kettle::Dev::Version::VERSION} =="
27
+ rescue LoadError => e
28
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
29
+ warn("Hint: Ensure the host project has kettle-dev as a dependency and run bundle install.")
30
+ exit(1)
31
+ end
23
32
 
24
33
  # Always execute when this file is loaded (e.g., via a Bundler binstub).
25
34
  # Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
@@ -48,22 +57,22 @@ if ARGV.include?("-h") || ARGV.include?("--help")
48
57
  exit 0
49
58
  end
50
59
 
51
- puts "== kettle-release v#{Kettle::Dev::Version::VERSION} =="
52
60
  # Parse start_step=<n> from ARGV
53
61
  start_step_arg = ARGV.find { |a| a.start_with?("start_step=") }
54
62
  start_step = start_step_arg ? start_step_arg.split("=", 2)[1].to_i : 1
63
+
55
64
  begin
56
65
  Kettle::Dev::ReleaseCLI.new(start_step: start_step).run
57
66
  rescue LoadError => e
58
- warn("kettle-release: could not load dependency: #{e.message}")
67
+ warn("#{script_basename}: could not load dependency: #{e.class}: #{e.message}")
59
68
  warn(e.backtrace.join("\n")) if ENV["DEBUG"]
60
69
  exit(1)
61
70
  rescue SystemExit => e
62
71
  # Preserve exit status, but ensure at least a newline so shells don't show an empty line only.
63
- warn("kettle-release exited (status=#{e.status})") if e.status != 0
72
+ warn("#{script_basename}: exited (status=#{e.status}, msg=#{e.message})") if e.status != 0
64
73
  raise
65
74
  rescue StandardError => e
66
- warn("kettle-release: unexpected error: #{e.class}: #{e.message}")
75
+ warn("#{script_basename}: unexpected error: #{e.class}: #{e.message}")
67
76
  warn(e.backtrace.join("\n"))
68
77
  exit(1)
69
78
  end
@@ -67,8 +67,8 @@ Gem::Specification.new do |spec|
67
67
  # Signatures
68
68
  "sig/**/*.rbs",
69
69
  ]
70
- # Automatically included with gem package, normally no need to list again in files.
71
- # But this gem acts as a pseudo-template, so we include some in both places.
70
+
71
+ # Automatically included with gem package, no need to list again in files.
72
72
  spec.extra_rdoc_files = Dir[
73
73
  # Files (alphabetical)
74
74
  "CHANGELOG.md",