kettle-dev 1.0.9 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.junie/guidelines.md +4 -3
  6. data/.simplecov +5 -1
  7. data/Appraisals +3 -0
  8. data/CHANGELOG.md +22 -1
  9. data/CONTRIBUTING.md +6 -0
  10. data/README.md +18 -5
  11. data/Rakefile +7 -11
  12. data/exe/kettle-commit-msg +9 -143
  13. data/exe/kettle-readme-backers +7 -353
  14. data/exe/kettle-release +8 -702
  15. data/lib/kettle/dev/ci_helpers.rb +1 -0
  16. data/lib/kettle/dev/commit_msg.rb +39 -0
  17. data/lib/kettle/dev/exit_adapter.rb +36 -0
  18. data/lib/kettle/dev/git_adapter.rb +120 -0
  19. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  20. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  21. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  22. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  23. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  24. data/lib/kettle/dev/rakelib/install.rake +1 -295
  25. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  26. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  27. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  28. data/lib/kettle/dev/rakelib/template.rake +3 -465
  29. data/lib/kettle/dev/readme_backers.rb +340 -0
  30. data/lib/kettle/dev/release_cli.rb +672 -0
  31. data/lib/kettle/dev/tasks/ci_task.rb +334 -0
  32. data/lib/kettle/dev/tasks/install_task.rb +298 -0
  33. data/lib/kettle/dev/tasks/template_task.rb +491 -0
  34. data/lib/kettle/dev/template_helpers.rb +4 -4
  35. data/lib/kettle/dev/version.rb +1 -1
  36. data/lib/kettle/dev.rb +30 -1
  37. data/lib/kettle-dev.rb +2 -3
  38. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  39. data/sig/kettle/dev/commit_msg.rbs +8 -0
  40. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  41. data/sig/kettle/dev/git_adapter.rbs +15 -0
  42. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  43. data/sig/kettle/dev/readme_backers.rbs +20 -0
  44. data/sig/kettle/dev/release_cli.rbs +8 -0
  45. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  46. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  47. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  48. data/sig/kettle/dev/tasks.rbs +0 -0
  49. data/sig/kettle/dev/version.rbs +0 -0
  50. data/sig/kettle/emoji_regex.rbs +5 -0
  51. data/sig/kettle-dev.rbs +0 -0
  52. data.tar.gz.sig +0 -0
  53. metadata +55 -5
  54. metadata.gz.sig +0 -0
@@ -1,359 +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"
23
- begin
24
- require "bundler/setup"
25
- rescue LoadError
26
- # Allow running outside of Bundler; runtime deps should still be available via rubygems
27
- end
6
+ # Immediate, unbuffered output
7
+ $stdout.sync = true
8
+ # Ensure bundler is set up by the host project
9
+ require "bundler/setup"
28
10
 
29
- require "yaml"
30
- require "json"
31
- require "uri"
32
- require "net/http"
33
- require "set"
11
+ require "kettle/dev"
34
12
 
