kettle-dev 1.0.8 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +4 -3
  4. data/.github/workflows/ancient.yml +2 -4
  5. data/.github/workflows/coverage.yml +5 -7
  6. data/.github/workflows/current.yml +2 -4
  7. data/.github/workflows/heads.yml +2 -4
  8. data/.github/workflows/jruby.yml +2 -4
  9. data/.github/workflows/legacy.yml +2 -4
  10. data/.github/workflows/locked_deps.yml +1 -4
  11. data/.github/workflows/style.yml +2 -4
  12. data/.github/workflows/supported.yml +2 -4
  13. data/.github/workflows/truffle.yml +2 -4
  14. data/.github/workflows/unlocked_deps.yml +1 -4
  15. data/.github/workflows/unsupported.yml +2 -4
  16. data/.junie/guidelines.md +4 -3
  17. data/.simplecov +5 -1
  18. data/Appraisals +3 -0
  19. data/CHANGELOG.md +50 -3
  20. data/CHANGELOG.md.example +47 -0
  21. data/CONTRIBUTING.md +6 -0
  22. data/README.md +23 -5
  23. data/Rakefile +43 -54
  24. data/exe/kettle-commit-msg +8 -140
  25. data/exe/kettle-readme-backers +6 -348
  26. data/exe/kettle-release +8 -549
  27. data/lib/kettle/dev/ci_helpers.rb +1 -0
  28. data/lib/kettle/dev/commit_msg.rb +39 -0
  29. data/lib/kettle/dev/exit_adapter.rb +36 -0
  30. data/lib/kettle/dev/git_adapter.rb +120 -0
  31. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  32. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  33. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  34. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  35. data/lib/kettle/dev/rakelib/ci.rake +4 -343
  36. data/lib/kettle/dev/rakelib/install.rake +1 -295
  37. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  38. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  39. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  40. data/lib/kettle/dev/rakelib/template.rake +3 -454
  41. data/lib/kettle/dev/readme_backers.rb +340 -0
  42. data/lib/kettle/dev/release_cli.rb +672 -0
  43. data/lib/kettle/dev/tasks/ci_task.rb +334 -0
  44. data/lib/kettle/dev/tasks/install_task.rb +298 -0
  45. data/lib/kettle/dev/tasks/template_task.rb +491 -0
  46. data/lib/kettle/dev/template_helpers.rb +4 -4
  47. data/lib/kettle/dev/version.rb +1 -1
  48. data/lib/kettle/dev.rb +30 -1
  49. data/lib/kettle-dev.rb +2 -3
  50. data/sig/kettle/dev/ci_helpers.rbs +18 -8
  51. data/sig/kettle/dev/commit_msg.rbs +8 -0
  52. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  53. data/sig/kettle/dev/git_adapter.rbs +15 -0
  54. data/sig/kettle/dev/git_commit_footer.rbs +16 -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/version.rbs +0 -0
  62. data/sig/kettle/emoji_regex.rbs +5 -0
  63. data/sig/kettle-dev.rbs +0 -0
  64. data.tar.gz.sig +0 -0
  65. metadata +56 -5
  66. metadata.gz.sig +4 -2
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Minimal Git adapter used by kettle-dev to avoid invoking live shell commands
6
+ # directly from the library code. In tests, mock this adapter's methods to
7
+ # prevent any real network or repository mutations.
8
+ #
9
+ # This adapter requires the 'git' gem at runtime and does not shell out to
10
+ # the system git. Specs should stub the git gem API to avoid real pushes.
11
+ #
12
+ # Public API is intentionally small and only includes what we need right now.
13
+ class GitAdapter
14
+ # Create a new adapter rooted at the current working directory.
15
+ # @return [void]
16
+ def initialize
17
+ begin
18
+ require "git"
19
+ @git = ::Git.open(Dir.pwd)
20
+ rescue LoadError
21
+ raise Kettle::Dev::Error, "The 'git' gem is required at runtime. Please add it as a dependency."
22
+ rescue StandardError => e
23
+ raise Kettle::Dev::Error, "Failed to open git repository: #{e.message}"
24
+ end
25
+ end
26
+
27
+ # Push a branch to a remote.
28
+ # @param remote [String, nil] remote name (nil means default remote)
29
+ # @param branch [String] branch name (required)
30
+ # @param force [Boolean] whether to force push
31
+ # @return [Boolean] true when the push is reported successful
32
+ def push(remote, branch, force: false)
33
+ # git gem supports force: true option on push
34
+ begin
35
+ if remote
36
+ @git.push(remote, branch, force: force)
37
+ else
38
+ # Default remote according to repo config
39
+ @git.push(nil, branch, force: force)
40
+ end
41
+ true
42
+ rescue StandardError
43
+ false
44
+ end
45
+ end
46
+
47
+ # @return [String, nil] current branch name, or nil on error
48
+ def current_branch
49
+ @git.current_branch
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ # @return [Array<String>] list of remote names
55
+ def remotes
56
+ @git.remotes.map(&:name)
57
+ rescue StandardError
58
+ []
59
+ end
60
+
61
+ # @return [Hash{String=>String}] remote name => fetch URL
62
+ def remotes_with_urls
63
+ @git.remotes.each_with_object({}) do |r, h|
64
+ begin
65
+ h[r.name] = r.url
66
+ rescue StandardError
67
+ # ignore
68
+ end
69
+ end
70
+ rescue StandardError
71
+ {}
72
+ end
73
+
74
+ # @param name [String]
75
+ # @return [String, nil]
76
+ def remote_url(name)
77
+ r = @git.remotes.find { |x| x.name == name }
78
+ r&.url
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ # Checkout the given branch
84
+ # @param branch [String]
85
+ # @return [Boolean]
86
+ def checkout(branch)
87
+ @git.checkout(branch)
88
+ true
89
+ rescue StandardError
90
+ false
91
+ end
92
+
93
+ # Pull from a remote/branch
94
+ # @param remote [String]
95
+ # @param branch [String]
96
+ # @return [Boolean]
97
+ def pull(remote, branch)
98
+ @git.pull(remote, branch)
99
+ true
100
+ rescue StandardError
101
+ false
102
+ end
103
+
104
+ # Fetch a ref from a remote (or everything if ref is nil)
105
+ # @param remote [String]
106
+ # @param ref [String, nil]
107
+ # @return [Boolean]
108
+ def fetch(remote, ref = nil)
109
+ if ref
110
+ @git.fetch(remote, ref)
111
+ else
112
+ @git.fetch(remote)
113
+ end
114
+ true
115
+ rescue StandardError
116
+ false
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Exposed from lib/ so that exe/kettle-commit-msg can be a minimal wrapper.
4
+
5
+ module Kettle
6
+ module Dev
7
+ class GitCommitFooter
8
+ # Regex to extract `name = "value"` assignments from a gemspec.
9
+ # @return [Regexp]
10
+ NAME_ASSIGNMENT_REGEX = /\bname\s*=\s*(["'])([^"']+)\1/.freeze
11
+
12
+ # Whether footer appending is enabled (via GIT_HOOK_FOOTER_APPEND=true)
13
+ # @return [Boolean]
14
+ FOOTER_APPEND = ENV.fetch("GIT_HOOK_FOOTER_APPEND", "false").casecmp("true").zero?
15
+ # The sentinel string that must be present to avoid duplicate footers
16
+ # @return [String, nil]
17
+ SENTINEL = ENV["GIT_HOOK_FOOTER_SENTINEL"]
18
+
19
+ class << self
20
+ # Resolve git repository top-level dir, or nil outside a repo.
21
+ # @return [String, nil]
22
+ def git_toplevel
23
+ toplevel = nil
24
+ begin
25
+ out = %x(git rev-parse --show-toplevel 2>/dev/null)
26
+ toplevel = out.strip unless out.nil? || out.empty?
27
+ rescue StandardError
28
+ end
29
+ toplevel
30
+ end
31
+
32
+ def local_hooks_dir
33
+ top = git_toplevel
34
+ return unless top && !top.empty?
35
+ File.join(top, ".git-hooks")
36
+ end
37
+
38
+ def global_hooks_dir
39
+ File.join(ENV["HOME"], ".git-hooks")
40
+ end
41
+
42
+ def hooks_path_for(filename)
43
+ local_dir = local_hooks_dir
44
+ if local_dir
45
+ local_path = File.join(local_dir, filename)
46
+ return local_path if File.file?(local_path)
47
+ end
48
+ File.join(global_hooks_dir, filename)
49
+ end
50
+
51
+ def commit_goalie_path
52
+ hooks_path_for("commit-subjects-goalie.txt")
53
+ end
54
+
55
+ def goalie_allows_footer?(subject_line)
56
+ goalie_path = commit_goalie_path
57
+ return false unless File.file?(goalie_path)
58
+
59
+ prefixes = File.read(goalie_path).lines.map { |l| l.strip }.reject { |l| l.empty? || l.start_with?("#") }
60
+ return false if prefixes.empty?
61
+
62
+ subj = subject_line.to_s.strip
63
+ prefixes.any? { |prefix| subj.start_with?(prefix) }
64
+ end
65
+
66
+ def render(*argv)
67
+ commit_msg = File.read(argv[0])
68
+ subject_line = commit_msg.lines.first.to_s
69
+
70
+ # Evaluate configuration at runtime to respect ENV set during tests/CI
71
+ footer_append = ENV.fetch("GIT_HOOK_FOOTER_APPEND", "false").casecmp("true").zero?
72
+ sentinel = ENV["GIT_HOOK_FOOTER_SENTINEL"]
73
+
74
+ if footer_append && (sentinel.nil? || sentinel.to_s.empty?)
75
+ raise "Set GIT_HOOK_FOOTER_SENTINEL=<footer sentinel> in .env.local (e.g., '⚡️ A message from a fellow meat-based-AI ⚡️')"
76
+ end
77
+
78
+ if footer_append && goalie_allows_footer?(subject_line)
79
+ if commit_msg.include?(sentinel)
80
+ exit(0)
81
+ else
82
+ footer_binding = GitCommitFooter.new
83
+ File.open(argv[0], "w") do |file|
84
+ file.print(commit_msg)
85
+ file.print("\n")
86
+ file.print(footer_binding.render)
87
+ end
88
+ end
89
+ else
90
+ # Skipping footer append
91
+ end
92
+ end
93
+ end
94
+
95
+ def initialize
96
+ @pwd = Dir.pwd
97
+ @gemspecs = Dir["*.gemspec"]
98
+ @spec = @gemspecs.first
99
+ @gemspec_path = File.expand_path(@spec, @pwd)
100
+ @gem_name = parse_gemspec_name || derive_gem_name
101
+ end
102
+
103
+ def render
104
+ ERB.new(template).result(binding)
105
+ end
106
+
107
+ private
108
+
109
+ def parse_gemspec_name
110
+ begin
111
+ content = File.read(@gemspec_path)
112
+ @name_index = content =~ NAME_ASSIGNMENT_REGEX
113
+ if @name_index
114
+ return $2
115
+ end
116
+ rescue StandardError
117
+ end
118
+ nil
119
+ end
120
+
121
+ def derive_gem_name
122
+ File.basename(@gemspec_path, ".*") if @gemspec_path
123
+ end
124
+
125
+ def template
126
+ File.read(self.class.hooks_path_for("footer-template.erb.txt"))
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,9 +1,4 @@
1
- begin
2
- require "bundler"
3
- rescue LoadError
4
- warn("[kettle-dev][appraisal.rake] failed to load bundler") if Kettle::Dev::DEBUGGING
5
- # ok, might still work
6
- end
1
+ # frozen_string_literal: true
7
2
 
8
3
  # --- Appraisals (dev-only) ---
9
4
  begin
@@ -16,15 +11,19 @@ begin
16
11
  run_in_unbundled = proc do
17
12
  env = {"BUNDLE_GEMFILE" => "Appraisal.root.gemfile"}
18
13
 
19
- # 1) BUNDLE_GEMFILE=Appraisal.root.gemfile bundle
14
+ # 1) BUNDLE_GEMFILE=Appraisal.root.gemfile bundle update --bundler
15
+ ok = system(env, bundle, "update", "--bundler")
16
+ abort("appraisal:update failed: bundle update --bundler under Appraisal.root.gemfile") unless ok
17
+
18
+ # 2) BUNDLE_GEMFILE=Appraisal.root.gemfile bundle (install)
20
19
  ok = system(env, bundle)
21
20
  abort("appraisal:update failed: bundler install under Appraisal.root.gemfile") unless ok
22
21
 
23
- # 2) BUNDLE_GEMFILE=Appraisal.root.gemfile bundle exec appraisal update
22
+ # 3) BUNDLE_GEMFILE=Appraisal.root.gemfile bundle exec appraisal update
24
23
  ok = system(env, bundle, "exec", "appraisal", "update")
25
24
  abort("appraisal:update failed: bundle exec appraisal update") unless ok
26
25
 
27
- # 3) bundle exec rake rubocop_gradual:autocorrect
26
+ # 4) bundle exec rake rubocop_gradual:autocorrect
28
27
  ok = system(bundle, "exec", "rake", "rubocop_gradual:autocorrect")
29
28
  abort("appraisal:update failed: rubocop_gradual:autocorrect") unless ok
30
29
  end
@@ -1,11 +1,6 @@
1
- require "rbconfig" if !Dir[File.join(__dir__, "benchmarks")].empty? # Used by `rake bench:run`
1
+ # frozen_string_literal: true
2
2
 
3
- begin
4
- require "bundler"
5
- rescue LoadError
6
- warn("[kettle-dev][bench.rake] failed to load bundler") if Kettle::Dev::DEBUGGING
7
- # ok, might still work
8
- end
3
+ require "rbconfig" if !Dir[File.join(__dir__, "benchmarks")].empty? # Used by `rake bench:run`
9
4
 
10
5
  # --- Benchmarks (dev-only) ---
11
6
  namespace :bench do
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Setup Bundle Audit
2
4
  begin
3
5
  require "bundler/audit/task"
@@ -1,348 +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
- 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)
7
+ Kettle::Dev::Tasks::CITask.act(args[:opt])
346
8
  end
347
- # rubocop:enable ThreadSafety/NewThread
348
9
  end