kettle-dev 1.0.1 → 1.0.3

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.devcontainer/devcontainer.json +26 -0
  4. data/.envrc +42 -0
  5. data/.git-hooks/commit-msg +41 -0
  6. data/.git-hooks/commit-subjects-goalie.txt +8 -0
  7. data/.git-hooks/footer-template.erb.txt +16 -0
  8. data/.git-hooks/prepare-commit-msg +20 -0
  9. data/.github/FUNDING.yml +13 -0
  10. data/.github/dependabot.yml +11 -0
  11. data/.github/workflows/ancient.yml +80 -0
  12. data/.github/workflows/auto-assign.yml +21 -0
  13. data/.github/workflows/codeql-analysis.yml +70 -0
  14. data/.github/workflows/coverage.yml +130 -0
  15. data/.github/workflows/current.yml +88 -0
  16. data/.github/workflows/dependency-review.yml +20 -0
  17. data/.github/workflows/discord-notifier.yml +38 -0
  18. data/.github/workflows/heads.yml +87 -0
  19. data/.github/workflows/jruby.yml +79 -0
  20. data/.github/workflows/legacy.yml +70 -0
  21. data/.github/workflows/locked_deps.yml +88 -0
  22. data/.github/workflows/opencollective.yml +40 -0
  23. data/.github/workflows/style.yml +67 -0
  24. data/.github/workflows/supported.yml +85 -0
  25. data/.github/workflows/truffle.yml +78 -0
  26. data/.github/workflows/unlocked_deps.yml +87 -0
  27. data/.github/workflows/unsupported.yml +78 -0
  28. data/.gitignore +48 -0
  29. data/.gitlab-ci.yml +45 -0
  30. data/.junie/guidelines-rbs.md +49 -0
  31. data/.junie/guidelines.md +132 -0
  32. data/.opencollective.yml +3 -0
  33. data/.qlty/qlty.toml +79 -0
  34. data/.rspec +8 -0
  35. data/.rubocop.yml +13 -0
  36. data/.simplecov +7 -0
  37. data/.tool-versions +1 -0
  38. data/.yard_gfm_support.rb +22 -0
  39. data/.yardopts +11 -0
  40. data/Appraisal.root.gemfile +12 -0
  41. data/Appraisals +120 -0
  42. data/CHANGELOG.md +26 -1
  43. data/Gemfile +32 -0
  44. data/Rakefile +99 -0
  45. data/checksums/kettle-dev-1.0.2.gem.sha256 +1 -0
  46. data/checksums/kettle-dev-1.0.2.gem.sha512 +1 -0
  47. data/checksums/kettle-dev-1.0.3.gem.sha256 +1 -0
  48. data/checksums/kettle-dev-1.0.3.gem.sha512 +1 -0
  49. data/exe/kettle-release +2 -3
  50. data/gemfiles/modular/coverage.gemfile +6 -0
  51. data/gemfiles/modular/documentation.gemfile +11 -0
  52. data/gemfiles/modular/style.gemfile +16 -0
  53. data/lib/kettle/dev/rakelib/appraisal.rake +40 -0
  54. data/lib/kettle/dev/rakelib/bench.rake +58 -0
  55. data/lib/kettle/dev/rakelib/bundle_audit.rake +18 -0
  56. data/lib/kettle/dev/rakelib/ci.rake +348 -0
  57. data/lib/kettle/dev/rakelib/install.rake +304 -0
  58. data/lib/kettle/dev/rakelib/reek.rake +34 -0
  59. data/lib/kettle/dev/rakelib/require_bench.rake +7 -0
  60. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +9 -0
  61. data/lib/kettle/dev/rakelib/spec_test.rake +42 -0
  62. data/lib/kettle/dev/rakelib/template.rake +461 -0
  63. data/lib/kettle/dev/rakelib/yard.rake +33 -0
  64. data/lib/kettle/dev/version.rb +1 -1
  65. data.tar.gz.sig +0 -0
  66. metadata +67 -4
  67. metadata.gz.sig +0 -0