35
- module Kettle
36
- module Dev
37
- class ReadmeBackers
38
- DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
39
- README_PATH = File.expand_path("../README.md", __dir__)
40
- OC_YML_PATH = File.expand_path("../.opencollective.yml", __dir__)
41
- README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
42
- COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
43
-
44
- Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
45
-
46
- def initialize(handle: nil, readme_path: README_PATH)
47
- @handle = handle || resolve_handle
48
- @readme_path = readme_path
49
- end
50
-
51
- def run!
52
- readme = File.read(@readme_path)
53
-
54
- # Identify previous entries for diffing/mentions
55
- b_start, b_end = detect_backer_tags(readme)
56
- prev_backer_identities = extract_section_identities(readme, b_start, b_end)
57
- s_start_prev, s_end_prev = detect_sponsor_tags(readme)
58
- prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
59
-
60
- # Backers (individuals)
61
- backers = fetch_members("backers.json")
62
- backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
63
- updated = replace_between_tags(readme, b_start, b_end, backers_md)
64
- case updated
65
- when :not_found
66
- # Do not exit yet; we may still update sponsors.
67
- updated_readme = readme
68
- backers_changed = false
69
- new_backers = []
70
- when :no_change
71
- updated_readme = readme
72
- backers_changed = false
73
- new_backers = []
74
- else
75
- updated_readme = updated
76
- backers_changed = true
77
- new_backers = compute_new_members(prev_backer_identities, backers)
78
- end
79
-
80
- # Sponsors (organizations)
81
- sponsors = fetch_members("sponsors.json")
82
- sponsors_md = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
83
- s_start, s_end = detect_sponsor_tags(updated_readme)
84
- updated2 = replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
85
- case updated2
86
- when :not_found
87
- sponsors_changed = false
88
- final = updated_readme
89
- new_sponsors = []
90
- when :no_change
91
- sponsors_changed = false
92
- final = updated_readme
93
- new_sponsors = []
94
- else
95
- sponsors_changed = true
96
- final = updated2
97
- new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
98
- end
99
-
100
- if !backers_changed && !sponsors_changed
101
- if b_start == :not_found && s_start == :not_found
102
- ts = tag_strings
103
- warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
104
- "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
105
- exit(2)
106
- end
107
- puts "No changes to backers or sponsors sections in #{@readme_path}."
108
- return
109
- end
110
-
111
- File.write(@readme_path, final)
112
- msgs = []
113
- msgs << "backers" if backers_changed
114
- msgs << "sponsors" if sponsors_changed
115
- puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."
116
-
117
- # Compose and perform commit with mentions if in a git repo
118
- perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)
119
- end
120
-
121
- private
122
-
123
- def readme_osc_tag
124
- env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
125
- return env unless env.strip.empty?
126
- if File.file?(OC_YML_PATH)
127
- begin
128
- yml = YAML.safe_load(File.read(OC_YML_PATH))
129
- if yml.is_a?(Hash)
130
- from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
131
- from_yml = from_yml.to_s if from_yml
132
- return from_yml unless from_yml.nil? || from_yml.strip.empty?
133
- end
134
- rescue StandardError
135
- # ignore yaml errors and fall back
136
- end
137
- end
138
- README_OSC_TAG_DEFAULT
139
- end
140
-
141
- def tag_strings
142
- base = readme_osc_tag
143
- {
144
- generic_start: "<!-- #{base}:START -->",
145
- generic_end: "<!-- #{base}:END -->",
146
- individuals_start: "<!-- #{base}-INDIVIDUALS:START -->",
147
- individuals_end: "<!-- #{base}-INDIVIDUALS:END -->",
148
- orgs_start: "<!-- #{base}-ORGANIZATIONS:START -->",
149
- orgs_end: "<!-- #{base}-ORGANIZATIONS:END -->",
150
- }
151
- end
152
-
153
- def resolve_handle
154
- env = ENV["OPENCOLLECTIVE_HANDLE"]
155
- return env unless env.nil? || env.strip.empty?
156
- if File.file?(OC_YML_PATH)
157
- yml = YAML.safe_load(File.read(OC_YML_PATH))
158
- handle = yml.is_a?(Hash) ? yml["collective"] || yml[:collective] : nil
159
- return handle.to_s unless handle.nil? || handle.to_s.strip.empty?
160
- end
161
- abort("ERROR: Open Collective handle not provided. Set OPENCOLLECTIVE_HANDLE or add 'collective: <handle>' to .opencollective.yml.")
162
- end
163
-
164
- def fetch_members(path)
165
- url = URI("https://opencollective.com/#{@handle}/#{path}")
166
- response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |conn|
167
- conn.read_timeout = 10
168
- conn.open_timeout = 5
169
- req = Net::HTTP::Get.new(url)
170
- req["User-Agent"] = "kettle-dev/README-backers"
171
- conn.request(req)
172
- end
173
- return [] unless response.is_a?(Net::HTTPSuccess)
174
- parsed = JSON.parse(response.body)
175
- Array(parsed).map do |h|
176
- Backer.new(
177
- name: h["name"],
178
- image: (h["image"].to_s.strip.empty? ? nil : h["image"]),
179
- website: (h["website"].to_s.strip.empty? ? nil : h["website"]),
180
- profile: (h["profile"].to_s.strip.empty? ? nil : h["profile"]),
181
- )
182
- end
183
- rescue JSON::ParserError => e
184
- warn("Error parsing #{path} JSON: #{e.message}")
185
- []
186
- rescue StandardError => e
187
- warn("Error fetching #{path}: #{e.class}: #{e.message}")
188
- []
189
- end
190
-
191
- def generate_markdown(members, empty_message:, default_name:)
192
- return empty_message if members.nil? || members.empty?
193
- members.map do |m|
194
- image_url = m.image || DEFAULT_AVATAR
195
- link = m.website || m.profile || "#"
196
- name = (m.name && !m.name.strip.empty?) ? m.name : default_name
197
- "[![#{escape_text(name)}](#{image_url})](#{link})"
198
- end.join(" ")
199
- end
200
-
201
- def replace_between_tags(content, start_tag, end_tag, new_content)
202
- return :not_found if start_tag == :not_found || end_tag == :not_found
203
- start_index = content.index(start_tag)
204
- end_index = content.index(end_tag)
205
- return :not_found if start_index.nil? || end_index.nil? || end_index < start_index
206
- before = content[0..start_index + start_tag.length - 1]
207
- after = content[end_index..-1]
208
- replacement = "#{start_tag}\n#{new_content}\n#{end_tag}"
209
- current_block = content[start_index..end_index + end_tag.length - 1]
210
- return :no_change if current_block == replacement
211
- trailing = after[end_tag.length..-1] || ""
212
- "#{before}\n#{new_content}\n#{end_tag}#{trailing}"
213
- end
214
-
215
- def detect_backer_tags(content)
216
- ts = tag_strings
217
- if content.include?(ts[:generic_start]) && content.include?(ts[:generic_end])
218
- [ts[:generic_start], ts[:generic_end]]
219
- elsif content.include?(ts[:individuals_start]) && content.include?(ts[:individuals_end])
220
- [ts[:individuals_start], ts[:individuals_end]]
221
- else
222
- [:not_found, :not_found]
223
- end
224
- end
225
-
226
- def detect_sponsor_tags(content)
227
- ts = tag_strings
228
- if content.include?(ts[:orgs_start]) && content.include?(ts[:orgs_end])
229
- [ts[:orgs_start], ts[:orgs_end]]
230
- else
231
- [:not_found, :not_found]
232
- end
233
- end
234
-
235
- # Extract identity tokens from the current README section between start/end tags
236
- # Identity priority used for comparison:
237
- # href (profile/website URL) downcased, else alt text (name) downcased
238
- def extract_section_identities(content, start_tag, end_tag)
239
- return Set.new unless start_tag && end_tag && start_tag != :not_found && end_tag != :not_found
240
- start_index = content.index(start_tag)
241
- end_index = content.index(end_tag)
242
- return Set.new if start_index.nil? || end_index.nil? || end_index < start_index
243
- block = content[(start_index + start_tag.length)...end_index]
244
- identities = Set.new
245
- # Match patterns like: [![Alt](image_url)](link_url)
246
- block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
247
- href = (m[0] || "").strip
248
- identities << href.downcase unless href.empty?
249
- end
250
- # Also capture alt texts in case links are missing
251
- block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
252
- alt = (m[0] || "").strip
253
- identities << alt.downcase unless alt.empty?
254
- end
255
- identities
256
- end
257
-
258
- def compute_new_members(previous_identities, members)
259
- prev = previous_identities || Set.new
260
- members.select do |m|
261
- id = identity_for_member(m)
262
- !prev.include?(id)
263
- end
264
- end
265
-
266
- def identity_for_member(m)
267
- if m.profile && !m.profile.strip.empty?
268
- m.profile.strip.downcase
269
- elsif m.website && !m.website.strip.empty?
270
- m.website.strip.downcase
271
- elsif m.name && !m.name.strip.empty?
272
- m.name.strip.downcase
273
- else
274
- ""
275
- end
276
- end
277
-
278
- def mention_for_member(m, default_name: "Member")
279
- handle = github_handle_from_urls(m.profile, m.website)
280
- return "@#{handle}" if handle
281
- name = (m.name && !m.name.strip.empty?) ? m.name.strip : default_name
282
- name
283
- end
284
-
285
- def github_handle_from_urls(*urls)
286
- urls.compact.each do |u|
287
- begin
288
- uri = URI.parse(u)
289
- rescue URI::InvalidURIError
290
- next
291
- end
292
- next unless uri&.host&.downcase&.end_with?("github.com")
293
- path = (uri.path || "").sub(%r{^/}, "").sub(%r{/$}, "")
294
- next if path.empty?
295
- parts = path.split("/")
296
- # github.com/sponsors/<handle> or github.com/<handle>/...
297
- candidate = if parts[0].downcase == "sponsors" && parts[1]
298
- parts[1]
299
- else
300
- parts[0]
301
- end
302
- candidate = candidate.gsub(%r{[^a-zA-Z0-9-]}, "")
303
- return candidate unless candidate.empty?
304
- end
305
- nil
306
- end
307
-
308
- def perform_git_commit(new_backers, new_sponsors)
309
- backer_mentions = new_backers.map { |m| mention_for_member(m, default_name: "Backer") }.uniq
310
- sponsor_mentions = new_sponsors.map { |m| mention_for_member(m, default_name: "Subscriber") }.uniq
311
- title = commit_subject
312
- lines = [title]
313
- lines << ""
314
- lines << "Backers: #{backer_mentions.join(", ")}" unless backer_mentions.empty?
315
- lines << "Subscribers: #{sponsor_mentions.join(", ")}" unless sponsor_mentions.empty?
316
- message = lines.join("\n")
317
- # Stage and commit README.md
318
- system("git", "add", @readme_path)
319
- # Only commit if README is staged/changed
320
- if system("git", "diff", "--cached", "--quiet")
321
- # nothing staged; skip commit
322
- return
323
- end
324
- system("git", "commit", "-m", message)
325
- end
326
-
327
- def commit_subject
328
- env = ENV["KETTLE_README_BACKERS_COMMIT_SUBJECT"].to_s
329
- return env unless env.strip.empty?
330
- # Fallback to .opencollective.yml key: readme-backers-commit-subject
331
- if File.file?(OC_YML_PATH)
332
- begin
333
- yml = YAML.safe_load(File.read(OC_YML_PATH))
334
- if yml.is_a?(Hash)
335
- from_yml = yml["readme-backers-commit-subject"] || yml[:"readme-backers-commit-subject"]
336
- from_yml = from_yml.to_s if from_yml
337
- return from_yml unless from_yml.nil? || from_yml.strip.empty?
338
- end
339
- rescue StandardError
340
- # ignore yaml read errors and fall back to default
341
- end
342
- end
343
- COMMIT_SUBJECT_DEFAULT
344
- end
345
-
346
- def git_repo?
347
- system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
348
- end
349
-
350
- def escape_text(text)
351
- text.gsub("[", "\\[").gsub("]", "\\]")
352
- end
353
- end
354
- end
355
- end
356
-
357
- if __FILE__ == $PROGRAM_NAME
358
- Kettle::Dev::ReadmeBackers.new.run!
359
- end
13
+ Kettle::Dev::ReadmeBackers.new.run!