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
data/Rakefile
CHANGED
@@ -1,22 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
#
|
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
|
29
|
-
# rake bench
|
30
|
-
# rake bench:list
|
31
|
-
# rake bench:run
|
32
|
-
# rake build
|
33
|
-
# rake build:checksum
|
34
|
-
# rake build:generate_checksums
|
35
|
-
# rake bundle:audit:check
|
36
|
-
# rake bundle:audit:update
|
37
|
-
# rake ci:act[opt]
|
38
|
-
# rake clean
|
39
|
-
# rake clobber
|
40
|
-
# rake coverage
|
41
|
-
# rake
|
42
|
-
# rake install
|
43
|
-
# rake install:local
|
44
|
-
# rake
|
45
|
-
# rake
|
46
|
-
# rake
|
47
|
-
# rake
|
48
|
-
# rake
|
49
|
-
# rake rubocop_gradual
|
50
|
-
# rake rubocop_gradual:
|
51
|
-
# rake rubocop_gradual:
|
52
|
-
# rake rubocop_gradual:
|
53
|
-
# rake
|
54
|
-
# rake
|
55
|
-
# rake
|
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
|
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
|
data/exe/kettle-commit-msg
CHANGED
@@ -1,14 +1,18 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
#
|
2
|
+
# frozen_string_literal: true
|
3
3
|
|
4
|
-
|
4
|
+
# vim: set syntax=ruby
|
5
5
|
|
6
|
-
|
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
|
-
|
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)
|
data/exe/kettle-readme-backers
CHANGED
@@ -1,355 +1,13 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
-
#
|
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
|
-
|
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 "
|
26
|
-
require "json"
|
27
|
-
require "uri"
|
28
|
-
require "net/http"
|
29
|
-
require "set"
|
11
|
+
require "kettle/dev"
|
30
12
|
|
31
|
-
|
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
|
-
"[](#{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: [](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!
|