@@ -0,0 +1,348 @@
1
+ # --- CI helpers ---
2
+ namespace :ci do
3
+ # rubocop:disable ThreadSafety/NewThread
4
+ desc "Run 'act' with a selected workflow. Usage: rake ci:act[loc] (short code = first 3 letters of filename, e.g., 'loc' => locked_deps.yml), rake ci:act[locked_deps], rake ci:act[locked_deps.yml], or rake ci:act (then choose)"
5
+ task :act, [:opt] do |_t, args|
6
+ require "io/console"
7
+ require "open3"
8
+ require "net/http"
9
+ require "json"
10
+ require "uri"
11
+ require "kettle/dev/ci_helpers"
12
+
13
+ # Build mapping dynamically from workflow files; short code = first three letters of filename.
14
+ # Collisions are resolved by first-come wins via ||= as requested.
15
+ mapping = {}
16
+
17
+ # Normalize provided option. Accept either short code or the exact yml/yaml filename
18
+ choice = args[:opt]&.strip
19
+ root_dir = Kettle::Dev::CIHelpers.project_root
20
+ workflows_dir = File.join(root_dir, ".github", "workflows")
21
+
22
+ # Determine actual workflow files present, and prepare dynamic additions excluding specified files.
23
+ existing_files = if Dir.exist?(workflows_dir)
24
+ Dir[File.join(workflows_dir, "*.yml")] + Dir[File.join(workflows_dir, "*.yaml")]
25
+ else
26
+ []
27
+ end
28
+ existing_basenames = existing_files.map { |p| File.basename(p) }
29
+
30
+ # Build short-code mapping (first 3 chars of filename stem), excluding some maintenance workflows.
31
+ exclusions = Kettle::Dev::CIHelpers.exclusions
32
+ candidate_files = existing_basenames.uniq - exclusions
33
+ candidate_files.sort.each do |fname|
34
+ stem = fname.sub(/\.(ya?ml)\z/, "")
35
+ code = stem[0, 3].to_s.downcase
36
+ next if code.empty?
37
+ mapping[code] ||= fname # first-come wins on collisions
38
+ end
39
+
40
+ # Any remaining candidates that didn't get a unique shortcode are treated as dynamic (number-only) options
41
+ dynamic_files = candidate_files - mapping.values
42
+
43
+ # For internal status tracking and rendering, we use a display_code_for hash.
44
+ # For mapped (short-code) entries, display_code is the short code.
45
+ # For dynamic entries, display_code is empty string, but we key statuses by a unique code = the filename.
46
+ display_code_for = {}
47
+ mapping.keys.each { |k| display_code_for[k] = k }
48
+ dynamic_files.each { |f| display_code_for[f] = "" }
49
+
50
+ # Helpers
51
+ get_branch = proc do
52
+ out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
53
+ status.success? ? out.strip : nil
54
+ end
55
+
56
+ get_origin = proc do
57
+ out, status = Open3.capture2("git", "config", "--get", "remote.origin.url")
58
+ next nil unless status.success?
59
+ url = out.strip
60
+ # Support ssh and https URLs
61
+ if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
62
+ [$1, $2.sub(/\.git\z/, "")]
63
+ elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
64
+ [$1, $2.sub(/\.git\z/, "")]
65
+ end
66
+ end
67
+
68
+ status_emoji = proc do |status, conclusion|
69
+ case status
70
+ when "queued"
71
+ "⏳️"
72
+ when "in_progress"
73
+ "👟"
74
+ when "completed"
75
+ (conclusion == "success") ? "✅" : "🍅"
76
+ else
77
+ "⏳️"
78
+ end
79
+ end
80
+
81
+ fetch_and_print_status = proc do |workflow_file|
82
+ branch = get_branch.call
83
+ org_repo = get_origin.call
84
+ unless branch && org_repo
85
+ puts "GHA status: (skipped; missing git branch or remote)"
86
+ next
87
+ end
88
+ owner, repo = org_repo
89
+ uri = URI("https://api.github.com/repos/#{owner}/#{repo}/actions/workflows/#{workflow_file}/runs?branch=#{URI.encode_www_form_component(branch)}&per_page=1")
90
+ req = Net::HTTP::Get.new(uri)
91
+ req["User-Agent"] = "ci:act rake task"
92
+ token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
93
+ req["Authorization"] = "token #{token}" if token && !token.empty?
94
+
95
+ begin
96
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
97
+ if res.is_a?(Net::HTTPSuccess)
98
+ data = JSON.parse(res.body)
99
+ run = data["workflow_runs"]&.first
100
+ if run
101
+ status = run["status"]
102
+ conclusion = run["conclusion"]
103
+ emoji = status_emoji.call(status, conclusion)
104
+ details = [status, conclusion].compact.join("/")
105
+ puts "Latest GHA (#{branch}) for #{workflow_file}: #{emoji} (#{details})"
106
+ else
107
+ puts "Latest GHA (#{branch}) for #{workflow_file}: none"
108
+ end
109
+ else
110
+ puts "GHA status: request failed (#{res.code})"
111
+ end
112
+ rescue StandardError => e
113
+ puts "GHA status: error #{e.class}: #{e.message}"
114
+ end
115
+ end
116
+
117
+ def run_act_for(file_path)
118
+ # Prefer array form to avoid shell escaping issues
119
+ ok = system("act", "-W", file_path)
120
+ abort("ci:act failed: 'act' command not found or exited with failure") unless ok
121
+ end
122
+
123
+ def process_success_response(res, c, f, old = nil, current = nil)
124
+ data = JSON.parse(res.body)
125
+ run = data["workflow_runs"]&.first
126
+ append = (old && current) ? " (update git remote: #{old} → #{current})" : ""
127
+ if run
128
+ st = run["status"]
129
+ con = run["conclusion"]
130
+ emoji = case st
131
+ when "queued" then "⏳️"
132
+ when "in_progress" then "👟"
133
+ when "completed" then ((con == "success") ? "✅" : "🍅")
134
+ else "⏳️"
135
+ end
136
+ details = [st, con].compact.join("/")
137
+ [c, f, "#{emoji} (#{details})#{append}"]
138
+ else
139
+ [c, f, "none#{append}"]
140
+ end
141
+ end
142
+
143
+ if choice && !choice.empty?
144
+ # If user passed a filename directly (with or without extension), resolve it
145
+ file = if mapping.key?(choice)
146
+ mapping.fetch(choice)
147
+ elsif !!(/\.(yml|yaml)\z/ =~ choice)
148
+ # Accept either full basename (without ext) or basename with .yml/.yaml
149
+ choice
150
+ else
151
+ cand_yml = File.join(workflows_dir, "#{choice}.yml")
152
+ cand_yaml = File.join(workflows_dir, "#{choice}.yaml")
153
+ if File.file?(cand_yml)
154
+ "#{choice}.yml"
155
+ elsif File.file?(cand_yaml)
156
+ "#{choice}.yaml"
157
+ else
158
+ # Fall back to .yml for error messaging; will fail below
159
+ "#{choice}.yml"
160
+ end
161
+ end
162
+ file_path = File.join(workflows_dir, file)
163
+ unless File.file?(file_path)
164
+ puts "Unknown option or missing workflow file: #{choice} -> #{file}"
165
+ puts "Available options:"
166
+ mapping.each { |k, v| puts " #{k.ljust(3)} => #{v}" }
167
+ # Also display dynamically discovered files
168
+ unless dynamic_files.empty?
169
+ puts " (others) =>"
170
+ dynamic_files.each { |v| puts " #{v}" }
171
+ end
172
+ abort("ci:act aborted")
173
+ end
174
+ fetch_and_print_status.call(file)
175
+ run_act_for(file_path)
176
+ next
177
+ end
178
+
179
+ # No option provided: interactive menu with live GHA statuses via Threads (no Ractors)
180
+ require "thread"
181
+
182
+ tty = $stdout.tty?
183
+ # Build options: first the filtered short-code mapping, then dynamic files (no short codes)
184
+ options = mapping.to_a + dynamic_files.map { |f| [f, f] }
185
+
186
+ # Add a Quit choice
187
+ quit_code = "q"
188
+ options_with_quit = options + [[quit_code, "(quit)"]]
189
+
190
+ idx_by_code = {}
191
+ options_with_quit.each_with_index { |(k, _v), i| idx_by_code[k] = i }
192
+
193
+ # Determine repo context once
194
+ branch = get_branch.call
195
+ org = get_origin.call
196
+ owner, repo = org if org
197
+ token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
198
+
199
+ puts "Select a workflow to run with 'act':"
200
+
201
+ # Render initial menu with placeholder statuses
202
+ placeholder = "[…]"
203
+ options_with_quit.each_with_index do |(k, v), idx|
204
+ status_col = (k == quit_code) ? "" : placeholder
205
+ disp = (k == quit_code) ? k : display_code_for[k]
206
+ line = format("%2d) %-3s => %-20s %s", idx + 1, disp, v, status_col)
207
+ puts line
208
+ end
209
+
210
+ puts "(Fetching latest GHA status for branch #{branch || "n/a"} — you can type your choice and press Enter)"
211
+ prompt = "Enter number or code (or 'q' to quit): "
212
+ print prompt
213
+ $stdout.flush
214
+
215
+ # Thread + Queue to read user input
216
+ input_q = Queue.new
217
+ input_thread = Thread.new do
218
+ line = $stdin.gets&.strip
219
+ input_q << line
220
+ end
221
+
222
+ # Worker threads to fetch statuses and stream updates as they complete
223
+ status_q = Queue.new
224
+ workers = []
225
+
226
+ # Capture a monotonic start time to guard against early race with terminal rendering
227
+ start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
228
+
229
+ options.each do |code, file|
230
+ workers << Thread.new(code, file, owner, repo, branch, token, start_at) do |c, f, ow, rp, br, tk, st_at|
231
+ begin
232
+ # small initial delay if threads finish too quickly, to let the menu/prompt finish rendering
233
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
234
+ delay = 0.12 - (now - st_at)
235
+ sleep(delay) if delay && delay > 0
236
+
237
+ if ow.nil? || rp.nil? || br.nil?
238
+ status_q << [c, f, "n/a"]
239
+ Thread.exit
240
+ end
241
+ uri = URI("https://api.github.com/repos/#{ow}/#{rp}/actions/workflows/#{f}/runs?branch=#{URI.encode_www_form_component(br)}&per_page=1")
242
+ req = Net::HTTP::Get.new(uri)
243
+ req["User-Agent"] = "ci:act rake task"
244
+ req["Authorization"] = "token #{tk}" if tk && !tk.empty?
245
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
246
+ status_q <<
247
+ if res.is_a?(Net::HTTPSuccess)
248
+ process_success_response(res, c, f)
249
+ else
250
+ [c, f, "fail #{res.code}"]
251
+ end
252
+ rescue StandardError
253
+ status_q << [c, f, "err"]
254
+ end
255
+ end
256
+ end
257
+
258
+ # Live update loop: either statuses arrive or the user submits input
259
+ statuses = Hash.new(placeholder)
260
+ selected = nil
261
+
262
+ loop do
263
+ # Check for user input first (non-blocking)
264
+ unless input_q.empty?
265
+ selected = begin
266
+ input_q.pop(true)
267
+ rescue
268
+ nil
269
+ end
270
+ break if selected
271
+ end
272
+
273
+ # Drain any available status updates without blocking
274
+ begin
275
+ code, file_name, display = status_q.pop(true)
276
+ statuses[code] = display
277
+
278
+ if tty
279
+ idx = idx_by_code[code]
280
+ if idx.nil?
281
+ puts "status #{code}: #{display}"
282
+ print(prompt)
283
+ else
284
+ move_up = options_with_quit.size - idx + 1 # 1 for instruction line + remaining options above last
285
+ $stdout.print("\e[#{move_up}A\r\e[2K")
286
+ disp = (code == quit_code) ? code : display_code_for[code]
287
+ $stdout.print(format("%2d) %-3s => %-20s %s\n", idx + 1, disp, file_name, display))
288
+ $stdout.print("\e[#{move_up - 1}B\r")
289
+ $stdout.print(prompt)
290
+ end
291
+ $stdout.flush
292
+ else
293
+ puts "status #{code}: #{display}"
294
+ end
295
+ rescue ThreadError
296
+ # Queue empty: brief sleep to avoid busy wait
297
+ sleep(0.05)
298
+ end
299
+ end
300
+
301
+ # Cleanup: kill any still-running threads
302
+ begin
303
+ workers.each { |t| t.kill if t&.alive? }
304
+ rescue StandardError
305
+ # ignore
306
+ end
307
+ begin
308
+ input_thread.kill if input_thread&.alive?
309
+ rescue StandardError
310
+ # ignore
311
+ end
312
+
313
+ input = selected
314
+ abort("ci:act aborted: no selection") if input.nil? || input.empty?
315
+
316
+ # Normalize selection
317
+ chosen_file = nil
318
+ if !!(/^\d+$/ =~ input)
319
+ idx = input.to_i - 1
320
+ if idx < 0 || idx >= options_with_quit.length
321
+ abort("ci:act aborted: invalid selection #{input}")
322
+ end
323
+ code, val = options_with_quit[idx]
324
+ if code == quit_code
325
+ puts "ci:act: quit"
326
+ next
327
+ else
328
+ chosen_file = val
329
+ end
330
+ else
331
+ code = input
332
+ if ["q", "quit", "exit"].include?(code.downcase)
333
+ puts "ci:act: quit"
334
+ next
335
+ end
336
+ chosen_file = mapping[code]
337
+ abort("ci:act aborted: unknown code '#{code}'") unless chosen_file
338
+ end
339
+
340
+ file_path = File.join(workflows_dir, chosen_file)
341
+ abort("ci:act aborted: workflow not found: #{file_path}") unless File.file?(file_path)
342
+
343
+ # Print status for the chosen workflow (for consistency)
344
+ fetch_and_print_status.call(chosen_file)
345
+ run_act_for(file_path)
346
+ end
347
+ # rubocop:enable ThreadSafety/NewThread
348
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Install helpers for kettle-dev
4
+ # Creates a .github directory in the invoking project, copying the templates
5
+ # from this library's repository checkout (when available), and sets executable
6
+ # bits on script files. Also prints post-install instructions.
7
+ namespace :kettle do
8
+ namespace :dev do
9
+ desc "Install kettle-dev GitHub automation and setup hints into the current project"
10
+ task :install do
11
+ require "fileutils"
12
+ require "kettle/dev/template_helpers"
13
+
14
+ helpers = Kettle::Dev::TemplateHelpers
15
+
16
+ # Determine invoking project root (where rake was started)
17
+ project_root = helpers.project_root
18
+
19
+ # Run file templating via dedicated task
20
+ Rake::Task["kettle:dev:template"].invoke
21
+ # template task does the clean git check first thing.
22
+ # If template is moved do it does not run here, then the clean git check must be run here instead.
23
+ # Ensure git working tree is clean before making changes
24
+ # helpers.ensure_clean_git!(root: project_root, task_label: "kettle:dev:install")
25
+
26
+ # .tool-versions cleanup offers
27
+ tool_versions_path = File.join(project_root, ".tool-versions")
28
+ if File.file?(tool_versions_path)
29
+ rv = File.join(project_root, ".ruby-version")
30
+ rg = File.join(project_root, ".ruby-gemset")
31
+ to_remove = [rv, rg].select { |p| File.exist?(p) }
32
+ unless to_remove.empty?
33
+ if helpers.ask("Remove #{to_remove.map { |p| File.basename(p) }.join(" and ")} (managed by .tool-versions)?", true)
34
+ to_remove.each { |p| FileUtils.rm_f(p) }
35
+ puts "Removed #{to_remove.map { |p| File.basename(p) }.join(" and ")}"
36
+ end
37
+ end
38
+ end
39
+
40
+ puts
41
+ puts "Next steps:"
42
+ puts "1) Configure a shared git hooks path (optional, recommended):"
43
+ puts " git config --global core.hooksPath .git-hooks"
44
+ puts
45
+ puts "2) Install binstubs for this gem so the commit-msg tool is available in ./bin:"
46
+ puts " bundle binstubs kettle-dev --path bin"
47
+ puts " # After running, you should have bin/kettle-commit-msg (wrapper)."
48
+ puts
49
+ # Step 3: direnv and .envrc
50
+ envrc_path = File.join(project_root, ".envrc")
51
+ puts "3) Install direnv (if not already):"
52
+ puts " brew install direnv"
53
+ if helpers.modified_by_template?(envrc_path)
54
+ puts " Your .envrc was created/updated by kettle:dev:template."
55
+ puts " It includes PATH_add bin so that executables in ./bin are on PATH when direnv is active."
56
+ puts " This allows running tools without the bin/ prefix inside the project directory."
57
+ else
58
+ # Ensure PATH_add bin exists in .envrc if the template task did not modify it this run
59
+ begin
60
+ current = File.file?(envrc_path) ? File.read(envrc_path) : ""
61
+ rescue StandardError
62
+ current = ""
63
+ end
64
+ has_path_add = current.lines.any? { |l| l.strip =~ /^PATH_add\s+bin\b/ }
65
+ if has_path_add
66
+ puts " Your .envrc already contains PATH_add bin."
67
+ else
68
+ puts " Adding PATH_add bin to your project's .envrc is recommended to expose ./bin on PATH."
69
+ if helpers.ask("Add PATH_add bin to #{envrc_path}?", true)
70
+ content = current.dup
71
+ insertion = "# Run any command in this project's bin/ without the bin/ prefix\nPATH_add bin\n"
72
+ if content.empty?
73
+ content = insertion
74
+ else
75
+ content = insertion + "\n" + content unless content.start_with?(insertion)
76
+ end
77
+ File.open(envrc_path, "w") { |f| f.write(content) }
78
+ puts " Updated #{envrc_path} with PATH_add bin"
79
+ updated_envrc_by_install = true
80
+ else
81
+ puts " Skipping modification of .envrc. You may add 'PATH_add bin' manually at the top."
82
+ end
83
+ end
84
+ end
85
+
86
+ # Warn about .env.local and offer to add it to .gitignore
87
+ puts
88
+ puts "WARNING: Do not commit .env.local; it often contains machine-local secrets."
89
+ puts "Ensure your .gitignore includes:"
90
+ puts " # direnv - brew install direnv"
91
+ puts " .env.local"
92
+
93
+ gitignore_path = File.join(project_root, ".gitignore")
94
+ if helpers.modified_by_template?(gitignore_path)
95
+ # .gitignore was created or replaced by template; do not modify it here
96
+ # Assume template provided sensible defaults.
97
+ else
98
+ begin
99
+ gitignore_current = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
100
+ rescue StandardError
101
+ gitignore_current = ""
102
+ end
103
+ has_env_local = gitignore_current.lines.any? { |l| l.strip == ".env.local" }
104
+ unless has_env_local
105
+ puts
106
+ puts "Would you like to add '.env.local' to #{gitignore_path}?"
107
+ print "Add to .gitignore now [Y/n]: "
108
+ answer = $stdin.gets&.strip
109
+ add_it = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
110
+ true
111
+ else
112
+ answer.nil? || answer.empty? || answer =~ /\Ay(es)?\z/i
113
+ end
114
+ if add_it
115
+ FileUtils.mkdir_p(File.dirname(gitignore_path))
116
+ mode = File.exist?(gitignore_path) ? "a" : "w"
117
+ File.open(gitignore_path, mode) do |f|
118
+ f.write("\n") unless gitignore_current.empty? || gitignore_current.end_with?("\n")
119
+ unless gitignore_current.lines.any? { |l| l.strip == "# direnv - brew install direnv" }
120
+ f.write("# direnv - brew install direnv\n")
121
+ end
122
+ f.write(".env.local\n")
123
+ end
124
+ puts "Added .env.local to #{gitignore_path}"
125
+ else
126
+ puts "Skipping modification of .gitignore. Remember to add .env.local to avoid committing it."
127
+ end
128
+ end
129
+ end
130
+
131
+ # Validate gemspec homepage points to GitHub and is a non-interpolated string
132
+ begin
133
+ gemspecs = Dir.glob(File.join(project_root, "*.gemspec"))
134
+ if gemspecs.empty?
135
+ puts
136
+ puts "No .gemspec found in #{project_root}; skipping homepage check."
137
+ else
138
+ gemspec_path = gemspecs.first
139
+ if gemspecs.size > 1
140
+ puts
141
+ puts "Multiple gemspecs found; defaulting to #{File.basename(gemspec_path)} for homepage check."
142
+ end
143
+
144
+ content = File.read(gemspec_path)
145
+ homepage_line = content.lines.find { |l| l =~ /\bspec\.homepage\s*=\s*/ }
146
+ if homepage_line.nil?
147
+ puts
148
+ puts "WARNING: spec.homepage not found in #{File.basename(gemspec_path)}."
149
+ puts "This gem should declare a GitHub homepage: https://github.com/<org>/<repo>"
150
+ else
151
+ # Extract the assigned value as text
152
+ assigned = homepage_line.split("=", 2).last.to_s.strip
153
+ # Detect interpolation
154
+ interpolated = assigned.include?('#{')
155
+
156
+ # If quoted literal, strip quotes
157
+ if assigned.start_with?("\"", "'")
158
+ begin
159
+ assigned[1..-2]
160
+ rescue
161
+ assigned
162
+ end
163
+ else
164
+ assigned
165
+ end
166
+
167
+ github_repo_from_url = lambda do |url|
168
+ return unless url
169
+ url = url.strip
170
+ # Supported formats:
171
+ # - https://github.com/org/repo(.git)?
172
+ # - http(s)://github.com/org/repo
173
+ # - git@github.com:org/repo(.git)?
174
+ # - ssh://git@github.com/org/repo(.git)?
175
+ m = url.match(%r{github\.com[/:]([^/\s:]+)/([^/\s]+?)(?:\.git)?/?\z}i)
176
+ return unless m
177
+ org = m[1]
178
+ repo = m[2]
179
+ [org, repo]
180
+ end
181
+
182
+ github_homepage_literal = lambda do |val|
183
+ return false unless val
184
+ return false if val.include?('#{')
185
+ v = val.to_s.strip
186
+ if (v.start_with?("\"") && v.end_with?("\"")) || (v.start_with?("'") && v.end_with?("'"))
187
+ v = begin
188
+ v[1..-2]
189
+ rescue
190
+ v
191
+ end
192
+ end
193
+ return false unless v =~ %r{\Ahttps?://github\.com/}i
194
+ !!github_repo_from_url.call(v)
195
+ end
196
+
197
+ valid_literal = github_homepage_literal.call(assigned)
198
+
199
+ if interpolated || !valid_literal
200
+ puts
201
+ puts "Checking git remote 'origin' to derive GitHub homepage..."
202
+ origin_url = nil
203
+ begin
204
+ origin_cmd = ["git", "-C", project_root.to_s, "remote", "get-url", "origin"]
205
+ origin_url = IO.popen(origin_cmd, &:read).to_s.strip
206
+ rescue StandardError
207
+ origin_url = ""
208
+ end
209
+
210
+ org_repo = github_repo_from_url.call(origin_url)
211
+ unless org_repo
212
+ puts "ERROR: git remote 'origin' is not a GitHub URL (or not found): #{origin_url.empty? ? "(none)" : origin_url}"
213
+ puts "To complete installation: set your GitHub repository as the 'origin' remote, and move any other forge to an alternate name."
214
+ puts "Example:"
215
+ puts " git remote rename origin something_else"
216
+ puts " git remote add origin https://github.com/<org>/<repo>.git"
217
+ puts "After fixing, re-run: rake kettle:dev:install"
218
+ abort("Aborting: homepage cannot be corrected without a GitHub origin remote.")
219
+ end
220
+
221
+ org, repo = org_repo
222
+ suggested = "https://github.com/#{org}/#{repo}"
223
+
224
+ puts "Current spec.homepage appears #{interpolated ? "interpolated" : "invalid"}: #{assigned}"
225
+ puts "Suggested literal homepage: \"#{suggested}\""
226
+ print("Update #{File.basename(gemspec_path)} to use this homepage? [Y/n]: ")
227
+ ans = $stdin.gets&.strip
228
+ do_update = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
229
+ true
230
+ else
231
+ ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
232
+ end
233
+
234
+ if do_update
235
+ new_line = homepage_line.sub(/=.*/, "= \"#{suggested}\"\n")
236
+ new_content = content.sub(homepage_line, new_line)
237
+ File.open(gemspec_path, "w") { |f| f.write(new_content) }
238
+ puts "Updated spec.homepage in #{File.basename(gemspec_path)} to #{suggested}"
239
+ else
240
+ puts "Skipping update of spec.homepage. You should set it to: #{suggested}"
241
+ end
242
+ end
243
+ end
244
+ end
245
+ rescue StandardError => e
246
+ puts "WARNING: An error occurred while checking gemspec homepage: #{e.class}: #{e.message}"
247
+ end
248
+
249
+ # If .envrc was modified during install step, require `direnv allow` and exit unless allowed
250
+ if defined?(updated_envrc_by_install) && updated_envrc_by_install
251
+ if ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
252
+ puts "Proceeding after .envrc update because allowed=true."
253
+ else
254
+ puts
255
+ puts "IMPORTANT: .envrc was updated during kettle:dev:install."
256
+ puts "Please review it and then run:"
257
+ puts " direnv allow"
258
+ puts
259
+ puts "After that, re-run to resume:"
260
+ puts " bundle exec rake kettle:dev:install allowed=true"
261
+ abort("Aborting: direnv allow required after .envrc changes.")
262
+ end
263
+ end
264
+
265
+ # Summary of templating changes
266
+ begin
267
+ results = helpers.template_results
268
+ meaningful = results.select { |_, rec| [:create, :replace, :dir_create, :dir_replace].include?(rec[:action]) }
269
+ puts
270
+ puts "Summary of templating changes:"
271
+ if meaningful.empty?
272
+ puts " (no files were created or replaced by kettle:dev:template)"
273
+ else
274
+ # Order: create, replace, dir_create, dir_replace
275
+ action_labels = {
276
+ create: "Created",
277
+ replace: "Replaced",
278
+ dir_create: "Directory created",
279
+ dir_replace: "Directory replaced",
280
+ }
281
+ [:create, :replace, :dir_create, :dir_replace].each do |sym|
282
+ items = meaningful.select { |_, rec| rec[:action] == sym }.map { |path, _| path }
283
+ next if items.empty?
284
+ puts " #{action_labels[sym]}:"
285
+ items.sort.each do |abs|
286
+ rel = begin
287
+ abs.start_with?(project_root.to_s) ? abs.sub(/^#{Regexp.escape(project_root.to_s)}\/?/, "") : abs
288
+ rescue
289
+ abs
290
+ end
291
+ puts " - #{rel}"
292
+ end
293
+ end
294
+ end
295
+ rescue StandardError => e
296
+ puts
297
+ puts "Summary of templating changes: (unavailable: #{e.class}: #{e.message})"
298
+ end
299
+
300
+ puts
301
+ puts "kettle:dev:install complete."
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,34 @@
1
+ # Setup Reek
2
+ begin
3
+ require "reek/rake/task"
4
+
5
+ Reek::Rake::Task.new do |t|
6
+ t.fail_on_error = true
7
+ t.verbose = false
8
+ t.source_files = "{lib,spec,tests}/**/*.rb"
9
+ end
10
+
11
+ # Store current Reek output into REEK file
12
+ require "open3"
13
+ desc("Run reek and store the output into the REEK file")
14
+ task("reek:update") do
15
+ # Run via Bundler if available to ensure the right gem version is used
16
+ cmd = [Gem.bindir ? File.join(Gem.bindir, "bundle") : "bundle", "exec", "reek"]
17
+
18
+ output, status = Open3.capture2e(*cmd)
19
+
20
+ File.write("REEK", output)
21
+
22
+ # Mirror the failure semantics of the standard reek task
23
+ unless status.success?
24
+ abort("reek:update failed (reek reported smells). Output written to REEK")
25
+ end
26
+ end
27
+ Kettle::Dev.register_default("reek:update") unless Kettle::Dev::IS_CI
28
+ rescue LoadError
29
+ warn("[kettle-dev][reek.rake] failed to load reek/rake/task") if Kettle::Dev::DEBUGGING
30
+ desc("(stub) reek is unavailable")
31
+ task(:reek) do
32
+ warn("NOTE: reek isn't installed, or is disabled for #{RUBY_VERSION} in the current environment")
33
+ end
34
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "require_bench/tasks" if Kettle::Dev::REQUIRE_BENCH
5
+ rescue LoadError
6
+ warn("[kettle-dev][require_bench.rake] failed to load require_bench/tasks") if Kettle::Dev::DEBUGGING
7
+ end