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
data/Rakefile CHANGED
@@ -1,22 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Galtzo FLOSS Rakefile v1.0.11 - 2025-08-19
3
+ # kettle-dev Rakefile v1.0.0 - 2025-08-23
4
4
  # Ruby 2.3 (Safe Navigation) or higher required
5
5
  #
6
- # CHANGELOG
7
- # v1.0.0 - initial release w/ support for rspec, minitest, rubocop, reek, yard, and stone_checksums
8
- # v1.0.1 - fix test / spec tasks running 2x
9
- # v1.0.2 - fix duplicate task warning from RuboCop
10
- # v1.0.3 - add bench tasks to run mini benchmarks (add scripts to /benchmarks)
11
- # v1.0.4 - add support for floss_funding:install
12
- # v1.0.5 - add support for halting in Rake tasks with binding.b (from debug gem)
13
- # v1.0.6 - add RBS files and checksums to YARD-generated docs site
14
- # v1.0.7 - works with vanilla ruby, non-gem, bundler-managed, projects
15
- # v1.0.8 - improved Dir globs, add back and document rbconfig dependency
16
- # v1.0.9 - add appraisal:update task to update Appraisal gemfiles and autocorrect with RuboCop Gradual
17
- # v1.0.10 - add ci:act to run GHA workflows locally, and get status of remote workflows
18
- # v1.0.11 - ci:act workflows are populated entirely dynamically, based on existing files
19
- #
20
6
  # MIT License (see License.txt)
21
7
  #
22
8
  # Copyright (c) 2025 Peter H. Boling (galtzo.com)
@@ -25,38 +11,51 @@
25
11
  #
26
12
  # Sets up tasks for appraisal, floss_funding, rspec, minitest, rubocop, reek, yard, and stone_checksums.
27
13
  #
