kettle-dev 1.0.9 → 1.0.10

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 (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.junie/guidelines.md +4 -3
  6. data/.simplecov +5 -1
  7. data/Appraisals +3 -0
  8. data/CHANGELOG.md +22 -1
  9. data/CONTRIBUTING.md +6 -0
  10. data/README.md +18 -5
  11. data/Rakefile +7 -11
  12. data/exe/kettle-commit-msg +9 -143
  13. data/exe/kettle-readme-backers +7 -353
  14. data/exe/kettle-release +8 -702
  15. data/lib/kettle/dev/ci_helpers.rb +1 -0
  16. data/lib/kettle/dev/commit_msg.rb +39 -0
  17. data/lib/kettle/dev/exit_adapter.rb +36 -0
  18. data/lib/kettle/dev/git_adapter.rb +120 -0
  19. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  20. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  21. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  22. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  23. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  24. data/lib/kettle/dev/rakelib/install.rake +1 -295
  25. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  26. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  27. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  28. data/lib/kettle/dev/rakelib/template.rake +3 -465
  29. data/lib/kettle/dev/readme_backers.rb +340 -0
  30. data/lib/kettle/dev/release_cli.rb +672 -0
  31. data/lib/kettle/dev/tasks/ci_task.rb +334 -0
  32. data/lib/kettle/dev/tasks/install_task.rb +298 -0
  33. data/lib/kettle/dev/tasks/template_task.rb +491 -0
  34. data/lib/kettle/dev/template_helpers.rb +4 -4
  35. data/lib/kettle/dev/version.rb +1 -1
  36. data/lib/kettle/dev.rb +30 -1
  37. data/lib/kettle-dev.rb +2 -3
  38. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  39. data/sig/kettle/dev/commit_msg.rbs +8 -0
  40. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  41. data/sig/kettle/dev/git_adapter.rbs +15 -0
  42. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  43. data/sig/kettle/dev/readme_backers.rbs +20 -0
  44. data/sig/kettle/dev/release_cli.rbs +8 -0
  45. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  46. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  47. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  48. data/sig/kettle/dev/tasks.rbs +0 -0
  49. data/sig/kettle/dev/version.rbs +0 -0
  50. data/sig/kettle/emoji_regex.rbs +5 -0
  51. data/sig/kettle-dev.rbs +0 -0
  52. data.tar.gz.sig +0 -0
  53. metadata +55 -5
  54. metadata.gz.sig +0 -0
@@ -1,401 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # --- CI helpers ---
2
4
  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
+ desc "Run 'act' with a selected workflow. Usage: rake ci:act[loc], ci:act[locked_deps], ci:act[locked_deps.yml], or rake ci:act (interactive)"
5
6
  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
- get_sha = proc do
69
- out, status = Open3.capture2("git", "rev-parse", "--short", "HEAD")
70
- status.success? ? out.strip : nil
71
- end
72
-
73
- get_upstream = proc do
74
- out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
75
- if status.success?
76
- out.strip
77
- else
78
- br = get_branch.call
79
- br ? "origin/#{br}" : nil
80
- end
81
- end
82
-
83
- status_emoji = proc do |status, conclusion|
84
- case status
85
- when "queued"
86
- "⏳️"
87
- when "in_progress"
88
- "👟"
89
- when "completed"
90
- (conclusion == "success") ? "✅" : "🍅"
91
- else
92
- "⏳️"
93
- end
94
- end
95
-
96
- fetch_and_print_status = proc do |workflow_file|
97
- branch = get_branch.call
98
- org_repo = get_origin.call
99
- unless branch && org_repo
100
- puts "GHA status: (skipped; missing git branch or remote)"
101
- next
102
- end
103
- owner, repo = org_repo
104
- uri = URI("https://api.github.com/repos/#{owner}/#{repo}/actions/workflows/#{workflow_file}/runs?branch=#{URI.encode_www_form_component(branch)}&per_page=1")
105
- req = Net::HTTP::Get.new(uri)
106
- req["User-Agent"] = "ci:act rake task"
107
- token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
108
- req["Authorization"] = "token #{token}" if token && !token.empty?
109
-
110
- begin
111
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
112
- if res.is_a?(Net::HTTPSuccess)
113
- data = JSON.parse(res.body)
114
- run = data["workflow_runs"]&.first
115
- if run
116
- status = run["status"]
117
- conclusion = run["conclusion"]
118
- emoji = status_emoji.call(status, conclusion)
119
- details = [status, conclusion].compact.join("/")
120
- puts "Latest GHA (#{branch}) for #{workflow_file}: #{emoji} (#{details})"
121
- else
122
- puts "Latest GHA (#{branch}) for #{workflow_file}: none"
123
- end
124
- else
125
- puts "GHA status: request failed (#{res.code})"
126
- end
127
- rescue StandardError => e
128
- puts "GHA status: error #{e.class}: #{e.message}"
129
- end
130
- end
131
-
132
- def run_act_for(file_path)
133
- # Prefer array form to avoid shell escaping issues
134
- ok = system("act", "-W", file_path)
135
- abort("ci:act failed: 'act' command not found or exited with failure") unless ok
136
- end
137
-
138
- def process_success_response(res, c, f, old = nil, current = nil)
139
- data = JSON.parse(res.body)
140
- run = data["workflow_runs"]&.first
141
- append = (old && current) ? " (update git remote: #{old} → #{current})" : ""
142
- if run
143
- st = run["status"]
144
- con = run["conclusion"]
145
- emoji = case st
146
- when "queued" then "⏳️"
147
- when "in_progress" then "👟"
148
- when "completed" then ((con == "success") ? "✅" : "🍅")
149
- else "⏳️"
150
- end
151
- details = [st, con].compact.join("/")
152
- [c, f, "#{emoji} (#{details})#{append}"]
153
- else
154
- [c, f, "none#{append}"]
155
- end
156
- end
157
-
158
- if choice && !choice.empty?
159
- # If user passed a filename directly (with or without extension), resolve it
160
- file = if mapping.key?(choice)
161
- mapping.fetch(choice)
162
- elsif !!(/\.(yml|yaml)\z/ =~ choice)
163
- # Accept either full basename (without ext) or basename with .yml/.yaml
164
- choice
165
- else
166
- cand_yml = File.join(workflows_dir, "#{choice}.yml")
167
- cand_yaml = File.join(workflows_dir, "#{choice}.yaml")
168
- if File.file?(cand_yml)
169
- "#{choice}.yml"
170
- elsif File.file?(cand_yaml)
171
- "#{choice}.yaml"
172
- else
173
- # Fall back to .yml for error messaging; will fail below
174
- "#{choice}.yml"
175
- end
176
- end
177
- file_path = File.join(workflows_dir, file)
178
- unless File.file?(file_path)
179
- puts "Unknown option or missing workflow file: #{choice} -> #{file}"
180
- puts "Available options:"
181
- mapping.each { |k, v| puts " #{k.ljust(3)} => #{v}" }
182
- # Also display dynamically discovered files
183
- unless dynamic_files.empty?
184
- puts " (others) =>"
185
- dynamic_files.each { |v| puts " #{v}" }
186
- end
187
- abort("ci:act aborted")
188
- end
189
- fetch_and_print_status.call(file)
190
- run_act_for(file_path)
191
- next
192
- end
193
-
194
- # No option provided: interactive menu with live GHA statuses via Threads (no Ractors)
195
- require "thread"
196
-
197
- tty = $stdout.tty?
198
- # Build options: first the filtered short-code mapping, then dynamic files (no short codes)
199
- options = mapping.to_a + dynamic_files.map { |f| [f, f] }
200
-
201
- # Add a Quit choice
202
- quit_code = "q"
203
- options_with_quit = options + [[quit_code, "(quit)"]]
204
-
205
- idx_by_code = {}
206
- options_with_quit.each_with_index { |(k, _v), i| idx_by_code[k] = i }
207
-
208
- # Determine repo context once
209
- branch = get_branch.call
210
- org = get_origin.call
211
- owner, repo = org if org
212
- token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
213
-
214
- # Header with remote branch and SHA
215
- upstream = get_upstream.call
216
- sha = get_sha.call
217
- if org && branch
218
- puts "Repo: #{owner}/#{repo}"
219
- elsif org
220
- puts "Repo: #{owner}/#{repo}"
221
- else
222
- puts "Repo: n/a"
223
- end
224
- puts "Upstream: #{upstream || "n/a"}"
225
- puts "HEAD: #{sha || "n/a"}"
226
- puts
227
-
228
- puts "Select a workflow to run with 'act':"
229
-
230
- # Render initial menu with placeholder statuses
231
- placeholder = "[…]"
232
- options_with_quit.each_with_index do |(k, v), idx|
233
- status_col = (k == quit_code) ? "" : placeholder
234
- disp = (k == quit_code) ? k : display_code_for[k]
235
- line = format("%2d) %-3s => %-20s %s", idx + 1, disp, v, status_col)
236
- puts line
237
- end
238
-
239
- puts "(Fetching latest GHA status for branch #{branch || "n/a"} — you can type your choice and press Enter)"
240
- prompt = "Enter number or code (or 'q' to quit): "
241
- print prompt
242
- $stdout.flush
243
-
244
- # Thread + Queue to read user input
245
- input_q = Queue.new
246
- input_thread = Thread.new do
247
- line = $stdin.gets&.strip
248
- input_q << line
249
- end
250
-
251
- # Worker threads to fetch statuses and stream updates as they complete
252
- status_q = Queue.new
253
- workers = []
254
-
255
- # Capture a monotonic start time to guard against early race with terminal rendering
256
- start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
257
-
258
- options.each do |code, file|
259
- workers << Thread.new(code, file, owner, repo, branch, token, start_at) do |c, f, ow, rp, br, tk, st_at|
260
- begin
261
- # small initial delay if threads finish too quickly, to let the menu/prompt finish rendering
262
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
- delay = 0.12 - (now - st_at)
264
- sleep(delay) if delay && delay > 0
265
-
266
- if ow.nil? || rp.nil? || br.nil?
267
- status_q << [c, f, "n/a"]
268
- Thread.exit
269
- end
270
- uri = URI("https://api.github.com/repos/#{ow}/#{rp}/actions/workflows/#{f}/runs?branch=#{URI.encode_www_form_component(br)}&per_page=1")
271
- poll_interval = Integer(ENV["CI_ACT_POLL_INTERVAL"] || 5)
272
- loop do
273
- begin
274
- req = Net::HTTP::Get.new(uri)
275
- req["User-Agent"] = "ci:act rake task"
276
- req["Authorization"] = "token #{tk}" if tk && !tk.empty?
277
- res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
278
- if res.is_a?(Net::HTTPSuccess)
279
- data = JSON.parse(res.body)
280
- run = data["workflow_runs"]&.first
281
- if run
282
- st = run["status"]
283
- con = run["conclusion"]
284
- emoji = case st
285
- when "queued" then "⏳️"
286
- when "in_progress" then "👟"
287
- when "completed" then ((con == "success") ? "✅" : "🍅")
288
- else "⏳️"
289
- end
290
- details = [st, con].compact.join("/")
291
- status_q << [c, f, "#{emoji} (#{details})"]
292
- break if st == "completed"
293
- else
294
- status_q << [c, f, "none"]
295
- break
296
- end
297
- else
298
- status_q << [c, f, "fail #{res.code}"]
299
- end
300
- rescue StandardError
301
- status_q << [c, f, "err"]
302
- end
303
- sleep(poll_interval)
304
- end
305
- rescue StandardError
306
- status_q << [c, f, "err"]
307
- end
308
- end
309
- end
310
-
311
- # Live update loop: either statuses arrive or the user submits input
312
- statuses = Hash.new(placeholder)
313
- selected = nil
314
-
315
- loop do
316
- # Check for user input first (non-blocking)
317
- unless input_q.empty?
318
- selected = begin
319
- input_q.pop(true)
320
- rescue
321
- nil
322
- end
323
- break if selected
324
- end
325
-
326
- # Drain any available status updates without blocking
327
- begin
328
- code, file_name, display = status_q.pop(true)
329
- statuses[code] = display
330
-
331
- if tty
332
- idx = idx_by_code[code]
333
- if idx.nil?
334
- puts "status #{code}: #{display}"
335
- print(prompt)
336
- else
337
- move_up = options_with_quit.size - idx + 1 # 1 for instruction line + remaining options above last
338
- $stdout.print("\e[#{move_up}A\r\e[2K")
339
- disp = (code == quit_code) ? code : display_code_for[code]
340
- $stdout.print(format("%2d) %-3s => %-20s %s\n", idx + 1, disp, file_name, display))
341
- $stdout.print("\e[#{move_up - 1}B\r")
342
- $stdout.print(prompt)
343
- end
344
- $stdout.flush
345
- else
346
- puts "status #{code}: #{display}"
347
- end
348
- rescue ThreadError
349
- # Queue empty: brief sleep to avoid busy wait
350
- sleep(0.05)
351
- end
352
- end
353
-
354
- # Cleanup: kill any still-running threads
355
- begin
356
- workers.each { |t| t.kill if t&.alive? }
357
- rescue StandardError
358
- # ignore
359
- end
360
- begin
361
- input_thread.kill if input_thread&.alive?
362
- rescue StandardError
363
- # ignore
364
- end
365
-
366
- input = selected
367
- abort("ci:act aborted: no selection") if input.nil? || input.empty?
368
-
369
- # Normalize selection
370
- chosen_file = nil
371
- if !!(/^\d+$/ =~ input)
372
- idx = input.to_i - 1
373
- if idx < 0 || idx >= options_with_quit.length
374
- abort("ci:act aborted: invalid selection #{input}")
375
- end
376
- code, val = options_with_quit[idx]
377
- if code == quit_code
378
- puts "ci:act: quit"
379
- next
380
- else
381
- chosen_file = val
382
- end
383
- else
384
- code = input
385
- if ["q", "quit", "exit"].include?(code.downcase)
386
- puts "ci:act: quit"
387
- next
388
- end
389
- chosen_file = mapping[code]
390
- abort("ci:act aborted: unknown code '#{code}'") unless chosen_file
391
- end
392
-
393
- file_path = File.join(workflows_dir, chosen_file)
394
- abort("ci:act aborted: workflow not found: #{file_path}") unless File.file?(file_path)
395
-
396
- # Print status for the chosen workflow (for consistency)
397
- fetch_and_print_status.call(chosen_file)
398
- run_act_for(file_path)
7
+ Kettle::Dev::Tasks::CITask.act(args[:opt])
399
8
  end
400
- # rubocop:enable ThreadSafety/NewThread
401
9
  end