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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.github/workflows/coverage.yml.example +127 -0
  6. data/.github/workflows/discord-notifier.yml +2 -1
  7. data/.github/workflows/truffle.yml +0 -8
  8. data/.junie/guidelines.md +4 -3
  9. data/.simplecov +5 -1
  10. data/Appraisals +5 -0
  11. data/Appraisals.example +102 -0
  12. data/CHANGELOG.md +80 -25
  13. data/CHANGELOG.md.example +4 -4
  14. data/CONTRIBUTING.md +43 -1
  15. data/Gemfile +3 -0
  16. data/README.md +65 -14
  17. data/README.md.example +515 -0
  18. data/{Rakefile → Rakefile.example} +17 -35
  19. data/exe/kettle-changelog +401 -0
  20. data/exe/kettle-commit-msg +11 -143
  21. data/exe/kettle-readme-backers +8 -352
  22. data/exe/kettle-release +7 -706
  23. data/gemfiles/modular/optional.gemfile +5 -0
  24. data/lib/kettle/dev/ci_helpers.rb +1 -0
  25. data/lib/kettle/dev/commit_msg.rb +39 -0
  26. data/lib/kettle/dev/exit_adapter.rb +36 -0
  27. data/lib/kettle/dev/git_adapter.rb +185 -0
  28. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  29. data/lib/kettle/dev/input_adapter.rb +40 -0
  30. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  31. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  32. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  33. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  34. data/lib/kettle/dev/rakelib/install.rake +1 -295
  35. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  36. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  37. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  38. data/lib/kettle/dev/rakelib/template.rake +3 -465
  39. data/lib/kettle/dev/readme_backers.rb +340 -0
  40. data/lib/kettle/dev/release_cli.rb +674 -0
  41. data/lib/kettle/dev/tasks/ci_task.rb +337 -0
  42. data/lib/kettle/dev/tasks/install_task.rb +516 -0
  43. data/lib/kettle/dev/tasks/template_task.rb +593 -0
  44. data/lib/kettle/dev/template_helpers.rb +65 -12
  45. data/lib/kettle/dev/version.rb +1 -1
  46. data/lib/kettle/dev/versioning.rb +68 -0
  47. data/lib/kettle/dev.rb +30 -1
  48. data/lib/kettle-dev.rb +2 -3
  49. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  50. data/sig/kettle/dev/commit_msg.rbs +8 -0
  51. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  52. data/sig/kettle/dev/git_adapter.rbs +15 -0
  53. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  54. data/sig/kettle/dev/input_adapter.rbs +8 -0
  55. data/sig/kettle/dev/readme_backers.rbs +20 -0
  56. data/sig/kettle/dev/release_cli.rbs +8 -0
  57. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  58. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  59. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  60. data/sig/kettle/dev/tasks.rbs +0 -0
  61. data/sig/kettle/dev/template_helpers.rbs +3 -1
  62. data/sig/kettle/dev/version.rbs +0 -0
  63. data/sig/kettle/emoji_regex.rbs +5 -0
  64. data/sig/kettle-dev.rbs +0 -0
  65. data.tar.gz.sig +0 -0
  66. metadata +59 -10
  67. metadata.gz.sig +0 -0
  68. data/.gitlab-ci.yml +0 -45
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External
4
+ require "kettle/dev/exit_adapter"
5
+ require "kettle/dev/input_adapter"
6
+ require "open3"
7
+ require "net/http"
8
+ require "json"
9
+ require "uri"
10
+
11
+ module Kettle
12
+ module Dev
13
+ module Tasks
14
+ module CITask
15
+ module_function
16
+
17
+ # Local abort indirection to enable mocking via ExitAdapter
18
+ def abort(msg)
19
+ Kettle::Dev::ExitAdapter.abort(msg)
20
+ end
21
+ module_function :abort
22
+
23
+ # Runs `act` for a selected workflow. Option can be a short code or workflow basename.
24
+ # Mirrors the behavior previously implemented in the ci:act rake task.
25
+ # @param opt [String, nil]
26
+ def act(opt = nil)
27
+ require "io/console"
28
+ choice = opt&.strip
29
+
30
+ root_dir = Kettle::Dev::CIHelpers.project_root
31
+ workflows_dir = File.join(root_dir, ".github", "workflows")
32
+
33
+ # Build mapping dynamically from workflow files; short code = first three letters of filename stem
34
+ mapping = {}
35
+
36
+ existing_files = if Dir.exist?(workflows_dir)
37
+ Dir[File.join(workflows_dir, "*.yml")] + Dir[File.join(workflows_dir, "*.yaml")]
38
+ else
39
+ []
40
+ end
41
+ existing_basenames = existing_files.map { |p| File.basename(p) }
42
+
43
+ exclusions = Kettle::Dev::CIHelpers.exclusions
44
+ candidate_files = existing_basenames.uniq - exclusions
45
+ candidate_files.sort.each do |fname|
46
+ stem = fname.sub(/\.(ya?ml)\z/, "")
47
+ code = stem[0, 3].to_s.downcase
48
+ next if code.empty?
49
+ mapping[code] ||= fname
50
+ end
51
+
52
+ dynamic_files = candidate_files - mapping.values
53
+ display_code_for = {}
54
+ mapping.keys.each { |k| display_code_for[k] = k }
55
+ dynamic_files.each { |f| display_code_for[f] = "" }
56
+
57
+ status_emoji = proc do |status, conclusion|
58
+ case status
59
+ when "queued" then "⏳️"
60
+ when "in_progress" then "👟"
61
+ when "completed" then ((conclusion == "success") ? "✅" : "🍅")
62
+ else "⏳️"
63
+ end
64
+ end
65
+
66
+ fetch_and_print_status = proc do |workflow_file|
67
+ branch = Kettle::Dev::CIHelpers.current_branch
68
+ org_repo = Kettle::Dev::CIHelpers.repo_info
69
+ unless branch && org_repo
70
+ puts "GHA status: (skipped; missing git branch or remote)"
71
+ next
72
+ end
73
+ owner, repo = org_repo
74
+ uri = URI("https://api.github.com/repos/#{owner}/#{repo}/actions/workflows/#{workflow_file}/runs?branch=#{URI.encode_www_form_component(branch)}&per_page=1")
75
+ req = Net::HTTP::Get.new(uri)
76
+ req["User-Agent"] = "ci:act rake task"
77
+ token = Kettle::Dev::CIHelpers.default_token
78
+ req["Authorization"] = "token #{token}" if token && !token.empty?
79
+ begin
80
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
81
+ if res.is_a?(Net::HTTPSuccess)
82
+ data = JSON.parse(res.body)
83
+ run = data["workflow_runs"]&.first
84
+ if run
85
+ status = run["status"]
86
+ conclusion = run["conclusion"]
87
+ emoji = status_emoji.call(status, conclusion)
88
+ details = [status, conclusion].compact.join("/")
89
+ puts "Latest GHA (#{branch}) for #{workflow_file}: #{emoji} (#{details})"
90
+ else
91
+ puts "Latest GHA (#{branch}) for #{workflow_file}: none"
92
+ end
93
+ else
94
+ puts "GHA status: request failed (#{res.code})"
95
+ end
96
+ rescue StandardError => e
97
+ puts "GHA status: error #{e.class}: #{e.message}"
98
+ end
99
+ end
100
+
101
+ run_act_for = proc do |file_path|
102
+ ok = system("act", "-W", file_path)
103
+ abort("ci:act failed: 'act' command not found or exited with failure") unless ok
104
+ end
105
+
106
+ if choice && !choice.empty?
107
+ file = if mapping.key?(choice)
108
+ mapping.fetch(choice)
109
+ elsif !!(/\.(yml|yaml)\z/ =~ choice)
110
+ choice
111
+ else
112
+ cand_yml = File.join(workflows_dir, "#{choice}.yml")
113
+ cand_yaml = File.join(workflows_dir, "#{choice}.yaml")
114
+ if File.file?(cand_yml)
115
+ "#{choice}.yml"
116
+ elsif File.file?(cand_yaml)
117
+ "#{choice}.yaml"
118
+ else
119
+ "#{choice}.yml"
120
+ end
121
+ end
122
+ file_path = File.join(workflows_dir, file)
123
+ unless File.file?(file_path)
124
+ puts "Unknown option or missing workflow file: #{choice} -> #{file}"
125
+ puts "Available options:"
126
+ mapping.each { |k, v| puts " #{k.ljust(3)} => #{v}" }
127
+ unless dynamic_files.empty?
128
+ puts " (others) =>"
129
+ dynamic_files.each { |v| puts " #{v}" }
130
+ end
131
+ abort("ci:act aborted")
132
+ end
133
+ fetch_and_print_status.call(file)
134
+ run_act_for.call(file_path)
135
+ return
136
+ end
137
+
138
+ # Interactive menu
139
+ require "thread"
140
+ tty = $stdout.tty?
141
+ options = mapping.to_a + dynamic_files.map { |f| [f, f] }
142
+ quit_code = "q"
143
+ options_with_quit = options + [[quit_code, "(quit)"]]
144
+ idx_by_code = {}
145
+ options_with_quit.each_with_index { |(k, _v), i| idx_by_code[k] = i }
146
+
147
+ branch = Kettle::Dev::CIHelpers.current_branch
148
+ org = Kettle::Dev::CIHelpers.repo_info
149
+ owner, repo = org if org
150
+ token = Kettle::Dev::CIHelpers.default_token
151
+
152
+ upstream = begin
153
+ out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
154
+ status.success? ? out.strip : nil
155
+ rescue StandardError
156
+ nil
157
+ end
158
+ sha = begin
159
+ out, status = Open3.capture2("git", "rev-parse", "--short", "HEAD")
160
+ status.success? ? out.strip : nil
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ if org && branch
165
+ puts "Repo: #{owner}/#{repo}"
166
+ elsif org
167
+ puts "Repo: #{owner}/#{repo}"
168
+ else
169
+ puts "Repo: n/a"
170
+ end
171
+ puts "Upstream: #{upstream || "n/a"}"
172
+ puts "HEAD: #{sha || "n/a"}"
173
+ puts
174
+ puts "Select a workflow to run with 'act':"
175
+
176
+ placeholder = "[…]"
177
+ options_with_quit.each_with_index do |(k, v), idx|
178
+ status_col = (k == quit_code) ? "" : placeholder
179
+ disp = (k == quit_code) ? k : display_code_for[k]
180
+ line = format("%2d) %-3s => %-20s %s", idx + 1, disp, v, status_col)
181
+ puts line
182
+ end
183
+
184
+ puts "(Fetching latest GHA status for branch #{branch || "n/a"} — you can type your choice and press Enter)"
185
+ prompt = "Enter number or code (or 'q' to quit): "
186
+ print(prompt)
187
+ $stdout.flush
188
+
189
+ selected = nil
190
+ input_thread = Thread.new do
191
+ begin
192
+ selected = Kettle::Dev::InputAdapter.gets&.strip
193
+ rescue Exception => error
194
+ # Catch all exceptions in background thread, including SystemExit
195
+ # NOTE: look into refactoring to minimize potential SystemExit.
196
+ puts "Error in background thread: #{error.class}: #{error.message}" if Kettle::Dev::DEBUGGING
197
+ selected = nil
198
+ end
199
+ end
200
+
201
+ status_q = Queue.new
202
+ workers = []
203
+ start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
204
+
205
+ options.each do |code, file|
206
+ workers << Thread.new(code, file, owner, repo, branch, token, start_at) do |c, f, ow, rp, br, tk, st_at|
207
+ begin
208
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
209
+ delay = 0.12 - (now - st_at)
210
+ sleep(delay) if delay && delay > 0
211
+
212
+ if ow.nil? || rp.nil? || br.nil?
213
+ status_q << [c, f, "n/a"]
214
+ Thread.exit
215
+ end
216
+ uri = URI("https://api.github.com/repos/#{ow}/#{rp}/actions/workflows/#{f}/runs?branch=#{URI.encode_www_form_component(br)}&per_page=1")
217
+ poll_interval = Integer(ENV["CI_ACT_POLL_INTERVAL"] || 5)
218
+ loop do
219
+ begin
220
+ req = Net::HTTP::Get.new(uri)
221
+ req["User-Agent"] = "ci:act rake task"
222
+ req["Authorization"] = "token #{tk}" if tk && !tk.empty?
223
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
224
+ if res.is_a?(Net::HTTPSuccess)
225
+ data = JSON.parse(res.body)
226
+ run = data["workflow_runs"]&.first
227
+ if run
228
+ st = run["status"]
229
+ con = run["conclusion"]
230
+ emoji = case st
231
+ when "queued" then "⏳️"
232
+ when "in_progress" then "👟"
233
+ when "completed" then ((con == "success") ? "✅" : "🍅")
234
+ else "⏳️"
235
+ end
236
+ details = [st, con].compact.join("/")
237
+ status_q << [c, f, "#{emoji} (#{details})"]
238
+ break if st == "completed"
239
+ else
240
+ status_q << [c, f, "none"]
241
+ break
242
+ end
243
+ else
244
+ status_q << [c, f, "fail #{res.code}"]
245
+ end
246
+ rescue Exception
247
+ # Catch all exceptions to prevent crashing the process from a worker thread
248
+ status_q << [c, f, "err"]
249
+ end
250
+ sleep(poll_interval)
251
+ end
252
+ rescue Exception
253
+ # :nocov:
254
+ # Catch all exceptions in the worker thread boundary, including SystemExit
255
+ status_q << [c, f, "err"]
256
+ # :nocov:
257
+ end
258
+ end
259
+ end
260
+
261
+ statuses = Hash.new(placeholder)
262
+
263
+ loop do
264
+ if selected
265
+ break
266
+ end
267
+
268
+ begin
269
+ code, file_name, display = status_q.pop(true)
270
+ statuses[code] = display
271
+
272
+ if tty
273
+ idx = idx_by_code[code]
274
+ if idx.nil?
275
+ puts "status #{code}: #{display}"
276
+ print(prompt)
277
+ else
278
+ move_up = options_with_quit.size - idx + 1
279
+ $stdout.print("\e[#{move_up}A\r\e[2K")
280
+ disp = (code == quit_code) ? code : display_code_for[code]
281
+ $stdout.print(format("%2d) %-3s => %-20s %s\n", idx + 1, disp, file_name, display))
282
+ $stdout.print("\e[#{move_up - 1}B\r")
283
+ $stdout.print(prompt)
284
+ end
285
+ $stdout.flush
286
+ else
287
+ puts "status #{code}: #{display}"
288
+ end
289
+ rescue ThreadError
290
+ sleep(0.05)
291
+ end
292
+ end
293
+
294
+ begin
295
+ workers.each { |t| t.kill if t&.alive? }
296
+ rescue StandardError
297
+ end
298
+ begin
299
+ input_thread.kill if input_thread&.alive?
300
+ rescue StandardError
301
+ end
302
+
303
+ input = selected
304
+ abort("ci:act aborted: no selection") if input.nil? || input.empty?
305
+
306
+ chosen_file = nil
307
+ if !!(/^\d+$/ =~ input)
308
+ idx = input.to_i - 1
309
+ if idx < 0 || idx >= options_with_quit.length
310
+ abort("ci:act aborted: invalid selection #{input}")
311
+ end
312
+ code, val = options_with_quit[idx]
313
+ if code == quit_code
314
+ puts "ci:act: quit"
315
+ return
316
+ else
317
+ chosen_file = val
318
+ end
319
+ else
320
+ code = input
321
+ if ["q", "quit", "exit"].include?(code.downcase)
322
+ puts "ci:act: quit"
323
+ return
324
+ end
325
+ chosen_file = mapping[code]
326
+ abort("ci:act aborted: unknown code '#{code}'") unless chosen_file
327
+ end
328
+
329
+ file_path = File.join(workflows_dir, chosen_file)
330
+ abort("ci:act aborted: workflow not found: #{file_path}") unless File.file?(file_path)
331
+ fetch_and_print_status.call(chosen_file)
332
+ run_act_for.call(file_path)
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end