28
- # rake appraisal:update # Update Appraisal gemfiles and run RuboCop Gradual autocorrect
29
- # rake bench # Run all benchmarks (alias for bench:run)
30
- # rake bench:list # List available benchmark scripts
31
- # rake bench:run # Run all benchmark scripts (skips on CI)
32
- # rake build # Build gitmoji-regex-1.0.2.gem into the pkg directory
33
- # rake build:checksum # Generate SHA512 checksum of gitmoji-regex-1.0.2.gem into the checksums directory
34
- # rake build:generate_checksums # Generate both SHA256 & SHA512 checksums into the checksums directory, and git...
35
- # rake bundle:audit:check # Checks the Gemfile.lock for insecure dependencies
36
- # rake bundle:audit:update # Updates the bundler-audit vulnerability database
37
- # rake ci:act[opt] # Run 'act' with a selected workflow
38
- # rake clean # Remove any temporary products
39
- # rake clobber # Remove any generated files
40
- # rake coverage # Run specs w/ coverage and open results in browser
41
- # rake floss_funding:install # (stub) floss_funding is unavailable
42
- # rake install # Build and install gitmoji-regex-1.0.2.gem into system gems
43
- # rake install:local # Build and install gitmoji-regex-1.0.2.gem into system gems without network ac...
44
- # rake reek # Check for code smells
45
- # rake reek:update # Run reek and store the output into the REEK file
46
- # rake release[remote] # Create tag v1.0.2 and build and push gitmoji-regex-1.0.2.gem to rubygems.org
47
- # rake rubocop # alias rubocop task to rubocop_gradual
48
- # rake rubocop_gradual # Run RuboCop Gradual
49
- # rake rubocop_gradual:autocorrect # Run RuboCop Gradual with autocorrect (only when it's safe)
50
- # rake rubocop_gradual:autocorrect_all # Run RuboCop Gradual with autocorrect (safe and unsafe)
51
- # rake rubocop_gradual:check # Run RuboCop Gradual to check the lock file
52
- # rake rubocop_gradual:force_update # Run RuboCop Gradual to force update the lock file
53
- # rake spec # Run RSpec code examples
54
- # rake test # Run tests
55
- # rake yard # Generate YARD Documentation
14
+ # rake appraisal:update # Update Appraisal gemfiles and run RuboCop...
15
+ # rake bench # Run all benchmarks (alias for bench:run)
16
+ # rake bench:list # List available benchmark scripts
17
+ # rake bench:run # Run all benchmark scripts (skips on CI)
18
+ # rake build # Build kettle-dev-1.0.0.gem into the pkg d...
19
+ # rake build:checksum # Generate SHA512 checksum of kettle-dev-1....
20
+ # rake build:generate_checksums # Generate both SHA256 & SHA512 checksums i...
21
+ # rake bundle:audit:check # Checks the Gemfile.lock for insecure depe...
22
+ # rake bundle:audit:update # Updates the bundler-audit vulnerability d...
23
+ # rake ci:act[opt] # Run 'act' with a selected workflow
24
+ # rake clean # Remove any temporary products
25
+ # rake clobber # Remove any generated files
26
+ # rake coverage # Run specs w/ coverage and open results in...
27
+ # rake default # Default tasks aggregator
28
+ # rake install # Build and install kettle-dev-1.0.0.gem in...
29
+ # rake install:local # Build and install kettle-dev-1.0.0.gem in...
30
+ # rake kettle:dev:install # Install kettle-dev GitHub automation and ...
31
+ # rake kettle:dev:template # Template kettle-dev files into the curren...
32
+ # rake reek # Check for code smells
33
+ # rake reek:update # Run reek and store the output into the RE...
34
+ # rake release[remote] # Create tag v1.0.0 and build and push kett...
35
+ # rake rubocop_gradual # Run RuboCop Gradual
36
+ # rake rubocop_gradual:autocorrect # Run RuboCop Gradual with autocorrect (onl...
37
+ # rake rubocop_gradual:autocorrect_all # Run RuboCop Gradual with autocorrect (saf...
38
+ # rake rubocop_gradual:check # Run RuboCop Gradual to check the lock file
39
+ # rake rubocop_gradual:force_update # Run RuboCop Gradual to force update the l...
40
+ # rake rubocop_gradual_debug # Run RuboCop Gradual
41
+ # rake rubocop_gradual_debug:autocorrect # Run RuboCop Gradual with autocorrect (onl...
42
+ # rake rubocop_gradual_debug:autocorrect_all # Run RuboCop Gradual with autocorrect (saf...
43
+ # rake rubocop_gradual_debug:check # Run RuboCop Gradual to check the lock file
44
+ # rake rubocop_gradual_debug:force_update # Run RuboCop Gradual to force update the l...
45
+ # rake spec # Run RSpec code examples
46
+ # rake test # Run tests
47
+ # rake yard # Generate YARD Documentation
48
+ #
56
49
 
57
50
  # External gems
58
51
  require "bundler/gem_tasks" if !Dir[File.join(__dir__, "*.gemspec")].empty?
59
52
 
53
+ # Define a base default task early so other files can enhance it.
54
+ desc "Default tasks aggregator"
55
+ task :default do
56
+ puts "Default task complete."
57
+ end
58
+
60
59
  # Detect if the invoked task is spec/test to avoid eagerly requiring the library,
61
60
  # which would load code before SimpleCov can start (when running `rake spec`).
62
61
  invoked_tasks = Rake.application.top_level_tasks
@@ -74,22 +73,12 @@ if running_specs
74
73
  # If rspec isn't available, let it fail when the task is invoked
75
74
  end
76
75
  else
77
- require "kettle/dev"
78
-
79
- # Define a base default task early so other files can enhance it.
80
- desc "Default tasks aggregator"
81
- task :default do
82
- puts "Default task complete."
83
- end
84
-
85
- Kettle::Dev.install_tasks
76
+ require "kettle-dev"
86
77
 
87
78
  ### RELEASE TASKS
88
79
  # Setup stone_checksums
89
80
  begin
90
81
  require "stone_checksums"
91
-
92
- GemChecksums.install_tasks
93
82
  rescue LoadError
94
83
  desc("(stub) build:generate_checksums is unavailable")
95
84
  task("build:generate_checksums") do
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env ruby
2
- # vim: set syntax=ruby
2
+ # frozen_string_literal: true
3
3
 
