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
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ # External stdlib
4
+ require "kettle/dev/exit_adapter"
5
+ require "yaml"
6
+ require "json"
7
+ require "uri"
8
+ require "net/http"
9
+ require "set"
10
+
11
+ module Kettle
12
+ module Dev
13
+ class ReadmeBackers
14
+ private
15
+
16
+ def abort(msg)
17
+ Kettle::Dev::ExitAdapter.abort(msg)
18
+ end
19
+
20
+ public
21
+
22
+ DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
23
+ README_PATH = File.expand_path("../../../../README.md", __dir__)
24
+ OC_YML_PATH = File.expand_path("../../../../.opencollective.yml", __dir__)
25
+ README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
26
+ COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
27
+
28
+ # Ruby 2.3 compatibility: Struct keyword_init added in Ruby 2.5
29
+ # Switch to struct when dropping ruby < 2.5
30
+ # Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
31
+ # Fallback for Ruby < 2.5 where Struct keyword_init is unsupported
32
+ class Backer
33
+ attr_accessor :name, :image, :website, :profile
34
+
35
+ def initialize(name: nil, image: nil, website: nil, profile: nil, **_ignored)
36
+ @name = name
37
+ @image = image
38
+ @website = website
39
+ @profile = profile
40
+ end
41
+ end
42
+
43
+ def initialize(handle: nil, readme_path: README_PATH)
44
+ @handle = handle || resolve_handle
45
+ @readme_path = readme_path
46
+ end
47
+
48
+ def run!
49
+ readme = File.read(@readme_path)
50
+
51
+ # Identify previous entries for diffing/mentions
52
+ b_start, b_end = detect_backer_tags(readme)
53
+ prev_backer_identities = extract_section_identities(readme, b_start, b_end)
54
+ s_start_prev, s_end_prev = detect_sponsor_tags(readme)
55
+ prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
56
+
57
+ # Backers (individuals)
58
+ backers = fetch_members("backers.json")
59
+ backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
60
+ updated = replace_between_tags(readme, b_start, b_end, backers_md)
61
+ case updated
62
+ when :not_found
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
+ # Do not exit the process during tests or library use; just return.
102
+ return
103
+ end
104
+ puts "No changes to backers or sponsors sections in #{@readme_path}."
105
+ return
106
+ end
107
+
108
+ File.write(@readme_path, final)
109
+ msgs = []
110
+ msgs << "backers" if backers_changed
111
+ msgs << "sponsors" if sponsors_changed
112
+ puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."
113
+
114
+ # Compose and perform commit with mentions if in a git repo
115
+ perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)
116
+ end
117
+
118
+ private
119
+
120
+ def readme_osc_tag
121
+ env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
122
+ return env unless env.strip.empty?
123
+ if File.file?(OC_YML_PATH)
124
+ begin
125
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
126
+ if yml.is_a?(Hash)
127
+ from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
128
+ from_yml = from_yml.to_s if from_yml
129
+ return from_yml unless from_yml.nil? || from_yml.strip.empty?
130
+ end
131
+ rescue StandardError
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
+ def extract_section_identities(content, start_tag, end_tag)
232
+ return Set.new unless start_tag && end_tag && start_tag != :not_found && end_tag != :not_found
233
+ start_index = content.index(start_tag)
234
+ end_index = content.index(end_tag)
235
+ return Set.new if start_index.nil? || end_index.nil? || end_index < start_index
236
+ block = content[(start_index + start_tag.length)...end_index]
237
+ identities = Set.new
238
+ block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
239
+ href = (m[0] || "").strip
240
+ identities << href.downcase unless href.empty?
241
+ end
242
+ block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
243
+ alt = (m[0] || "").strip
244
+ identities << alt.downcase unless alt.empty?
245
+ end
246
+ identities
247
+ end
248
+
249
+ def compute_new_members(previous_identities, members)
250
+ prev = previous_identities || Set.new
251
+ members.select do |m|
252
+ id = identity_for_member(m)
253
+ !prev.include?(id)
254
+ end
255
+ end
256
+
257
+ def identity_for_member(m)
258
+ if m.profile && !m.profile.strip.empty?
259
+ m.profile.strip.downcase
260
+ elsif m.website && !m.website.strip.empty?
261
+ m.website.strip.downcase
262
+ elsif m.name && !m.name.strip.empty?
263
+ m.name.strip.downcase
264
+ else
265
+ ""
266
+ end
267
+ end
268
+
269
+ def mention_for_member(m, default_name: "Member")
270
+ handle = github_handle_from_urls(m.profile, m.website)
271
+ return "@#{handle}" if handle
272
+ name = (m.name && !m.name.strip.empty?) ? m.name.strip : default_name
273
+ name
274
+ end
275
+
276
+ def github_handle_from_urls(*urls)
277
+ urls.compact.each do |u|
278
+ begin
279
+ uri = URI.parse(u)
280
+ rescue URI::InvalidURIError
281
+ next
282
+ end
283
+ next unless uri&.host&.downcase&.end_with?("github.com")
284
+ path = (uri.path || "").sub(%r{^/}, "").sub(%r{/$}, "")
285
+ next if path.empty?
286
+ parts = path.split("/")
287
+ candidate = if parts[0].downcase == "sponsors" && parts[1]
288
+ parts[1]
289
+ else
290
+ parts[0]
291
+ end
292
+ candidate = candidate.gsub(%r{[^a-zA-Z0-9-]}, "")
293
+ return candidate unless candidate.empty?
294
+ end
295
+ nil
296
+ end
297
+
298
+ def perform_git_commit(new_backers, new_sponsors)
299
+ backer_mentions = new_backers.map { |m| mention_for_member(m, default_name: "Backer") }.uniq
300
+ sponsor_mentions = new_sponsors.map { |m| mention_for_member(m, default_name: "Subscriber") }.uniq
301
+ title = commit_subject
302
+ lines = [title]
303
+ lines << ""
304
+ lines << "Backers: #{backer_mentions.join(", ")}" unless backer_mentions.empty?
305
+ lines << "Subscribers: #{sponsor_mentions.join(", ")}" unless sponsor_mentions.empty?
306
+ message = lines.join("\n")
307
+ system("git", "add", @readme_path)
308
+ if system("git", "diff", "--cached", "--quiet")
309
+ return
310
+ end
311
+ system("git", "commit", "-m", message)
312
+ end
313
+
314
+ def commit_subject
315
+ env = ENV["KETTLE_README_BACKERS_COMMIT_SUBJECT"].to_s
316
+ return env unless env.strip.empty?
317
+ if File.file?(OC_YML_PATH)
318
+ begin
319
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
320
+ if yml.is_a?(Hash)
321
+ from_yml = yml["readme-backers-commit-subject"] || yml[:"readme-backers-commit-subject"]
322
+ from_yml = from_yml.to_s if from_yml
323
+ return from_yml unless from_yml.nil? || from_yml.strip.empty?
324
+ end
325
+ rescue StandardError
326
+ end
327
+ end
328
+ COMMIT_SUBJECT_DEFAULT
329
+ end
330
+
331
+ def git_repo?
332
+ system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
333
+ end
334
+
335
+ def escape_text(text)
336
+ text.gsub("[", "\\[").gsub("]", "\\]")
337
+ end
338
+ end
339
+ end
340
+ end