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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.envrc +4 -3
- data/.github/workflows/coverage.yml +3 -3
- data/.junie/guidelines.md +4 -3
- data/.simplecov +5 -1
- data/Appraisals +3 -0
- data/CHANGELOG.md +22 -1
- data/CONTRIBUTING.md +6 -0
- data/README.md +18 -5
- data/Rakefile +7 -11
- data/exe/kettle-commit-msg +9 -143
- data/exe/kettle-readme-backers +7 -353
- data/exe/kettle-release +8 -702
- 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 -396
- 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 -465
- 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 +8 -17
- 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 +55 -5
- metadata.gz.sig +0 -0
data/exe/kettle-readme-backers
CHANGED
@@ -1,359 +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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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 "
|
30
|
-
require "json"
|
31
|
-
require "uri"
|
32
|
-
require "net/http"
|
33
|
-
require "set"
|
11
|
+
require "kettle/dev"
|
34
12
|
|
35
|
-
|
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
|
-
"[](#{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: [](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!
|