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
@@ -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
|
+
"[](#{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
|