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,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
|