kettle-dev 1.0.9 → 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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +2 -2
- data/.envrc +4 -3
- data/.github/workflows/coverage.yml +3 -3
- data/.github/workflows/coverage.yml.example +127 -0
- data/.github/workflows/discord-notifier.yml +2 -1
- data/.github/workflows/truffle.yml +0 -8
- data/.junie/guidelines.md +4 -3
- data/.simplecov +5 -1
- data/Appraisals +5 -0
- data/Appraisals.example +102 -0
- data/CHANGELOG.md +80 -25
- data/CHANGELOG.md.example +4 -4
- data/CONTRIBUTING.md +43 -1
- data/Gemfile +3 -0
- data/README.md +65 -14
- data/README.md.example +515 -0
- data/{Rakefile → Rakefile.example} +17 -35
- data/exe/kettle-changelog +401 -0
- data/exe/kettle-commit-msg +11 -143
- data/exe/kettle-readme-backers +8 -352
- data/exe/kettle-release +7 -706
- data/gemfiles/modular/optional.gemfile +5 -0
- data/lib/kettle/dev/ci_helpers.rb +1 -0
- data/lib/kettle/dev/commit_msg.rb +39 -0
- data/lib/kettle/dev/exit_adapter.rb +36 -0
- data/lib/kettle/dev/git_adapter.rb +185 -0
- data/lib/kettle/dev/git_commit_footer.rb +130 -0
- data/lib/kettle/dev/input_adapter.rb +40 -0
- data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
- data/lib/kettle/dev/rakelib/bench.rake +2 -7
- data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
- data/lib/kettle/dev/rakelib/ci.rake +4 -396
- data/lib/kettle/dev/rakelib/install.rake +1 -295
- data/lib/kettle/dev/rakelib/reek.rake +2 -0
- data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
- data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
- data/lib/kettle/dev/rakelib/template.rake +3 -465
- data/lib/kettle/dev/readme_backers.rb +340 -0
- data/lib/kettle/dev/release_cli.rb +674 -0
- data/lib/kettle/dev/tasks/ci_task.rb +337 -0
- data/lib/kettle/dev/tasks/install_task.rb +516 -0
- data/lib/kettle/dev/tasks/template_task.rb +593 -0
- data/lib/kettle/dev/template_helpers.rb +65 -12
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev/versioning.rb +68 -0
- data/lib/kettle/dev.rb +30 -1
- data/lib/kettle-dev.rb +2 -3
- data/sig/kettle/dev/ci_helpers.rbs +8 -17
- data/sig/kettle/dev/commit_msg.rbs +8 -0
- data/sig/kettle/dev/exit_adapter.rbs +8 -0
- data/sig/kettle/dev/git_adapter.rbs +15 -0
- data/sig/kettle/dev/git_commit_footer.rbs +16 -0
- data/sig/kettle/dev/input_adapter.rbs +8 -0
- data/sig/kettle/dev/readme_backers.rbs +20 -0
- data/sig/kettle/dev/release_cli.rbs +8 -0
- data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
- data/sig/kettle/dev/tasks/install_task.rbs +10 -0
- data/sig/kettle/dev/tasks/template_task.rbs +10 -0
- data/sig/kettle/dev/tasks.rbs +0 -0
- data/sig/kettle/dev/template_helpers.rbs +3 -1
- data/sig/kettle/dev/version.rbs +0 -0
- data/sig/kettle/emoji_regex.rbs +5 -0
- data/sig/kettle-dev.rbs +0 -0
- data.tar.gz.sig +0 -0
- metadata +59 -10
- metadata.gz.sig +0 -0
- data/.gitlab-ci.yml +0 -45
@@ -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
|
data/exe/kettle-commit-msg
CHANGED
@@ -1,16 +1,20 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
2
4
|
# vim: set syntax=ruby
|
3
5
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
# Allow running outside of Bundler; runtime deps should still be available via rubygems
|
9
|
-
end
|
6
|
+
# Immediate, unbuffered output
|
7
|
+
$stdout.sync = true
|
8
|
+
# Depending library or project must be using bundler
|
9
|
+
require "bundler/setup"
|
10
10
|
|
11
11
|
# Standard library
|
12
12
|
require "erb"
|
13
13
|
|
14
|
+
require "kettle/dev"
|
15
|
+
|
16
|
+
puts "== kettle-commit-msg v#{Kettle::Dev::Version::VERSION} =="
|
17
|
+
|
14
18
|
# ENV variable control (set in .envrc, or .env.local)
|
15
19
|
# BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
|
16
20
|
# FOOTER_APPEND = true/false append commit message footer
|
@@ -48,140 +52,4 @@ else
|
|
48
52
|
# puts "No branch rule configured (set GIT_HOOK_BRANCH_VALIDATE=jira to enforce rules for jira style branch names)"
|
49
53
|
end
|
50
54
|
|
51
|
-
|
52
|
-
# Prefer project-local .git-hooks (repo root), then fallback to global ~/.git-hooks
|
53
|
-
NAME_ASSIGNMENT_REGEX = /\bname\s*=\s*(["'])([^"']+)\1/.freeze
|
54
|
-
FOOTER_APPEND = ENV.fetch("GIT_HOOK_FOOTER_APPEND", "false").casecmp("true").zero?
|
55
|
-
SENTINEL = ENV["GIT_HOOK_FOOTER_SENTINEL"] # No default to avoid accidental duplicate commit of a footer via ammended commits
|
56
|
-
raise "Set GIT_HOOK_FOOTER_SENTINEL=<footer sentinel> in .env.local (e.g., '⚡️ A message from a fellow meat-based-AI ⚡️')" if FOOTER_APPEND && (SENTINEL.nil? || SENTINEL.to_s.empty?)
|
57
|
-
|
58
|
-
class << self
|
59
|
-
def git_toplevel
|
60
|
-
toplevel = nil
|
61
|
-
begin
|
62
|
-
# 'git rev-parse --show-toplevel' returns the repo root when run anywhere inside the repo
|
63
|
-
out = %x(git rev-parse --show-toplevel 2>/dev/null)
|
64
|
-
toplevel = out.strip unless out.nil? || out.empty?
|
65
|
-
rescue StandardError
|
66
|
-
# ignore
|
67
|
-
end
|
68
|
-
toplevel
|
69
|
-
end
|
70
|
-
|
71
|
-
def local_hooks_dir
|
72
|
-
top = git_toplevel
|
73
|
-
return unless top && !top.empty?
|
74
|
-
File.join(top, ".git-hooks")
|
75
|
-
end
|
76
|
-
|
77
|
-
def global_hooks_dir
|
78
|
-
File.join(ENV["HOME"], ".git-hooks")
|
79
|
-
end
|
80
|
-
|
81
|
-
def hooks_path_for(filename)
|
82
|
-
local_dir = local_hooks_dir
|
83
|
-
if local_dir
|
84
|
-
local_path = File.join(local_dir, filename)
|
85
|
-
return local_path if File.file?(local_path)
|
86
|
-
end
|
87
|
-
File.join(global_hooks_dir, filename)
|
88
|
-
end
|
89
|
-
|
90
|
-
def commit_goalie_path
|
91
|
-
hooks_path_for("commit-subjects-goalie.txt")
|
92
|
-
end
|
93
|
-
|
94
|
-
# Determine whether the commit subject allows footer append, based on optional goalie file
|
95
|
-
# ~/.git-hooks/commit-subjects-goalie.txt
|
96
|
-
# - If present, only allow appending when the first line of the commit message starts with one of the non-commented prefixes
|
97
|
-
# - If absent, disallow footer
|
98
|
-
def goalie_allows_footer?(subject_line)
|
99
|
-
goalie_path = commit_goalie_path
|
100
|
-
return false unless File.file?(goalie_path)
|
101
|
-
|
102
|
-
prefixes = File.read(goalie_path).lines.map { |l| l.strip }.reject { |l| l.empty? || l.start_with?("#") }
|
103
|
-
# If the file exists but has no usable lines, treat as deny-all per goalie intent
|
104
|
-
return false if prefixes.empty?
|
105
|
-
|
106
|
-
subj = subject_line.to_s.strip
|
107
|
-
prefixes.any? { |prefix| subj.start_with?(prefix) }
|
108
|
-
end
|
109
|
-
|
110
|
-
def render(*argv)
|
111
|
-
commit_msg = File.read(argv[0])
|
112
|
-
subject_line = commit_msg.lines.first.to_s
|
113
|
-
if GitCommitFooter::FOOTER_APPEND && goalie_allows_footer?(subject_line)
|
114
|
-
if commit_msg.include?(GitCommitFooter::SENTINEL)
|
115
|
-
# This is a commit message that has already been appended
|
116
|
-
# This will happen if the commit message is edited and re-committed
|
117
|
-
# puts "FOOTER_APPEND is true, skipping footer append"
|
118
|
-
exit(0)
|
119
|
-
else
|
120
|
-
footer_binding = GitCommitFooter.new
|
121
|
-
# Append footer to the commit message
|
122
|
-
File.open(argv[0], "w") do |file|
|
123
|
-
file.print(commit_msg)
|
124
|
-
file.print("\n")
|
125
|
-
file.print(footer_binding.render)
|
126
|
-
end
|
127
|
-
end
|
128
|
-
else
|
129
|
-
# Skipping footer append (either FOOTER_APPEND is false, or goalie did not allow it)
|
130
|
-
end
|
131
|
-
end
|
132
|
-
end
|
133
|
-
|
134
|
-
def initialize
|
135
|
-
@pwd = Dir.pwd
|
136
|
-
@gemspecs = Dir["*.gemspec"]
|
137
|
-
@spec = @gemspecs.first
|
138
|
-
@gemspec_path = File.expand_path(@spec, @pwd)
|
139
|
-
@gem_name = parse_gemspec_name || derive_gem_name
|
140
|
-
end
|
141
|
-
|
142
|
-
# Render ERB with binding variables
|
143
|
-
def render
|
144
|
-
ERB.new(template).result(binding)
|
145
|
-
end
|
146
|
-
|
147
|
-
private
|
148
|
-
|
149
|
-
# Lightweight parse for gem name to avoid full Gem::Specification load
|
150
|
-
def parse_gemspec_name
|
151
|
-
begin
|
152
|
-
content = File.read(@gemspec_path)
|
153
|
-
# Look for name assignment patterns like:
|
154
|
-
# spec.name = "my_gem" OR Gem::Specification.new do |spec|; spec.name = 'my_gem'
|
155
|
-
@name_index = content =~ NAME_ASSIGNMENT_REGEX
|
156
|
-
if @name_index
|
157
|
-
return $2
|
158
|
-
end
|
159
|
-
rescue StandardError
|
160
|
-
# fall through
|
161
|
-
end
|
162
|
-
nil
|
163
|
-
end
|
164
|
-
|
165
|
-
# No-parse derivation of gem name, when parsing gemspec fails
|
166
|
-
def derive_gem_name
|
167
|
-
File.basename(@gemspec_path, ".*") if @gemspec_path
|
168
|
-
end
|
169
|
-
|
170
|
-
# Example
|
171
|
-
#
|
172
|
-
# ⚡️ A message from a fellow meat-based-AI ⚡️
|
173
|
-
# I ❤️ working on <%= @gem_name %>.
|
174
|
-
#
|
175
|
-
# The first line is the footer sentinel (which does appear in the commit).
|
176
|
-
# The second line, and any additional, is the main body of the footer.
|
177
|
-
#
|
178
|
-
# The sentinel must be set in an ENV variable (e.g., in your .env.local file):
|
179
|
-
#
|
180
|
-
# export GIT_HOOK_FOOTER_SENTINEL="⚡️ A message from a fellow meat-based-AI ⚡️"
|
181
|
-
#
|
182
|
-
def template
|
183
|
-
File.read(self.class.hooks_path_for("footer-template.erb.txt"))
|
184
|
-
end
|
185
|
-
end
|
186
|
-
|
187
|
-
GitCommitFooter.render(*ARGV)
|
55
|
+
Kettle::Dev::GitCommitFooter.render(*ARGV)
|