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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.envrc +4 -3
- data/.github/workflows/ancient.yml +2 -4
- data/.github/workflows/coverage.yml +5 -7
- data/.github/workflows/current.yml +2 -4
- data/.github/workflows/heads.yml +2 -4
- data/.github/workflows/jruby.yml +2 -4
- data/.github/workflows/legacy.yml +2 -4
- data/.github/workflows/locked_deps.yml +1 -4
- data/.github/workflows/style.yml +2 -4
- data/.github/workflows/supported.yml +2 -4
- data/.github/workflows/truffle.yml +2 -4
- data/.github/workflows/unlocked_deps.yml +1 -4
- data/.github/workflows/unsupported.yml +2 -4
- data/.junie/guidelines.md +4 -3
- data/.simplecov +5 -1
- data/Appraisals +3 -0
- data/CHANGELOG.md +50 -3
- data/CHANGELOG.md.example +47 -0
- data/CONTRIBUTING.md +6 -0
- data/README.md +23 -5
- data/Rakefile +43 -54
- data/exe/kettle-commit-msg +8 -140
- data/exe/kettle-readme-backers +6 -348
- data/exe/kettle-release +8 -549
- 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 +120 -0
- data/lib/kettle/dev/git_commit_footer.rb +130 -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 -343
- 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 -454
- data/lib/kettle/dev/readme_backers.rb +340 -0
- data/lib/kettle/dev/release_cli.rb +672 -0
- data/lib/kettle/dev/tasks/ci_task.rb +334 -0
- data/lib/kettle/dev/tasks/install_task.rb +298 -0
- data/lib/kettle/dev/tasks/template_task.rb +491 -0
- data/lib/kettle/dev/template_helpers.rb +4 -4
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +30 -1
- data/lib/kettle-dev.rb +2 -3
- data/sig/kettle/dev/ci_helpers.rbs +18 -8
- 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/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/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 +56 -5
- 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
|
-
|
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
|
-
#
|
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
|
-
#
|
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
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
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,348 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# --- CI helpers ---
|
2
4
|
namespace :ci do
|
3
|
-
|
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
|
-
|
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
|