4
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
4
+ # vim: set syntax=ruby
5
5
 
6
- require "rubygems"
6
+ # Immediate, unbuffered output
7
+ $stdout.sync = true
8
+ # Depending library or project must be using bundler
7
9
  require "bundler/setup"
8
10
 
9
11
  # Standard library
10
12
  require "erb"
11
13
 
14
+ require "kettle/dev"
15
+
12
16
  # ENV variable control (set in .envrc, or .env.local)
13
17
  # BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
14
18
  # FOOTER_APPEND = true/false append commit message footer
@@ -46,140 +50,4 @@ else
46
50
  # puts "No branch rule configured (set GIT_HOOK_BRANCH_VALIDATE=jira to enforce rules for jira style branch names)"
47
51
  end
48
52
 
49
- class GitCommitFooter
50
- # Prefer project-local .git-hooks (repo root), then fallback to global ~/.git-hooks
51
- NAME_ASSIGNMENT_REGEX = /\bname\s*=\s*(["'])([^"']+)\1/.freeze
52
- FOOTER_APPEND = ENV.fetch("GIT_HOOK_FOOTER_APPEND", "false").casecmp("true").zero?
53
- SENTINEL = ENV["GIT_HOOK_FOOTER_SENTINEL"] # No default to avoid accidental duplicate commit of a footer via ammended commits
54
- raise "Set GIT_HOOK_FOOTER_SENTINEL=<footer sentinel> in .env.local (e.g., '⚡️ A message from a fellow meat-based-AI ⚡️')" if FOOTER_APPEND && SENTINEL.nil? || SENTINEL.empty?
55
-
56
- class << self
57
- def git_toplevel
58
- toplevel = nil
59
- begin
60
- # 'git rev-parse --show-toplevel' returns the repo root when run anywhere inside the repo
61
- out = %x(git rev-parse --show-toplevel 2>/dev/null)
62
- toplevel = out.strip unless out.nil? || out.empty?
63
- rescue StandardError
64
- # ignore
65
- end
66
- toplevel
67
- end
68
-
69
- def local_hooks_dir
70
- top = git_toplevel
71
- return unless top && !top.empty?
72
- File.join(top, ".git-hooks")
73
- end
74
-
75
- def global_hooks_dir
76
- File.join(ENV["HOME"], ".git-hooks")
77
- end
78
-
79
- def hooks_path_for(filename)
80
- local_dir = local_hooks_dir
81
- if local_dir
82
- local_path = File.join(local_dir, filename)
83
- return local_path if File.file?(local_path)
84
- end
85
- File.join(global_hooks_dir, filename)
86
- end
87
-
88
- def commit_goalie_path
89
- hooks_path_for("commit-subjects-goalie.txt")
90
- end
91
-
92
- # Determine whether the commit subject allows footer append, based on optional goalie file
93
- # ~/.git-hooks/commit-subjects-goalie.txt
94
- # - If present, only allow appending when the first line of the commit message starts with one of the non-commented prefixes
95
- # - If absent, disallow footer
96
- def goalie_allows_footer?(subject_line)
97
- goalie_path = commit_goalie_path
98
- return false unless File.file?(goalie_path)
99
-
100
- prefixes = File.read(goalie_path).lines.map { |l| l.strip }.reject { |l| l.empty? || l.start_with?("#") }
101
- # If the file exists but has no usable lines, treat as deny-all per goalie intent
102
- return false if prefixes.empty?
103
-
104
- subj = subject_line.to_s.strip
105
- prefixes.any? { |prefix| subj.start_with?(prefix) }
106
- end
107
-
108
- def render(*argv)
109
- commit_msg = File.read(argv[0])
110
- subject_line = commit_msg.lines.first.to_s
111
- if GitCommitFooter::FOOTER_APPEND && goalie_allows_footer?(subject_line)
112
- if commit_msg.include?(GitCommitFooter::SENTINEL)
113
- # This is a commit message that has already been appended
114
- # This will happen if the commit message is edited and re-committed
115
- # puts "FOOTER_APPEND is true, skipping footer append"
116
- exit(0)
117
- else
118
- footer_binding = GitCommitFooter.new
119
- # Append footer to the commit message
120
- File.open(argv[0], "w") do |file|
121
- file.print(commit_msg)
122
- file.print("\n")
123
- file.print(footer_binding.render)
124
- end
125
- end
126
- else
127
- # Skipping footer append (either FOOTER_APPEND is false, or goalie did not allow it)
128
- end
129
- end
130
- end
131
-
132
- def initialize
133
- @pwd = Dir.pwd
134
- @gemspecs = Dir["*.gemspec"]
135
- @spec = @gemspecs.first
136
- @gemspec_path = File.expand_path(@spec, @pwd)
137
- @gem_name = parse_gemspec_name || derive_gem_name
138
- end
139
-
140
- # Render ERB with binding variables
141
- def render
142
- ERB.new(template).result(binding)
143
- end
144
-
145
- private
146
-
147
- # Lightweight parse for gem name to avoid full Gem::Specification load
148
- def parse_gemspec_name
149
- begin
150
- content = File.read(@gemspec_path)
151
- # Look for name assignment patterns like:
152
- # spec.name = "my_gem" OR Gem::Specification.new do |spec|; spec.name = 'my_gem'
153
- @name_index = content =~ NAME_ASSIGNMENT_REGEX
154
- if @name_index
155
- return $2
156
- end
157
- rescue StandardError
158
- # fall through
159
- end
160
- nil
161
- end
162
-
163
- # No-parse derivation of gem name, when parsing gemspec fails
164
- def derive_gem_name
165
- File.basename(@gemspec_path, ".*") if @gemspec_path
166
- end
167
-
168
- # Example
169
- #
170
- # ⚡️ A message from a fellow meat-based-AI ⚡️
171
- # I ❤️ working on <%= @gem_name %>.
172
- #
173
- # The first line is the footer sentinel (which does appear in the commit).
174
- # The second line, and any additional, is the main body of the footer.
175
- #
176
- # The sentinel must be set in an ENV variable (e.g., in your .env.local file):
177
- #
178
- # export GIT_HOOK_FOOTER_SENTINEL="⚡️ A message from a fellow meat-based-AI ⚡️"
179
- #
180
- def template
181
- File.read(self.class.hooks_path_for("footer-template.erb.txt"))
182
- end
183
- end
184
-
185
- GitCommitFooter.render(*ARGV)
53
+ Kettle::Dev::GitCommitFooter.render(*ARGV)
@@ -1,355 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Updates README.md backers and sponsors sections using data from Open Collective
5
- # backers.json and sponsors.json for the configured handle.
6
- #
7
- # Backers (individuals) section markers supported (first match wins):
8
- # <!-- OPENCOLLECTIVE:START --> ... <!-- OPENCOLLECTIVE:END -->
9
- # <!-- OPENCOLLECTIVE-INDIVIDUALS:START --> ... <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
10
- # Sponsors (organizations) section markers:
11
- # <!-- OPENCOLLECTIVE-ORGANIZATIONS:START --> ... <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
12
- #
13
- # Handle resolution order:
14
- # 1. ENV["OPENCOLLECTIVE_HANDLE"] if present
15
- # 2. .opencollective.yml's `collective:` key in project root if present
16
- # 3. Abort with error
17
- #
18
- # Usage:
19
- # OPENCOLLECTIVE_HANDLE=kettle-rb exe/kettle-readme-backers
20
- # # or ensure .opencollective.yml exists with collective: "kettle-rb"
4
+ # vim: set syntax=ruby
21
5
 
22
- require "rubygems"
6
+ # Immediate, unbuffered output
7
+ $stdout.sync = true
8
+ # Ensure bundler is set up by the host project
23
9
  require "bundler/setup"
24
10
 
25
- require "yaml"
26
- require "json"
27
- require "uri"
28
- require "net/http"
29
- require "set"
11
+ require "kettle/dev"
30
12
 
31
- module Kettle
32
- module Dev
33
- class ReadmeBackers
34
- DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
35
- README_PATH = File.expand_path("../README.md", __dir__)
36
- OC_YML_PATH = File.expand_path("../.opencollective.yml", __dir__)
37
- README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
38
- COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
39
-
40
- Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
41
-
42
- def initialize(handle: nil, readme_path: README_PATH)
43
- @handle = handle || resolve_handle
44
- @readme_path = readme_path
45
- end
46
-
47
- def run!
48
- readme = File.read(@readme_path)
49
-
50
- # Identify previous entries for diffing/mentions
51
- b_start, b_end = detect_backer_tags(readme)
52
- prev_backer_identities = extract_section_identities(readme, b_start, b_end)
53
- s_start_prev, s_end_prev = detect_sponsor_tags(readme)
54
- prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
55
-
56
- # Backers (individuals)
57
- backers = fetch_members("backers.json")
58
- backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
59
- updated = replace_between_tags(readme, b_start, b_end, backers_md)
60
- case updated
61
- when :not_found
62
- # Do not exit yet; we may still update sponsors.
63
- updated_readme = readme
64
- backers_changed = false
65
- new_backers = []
66
- when :no_change
67
- updated_readme = readme
68
- backers_changed = false
69
- new_backers = []
70
- else
71
- updated_readme = updated
72
- backers_changed = true
73
- new_backers = compute_new_members(prev_backer_identities, backers)
74
- end
75
-
76
- # Sponsors (organizations)
77
- sponsors = fetch_members("sponsors.json")
78
- sponsors_md = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
79
- s_start, s_end = detect_sponsor_tags(updated_readme)
80
- updated2 = replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
81
- case updated2
82
- when :not_found
83
- sponsors_changed = false
84
- final = updated_readme
85
- new_sponsors = []
86
- when :no_change
87
- sponsors_changed = false
88
- final = updated_readme
89
- new_sponsors = []
90
- else
91
- sponsors_changed = true
92
- final = updated2
93
- new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
94
- end
95
-
96
- if !backers_changed && !sponsors_changed
97
- if b_start == :not_found && s_start == :not_found
98
- ts = tag_strings
99
- warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
100
- "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
101
- exit(2)
102
- end
103
- puts "No changes to backers or sponsors sections in #{@readme_path}."
104
- return
105
- end
106
-
107
- File.write(@readme_path, final)
108
- msgs = []
109
- msgs << "backers" if backers_changed
110
- msgs << "sponsors" if sponsors_changed
111
- puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."
112
-
113
- # Compose and perform commit with mentions if in a git repo
114
- perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)
115
- end
116
-
117
- private
118
-
119
- def readme_osc_tag
120
- env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
121
- return env unless env.strip.empty?
122
- if File.file?(OC_YML_PATH)
123
- begin
124
- yml = YAML.safe_load(File.read(OC_YML_PATH))
125
- if yml.is_a?(Hash)
126
- from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
127
- from_yml = from_yml.to_s if from_yml
128
- return from_yml unless from_yml.nil? || from_yml.strip.empty?
129
- end
130
- rescue StandardError
131
- # ignore yaml errors and fall back
132
- end
133
- end
134
- README_OSC_TAG_DEFAULT
135
- end
136
-
137
- def tag_strings
138
- base = readme_osc_tag
139
- {
140
- generic_start: "<!-- #{base}:START -->",
141
- generic_end: "<!-- #{base}:END -->",
142
- individuals_start: "<!-- #{base}-INDIVIDUALS:START -->",
143
- individuals_end: "<!-- #{base}-INDIVIDUALS:END -->",
144
- orgs_start: "<!-- #{base}-ORGANIZATIONS:START -->",
145
- orgs_end: "<!-- #{base}-ORGANIZATIONS:END -->",
146
- }
147
- end
148
-
149
- def resolve_handle
150
- env = ENV["OPENCOLLECTIVE_HANDLE"]
151
- return env unless env.nil? || env.strip.empty?
152
- if File.file?(OC_YML_PATH)
153
- yml = YAML.safe_load(File.read(OC_YML_PATH))
154
- handle = yml.is_a?(Hash) ? yml["collective"] || yml[:collective] : nil
155
- return handle.to_s unless handle.nil? || handle.to_s.strip.empty?
156
- end
157
- abort("ERROR: Open Collective handle not provided. Set OPENCOLLECTIVE_HANDLE or add 'collective: <handle>' to .opencollective.yml.")
158
- end
159
-
160
- def fetch_members(path)
161
- url = URI("https://opencollective.com/#{@handle}/#{path}")
162
- response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |conn|
163
- conn.read_timeout = 10
164
- conn.open_timeout = 5
165
- req = Net::HTTP::Get.new(url)
166
- req["User-Agent"] = "kettle-dev/README-backers"
167
- conn.request(req)
168
- end
169
- return [] unless response.is_a?(Net::HTTPSuccess)
170
- parsed = JSON.parse(response.body)
171
- Array(parsed).map do |h|
172
- Backer.new(
173
- name: h["name"],
174
- image: (h["image"].to_s.strip.empty? ? nil : h["image"]),
175
- website: (h["website"].to_s.strip.empty? ? nil : h["website"]),
176
- profile: (h["profile"].to_s.strip.empty? ? nil : h["profile"]),
177
- )
178
- end
179
- rescue JSON::ParserError => e
180
- warn("Error parsing #{path} JSON: #{e.message}")
181
- []
182
- rescue StandardError => e
183
- warn("Error fetching #{path}: #{e.class}: #{e.message}")
184
- []
185
- end
186
-
187
- def generate_markdown(members, empty_message:, default_name:)
188
- return empty_message if members.nil? || members.empty?
189
- members.map do |m|
190
- image_url = m.image || DEFAULT_AVATAR
191
- link = m.website || m.profile || "#"
192
- name = (m.name && !m.name.strip.empty?) ? m.name : default_name
193
- "[![#{escape_text(name)}](#{image_url})](#{link})"
194
- end.join(" ")
195
- end
196
-
197
- def replace_between_tags(content, start_tag, end_tag, new_content)
198
- return :not_found if start_tag == :not_found || end_tag == :not_found
199
- start_index = content.index(start_tag)
200
- end_index = content.index(end_tag)
201
- return :not_found if start_index.nil? || end_index.nil? || end_index < start_index
202
- before = content[0..start_index + start_tag.length - 1]
203
- after = content[end_index..-1]
204
- replacement = "#{start_tag}\n#{new_content}\n#{end_tag}"
205
- current_block = content[start_index..end_index + end_tag.length - 1]
206
- return :no_change if current_block == replacement
207
- trailing = after[end_tag.length..-1] || ""
208
- "#{before}\n#{new_content}\n#{end_tag}#{trailing}"
209
- end
210
-
211
- def detect_backer_tags(content)
212
- ts = tag_strings
213
- if content.include?(ts[:generic_start]) && content.include?(ts[:generic_end])
214
- [ts[:generic_start], ts[:generic_end]]
215
- elsif content.include?(ts[:individuals_start]) && content.include?(ts[:individuals_end])
216
- [ts[:individuals_start], ts[:individuals_end]]
217
- else
218
- [:not_found, :not_found]
219
- end
220
- end
221
-
222
- def detect_sponsor_tags(content)
223
- ts = tag_strings
224
- if content.include?(ts[:orgs_start]) && content.include?(ts[:orgs_end])
225
- [ts[:orgs_start], ts[:orgs_end]]
226
- else
227
- [:not_found, :not_found]
228
- end
229
- end
230
-
231
- # Extract identity tokens from the current README section between start/end tags
232
- # Identity priority used for comparison:
233
- # href (profile/website URL) downcased, else alt text (name) downcased
234
- def extract_section_identities(content, start_tag, end_tag)
235
- return Set.new unless start_tag && end_tag && start_tag != :not_found && end_tag != :not_found
236
- start_index = content.index(start_tag)
237
- end_index = content.index(end_tag)
238
- return Set.new if start_index.nil? || end_index.nil? || end_index < start_index
239
- block = content[(start_index + start_tag.length)...end_index]
240
- identities = Set.new
241
- # Match patterns like: [![Alt](image_url)](link_url)
242
- block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
243
- href = (m[0] || "").strip
244
- identities << href.downcase unless href.empty?
245
- end
246
- # Also capture alt texts in case links are missing
247
- block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
248
- alt = (m[0] || "").strip
249
- identities << alt.downcase unless alt.empty?
250
- end
251
- identities
252
- end
253
-
254
- def compute_new_members(previous_identities, members)
255
- prev = previous_identities || Set.new
256
- members.select do |m|
257
- id = identity_for_member(m)
258
- !prev.include?(id)
259
- end
260
- end
261
-
262
- def identity_for_member(m)
263
- if m.profile && !m.profile.strip.empty?
264
- m.profile.strip.downcase
265
- elsif m.website && !m.website.strip.empty?
266
- m.website.strip.downcase
267
- elsif m.name && !m.name.strip.empty?
268
- m.name.strip.downcase
269
- else
270
- ""
271
- end
272
- end
273
-
274
- def mention_for_member(m, default_name: "Member")
275
- handle = github_handle_from_urls(m.profile, m.website)
276
- return "@#{handle}" if handle
277
- name = (m.name && !m.name.strip.empty?) ? m.name.strip : default_name
278
- name
279
- end
280
-
281
- def github_handle_from_urls(*urls)
282
- urls.compact.each do |u|
283
- begin
284
- uri = URI.parse(u)
285
- rescue URI::InvalidURIError
286
- next
287
- end
288
- next unless uri&.host&.downcase&.end_with?("github.com")
289
- path = (uri.path || "").sub(%r{^/}, "").sub(%r{/$}, "")
290
- next if path.empty?
291
- parts = path.split("/")
292
- # github.com/sponsors/<handle> or github.com/<handle>/...
293
- candidate = if parts[0].downcase == "sponsors" && parts[1]
294
- parts[1]
295
- else
296
- parts[0]
297
- end
298
- candidate = candidate.gsub(%r{[^a-zA-Z0-9-]}, "")
299
- return candidate unless candidate.empty?
300
- end
301
- nil
302
- end
303
-
304
- def perform_git_commit(new_backers, new_sponsors)
305
- backer_mentions = new_backers.map { |m| mention_for_member(m, default_name: "Backer") }.uniq
306
- sponsor_mentions = new_sponsors.map { |m| mention_for_member(m, default_name: "Subscriber") }.uniq
307
- title = commit_subject
308
- lines = [title]
309
- lines << ""
310
- lines << "Backers: #{backer_mentions.join(", ")}" unless backer_mentions.empty?
311
- lines << "Subscribers: #{sponsor_mentions.join(", ")}" unless sponsor_mentions.empty?
312
- message = lines.join("\n")
313
- # Stage and commit README.md
314
- system("git", "add", @readme_path)
315
- # Only commit if README is staged/changed
316
- if system("git", "diff", "--cached", "--quiet")
317
- # nothing staged; skip commit
318
- return
319
- end
320
- system("git", "commit", "-m", message)
321
- end
322
-
323
- def commit_subject
324
- env = ENV["KETTLE_README_BACKERS_COMMIT_SUBJECT"].to_s
325
- return env unless env.strip.empty?
326
- # Fallback to .opencollective.yml key: readme-backers-commit-subject
327
- if File.file?(OC_YML_PATH)
328
- begin
329
- yml = YAML.safe_load(File.read(OC_YML_PATH))
330
- if yml.is_a?(Hash)
331
- from_yml = yml["readme-backers-commit-subject"] || yml[:"readme-backers-commit-subject"]
332
- from_yml = from_yml.to_s if from_yml
333
- return from_yml unless from_yml.nil? || from_yml.strip.empty?
334
- end
335
- rescue StandardError
336
- # ignore yaml read errors and fall back to default
337
- end
338
- end
339
- COMMIT_SUBJECT_DEFAULT
340
- end
341
-
342
- def git_repo?
343
- system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
344
- end
345
-
346
- def escape_text(text)
347
- text.gsub("[", "\\[").gsub("]", "\\]")
348
- end
349
- end
350
- end
351
- end
352
-
353
- if __FILE__ == $PROGRAM_NAME
354
- Kettle::Dev::ReadmeBackers.new.run!
355
- end
13
+ Kettle::Dev::ReadmeBackers.new.run!