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,5 @@
1
+ # Optional dependencies are not dependended on directly, but will be used if present.
2
+ # git gem is not a direct dependency for two reasons:
3
+ # 1. it is incompatible with Truffleruby v23
4
+ # 2. it depends on activesupport, which is too heavy
5
+ gem "git", ">= 1.19.1" # ruby >= 2.3
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # External stdlib
3
4
  require "open3"
4
5
  require "net/http"
5
6
  require "json"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Branch rule enforcement and commit message footer support for commit-msg hook.
4
+ # Provides a lib entrypoint so the exe wrapper can be minimal.
5
+
6
+ module Kettle
7
+ module Dev
8
+ module CommitMsg
9
+ module_function
10
+
11
+ BRANCH_RULES = {
12
+ "jira" => /^(?<story_type>(hotfix)|(bug)|(feature)|(candy))\/(?<story_id>\d{8,})-.+\Z/,
13
+ }.freeze
14
+
15
+ # Enforce branch rule by appending [type][id] to the commit message when missing.
16
+ # @param path [String] path to commit message file (ARGV[0] from git)
17
+ def enforce_branch_rule!(path)
18
+ validate = ENV.fetch("GIT_HOOK_BRANCH_VALIDATE", "false")
19
+ branch_rule_type = (!validate.casecmp("false").zero? && validate) || nil
20
+ return unless branch_rule_type
21
+ branch_rule = BRANCH_RULES[branch_rule_type]
22
+ return unless branch_rule
23
+
24
+ branch = %x(git branch 2> /dev/null | grep -e ^* | awk '{print $2}')
25
+ match_data = branch.match(branch_rule)
26
+ return unless match_data
27
+
28
+ commit_msg = File.read(path)
29
+ unless commit_msg.include?(match_data[:story_id])
30
+ commit_msg = <<~EOS
31
+ #{commit_msg.strip}
32
+ [#{match_data[:story_type]}][#{match_data[:story_id]}]
33
+ EOS
34
+ File.open(path, "w") { |file| file.print(commit_msg) }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Exit/abort indirection layer to allow controllable behavior in tests.
6
+ #
7
+ # Production/default behavior delegates to Kernel.abort / Kernel.exit,
8
+ # which raise SystemExit. Specs can stub these methods to avoid terminating
9
+ # the process or to assert on arguments without coupling to Kernel.
10
+ #
11
+ # Example (RSpec):
12
+ # allow(Kettle::Dev::ExitAdapter).to receive(:abort).and_raise(SystemExit.new(1))
13
+ #
14
+ # This adapter mirrors the "mockable adapter" approach used for GitAdapter.
15
+ module ExitAdapter
16
+ module_function
17
+
18
+ # Abort the current execution with a message. By default this calls Kernel.abort,
19
+ # which raises SystemExit after printing the message to STDERR.
20
+ #
21
+ # @param msg [String]
22
+ # @return [void]
23
+ def abort(msg)
24
+ Kernel.abort(msg)
25
+ end
26
+
27
+ # Exit the current process with a given status code. By default this calls Kernel.exit.
28
+ #
29
+ # @param status [Integer]
30
+ # @return [void]
31
+ def exit(status = 0)
32
+ Kernel.exit(status)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Kettle
6
+ module Dev
7
+ # Minimal Git adapter used by kettle-dev to avoid invoking live shell commands
8
+ # directly from the higher-level library code. In tests, mock this adapter's
9
+ # methods to prevent any real network or repository mutations.
10
+ #
11
+ # Behavior:
12
+ # - Prefer the 'git' gem when available.
13
+ # - If the 'git' gem is not present (LoadError), fall back to shelling out to
14
+ # the system `git` executable for the small set of operations we need.
15
+ #
16
+ # Public API is intentionally small and only includes what we need right now.
17
+ class GitAdapter
18
+ # Create a new adapter rooted at the current working directory.
19
+ # @return [void]
20
+ def initialize
21
+ begin
22
+ # Allow users/CI to opt out of using the 'git' gem even when available.
23
+ # Set KETTLE_DEV_DISABLE_GIT_GEM to a truthy value ("1", "true", "yes") to force CLI backend.
24
+ env_val = ENV["KETTLE_DEV_DISABLE_GIT_GEM"]
25
+ # Ruby 2.3 compatibility: String#match? was added in 2.4; use Regexp#=== / =~ instead
26
+ disable_gem = env_val && !!(/\A(1|true|yes)\z/i =~ env_val)
27
+ if disable_gem
28
+ @backend = :cli
29
+ else
30
+ Kernel.require "git"
31
+ @backend = :gem
32
+ @git = ::Git.open(Dir.pwd)
33
+ end
34
+ rescue LoadError
35
+ # Optional dependency: fall back to CLI
36
+ @backend = :cli
37
+ rescue StandardError => e
38
+ raise Kettle::Dev::Error, "Failed to open git repository: #{e.message}"
39
+ end
40
+ end
41
+
42
+ # Push a branch to a remote.
43
+ # @param remote [String, nil] remote name (nil means default remote)
44
+ # @param branch [String] branch name (required)
45
+ # @param force [Boolean] whether to force push
46
+ # @return [Boolean] true when the push is reported successful
47
+ def push(remote, branch, force: false)
48
+ if @backend == :gem
49
+ begin
50
+ if remote
51
+ @git.push(remote, branch, force: force)
52
+ else
53
+ # Default remote according to repo config
54
+ @git.push(nil, branch, force: force)
55
+ end
56
+ true
57
+ rescue StandardError
58
+ false
59
+ end
60
+ else
61
+ args = ["git", "push"]
62
+ args << "--force" if force
63
+ if remote
64
+ args << remote.to_s << branch.to_s
65
+ end
66
+ system(*args)
67
+ end
68
+ end
69
+
70
+ # @return [String, nil] current branch name, or nil on error
71
+ def current_branch
72
+ if @backend == :gem
73
+ @git.current_branch
74
+ else
75
+ out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
76
+ status.success? ? out.strip : nil
77
+ end
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ # @return [Array<String>] list of remote names
83
+ def remotes
84
+ if @backend == :gem
85
+ @git.remotes.map(&:name)
86
+ else
87
+ out, status = Open3.capture2("git", "remote")
88
+ status.success? ? out.split(/\r?\n/).map(&:strip).reject(&:empty?) : []
89
+ end
90
+ rescue StandardError
91
+ []
92
+ end
93
+
94
+ # @return [Hash{String=>String}] remote name => fetch URL
95
+ def remotes_with_urls
96
+ if @backend == :gem
97
+ @git.remotes.each_with_object({}) do |r, h|
98
+ begin
99
+ h[r.name] = r.url
100
+ rescue StandardError
101
+ # ignore
102
+ end
103
+ end
104
+ else
105
+ out, status = Open3.capture2("git", "remote", "-v")
106
+ return {} unless status.success?
107
+ urls = {}
108
+ out.each_line do |line|
109
+ # Example: origin https://github.com/me/repo.git (fetch)
110
+ if line =~ /^(\S+)\s+(\S+)\s+\(fetch\)/
111
+ urls[Regexp.last_match(1)] = Regexp.last_match(2)
112
+ end
113
+ end
114
+ urls
115
+ end
116
+ rescue StandardError
117
+ {}
118
+ end
119
+
120
+ # @param name [String]
121
+ # @return [String, nil]
122
+ def remote_url(name)
123
+ if @backend == :gem
124
+ r = @git.remotes.find { |x| x.name == name }
125
+ r&.url
126
+ else
127
+ out, status = Open3.capture2("git", "config", "--get", "remote.#{name}.url")
128
+ status.success? ? out.strip : nil
129
+ end
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ # Checkout the given branch
135
+ # @param branch [String]
136
+ # @return [Boolean]
137
+ def checkout(branch)
138
+ if @backend == :gem
139
+ @git.checkout(branch)
140
+ true
141
+ else
142
+ system("git", "checkout", branch.to_s)
143
+ end
144
+ rescue StandardError
145
+ false
146
+ end
147
+
148
+ # Pull from a remote/branch
149
+ # @param remote [String]
150
+ # @param branch [String]
151
+ # @return [Boolean]
152
+ def pull(remote, branch)
153
+ if @backend == :gem
154
+ @git.pull(remote, branch)
155
+ true
156
+ else
157
+ system("git", "pull", remote.to_s, branch.to_s)
158
+ end
159
+ rescue StandardError
160
+ false
161
+ end
162
+
163
+ # Fetch a ref from a remote (or everything if ref is nil)
164
+ # @param remote [String]
165
+ # @param ref [String, nil]
166
+ # @return [Boolean]
167
+ def fetch(remote, ref = nil)
168
+ if @backend == :gem
169
+ if ref
170
+ @git.fetch(remote, ref)
171
+ else
172
+ @git.fetch(remote)
173
+ end
174
+ true
175
+ elsif ref
176
+ system("git", "fetch", remote.to_s, ref.to_s)
177
+ else
178
+ system("git", "fetch", remote.to_s)
179
+ end
180
+ rescue StandardError
181
+ false
182
+ end
183
+ end
184
+ end
185
+ 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
+ Kettle::Dev::ExitAdapter.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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Dev
5
+ # Input indirection layer to make interactive prompts safe in tests.
6
+ #
7
+ # Production/default behavior delegates to $stdin.gets (or Kernel#gets)
8
+ # so application code does not read from STDIN directly. In specs, mock
9
+ # this adapter's methods to return deterministic answers without touching
10
+ # global IO.
11
+ #
12
+ # Example (RSpec):
13
+ # allow(Kettle::Dev::InputAdapter).to receive(:gets).and_return("y\n")
14
+ #
15
+ # This mirrors the "mockable adapter" approach used for GitAdapter and ExitAdapter.
16
+ module InputAdapter
17
+ module_function
18
+
19
+ # Read one line from the standard input, including the trailing newline if
20
+ # present. Returns nil on EOF, consistent with IO#gets.
21
+ #
22
+ # @param args [Array] any args are forwarded to $stdin.gets for compatibility
23
+ # @return [String, nil]
24
+ def gets(*args)
25
+ $stdin.gets(*args)
26
+ end
27
+
28
+ # Read one line from standard input, raising EOFError on end-of-file.
29
+ # Provided for convenience symmetry with IO#readline when needed.
30
+ #
31
+ # @param args [Array]
32
+ # @return [String]
33
+ def readline(*args)
34
+ line = gets(*args)
35
+ raise EOFError, "end of file reached" if line.nil?
36
+ line
37
+ end
38
+ end
39
+ end
40
+ 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"