kettle-dev 1.1.25 → 1.1.26

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e75e0d20259bcb2cac5f2b536484c86c90f14571b6eb3eaf932be12312a1c0f4
4
- data.tar.gz: 371bb553cacb0123d8f23f45d96d12b868a4b575b414ba90c816bb28d60be421
3
+ metadata.gz: d5b1991c0a766cdf2ca22899493b377918b1c35904e8c7927e29c30ba927df02
4
+ data.tar.gz: 9ba2c426beb96666c08ad9d0462162921456d48c8c2ce2ece0d302a62ab6574e
5
5
  SHA512:
6
- metadata.gz: faf91f091e1fa86de01ce175e6b56080f49a7c2168e783f36a21870662b70441501c44cdada7cd1aa865550fab539d634daf95c3de7d7559789857c9ae3f1fac
7
- data.tar.gz: 7f7f375775fa0cbcd860b8366b3ec4f0638086335bf57881613c147ef4681916c7e8dc6434101a44793ba56f796aa8335ecbdc87686b0d0db1868529031d7c0d
6
+ metadata.gz: 1d688be0bf828e8d633970b38b37f0cc386afeb6718ddb7578f09f22f16ddd6e25b55b24978b14e9ba0d7e90f3aad4eef36fb394680f86e34195982e8f9d81f6
7
+ data.tar.gz: eee66b8b680aea507855b8b6323769e2e9f4d297243f0ed17a84261798241749d76a565dcf18dc2ba6730717aeb7701515b353fa9520f7add4bd37f99f0924ef
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -22,12 +22,16 @@ Please file a bug if you notice a violation of semantic versioning.
22
22
 
23
23
  ### Changed
24
24
 
25
+ - Use obfuscated URLs, and avatars from Open Collective in ReadmeBackers
26
+
25
27
  ### Deprecated
26
28
 
27
29
  ### Removed
28
30
 
29
31
  ### Fixed
30
32
 
33
+ - fixed handling of kettle-release when checksums are present and unchanged causing the gem_checksums script to fail
34
+
31
35
  ### Security
32
36
 
33
37
  ## [1.1.25] - 2025-09-18
@@ -980,7 +984,9 @@ Please file a bug if you notice a violation of semantic versioning.
980
984
  - Selecting will run the selected workflow via `act`
981
985
  - This may move to its own gem in the future.
982
986
 
983
- [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.25...HEAD
987
+ [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.26...HEAD
988
+ [1.1.26]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.25...v1.1.26
989
+ [1.1.26t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.1.26
984
990
  [1.1.25]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.24...v1.1.25
985
991
  [1.1.25t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.1.25
986
992
  [1.1.24]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.23...v1.1.24
data/README.md CHANGED
@@ -579,20 +579,24 @@ and [Tidelift][🏙️entsup-tidelift].
579
579
 
580
580
  ### Open Collective for Individuals
581
581
 
582
+ Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
583
+
584
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
585
+
582
586
  <!-- OPENCOLLECTIVE-INDIVIDUALS:START -->
583
587
  No backers yet. Be the first!
584
588
  <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
585
589
 
586
- Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
587
-
588
590
  ### Open Collective for Organizations
589
591
 
592
+ Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
593
+
594
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
595
+
590
596
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:START -->
591
597
  No sponsors yet. Be the first!
592
598
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
593
599
 
594
- Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
595
-
596
600
  ### Another way to support open-source
597
601
 
598
602
  > How wonderful it is that nobody need wait a single moment before starting to improve the world.<br/>
@@ -921,7 +925,7 @@ Thanks for RTFM. ☺️
921
925
  [📌gitmoji]:https://gitmoji.dev
922
926
  [📌gitmoji-img]:https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
923
927
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
924
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-3.828-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
928
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.225-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
925
929
  [🔐security]: SECURITY.md
926
930
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
927
931
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/README.md.example CHANGED
@@ -166,19 +166,25 @@ and [Tidelift][🏙️entsup-tidelift].
166
166
 
167
167
  ### Open Collective for Individuals
168
168
 
169
+ Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
170
+
171
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
172
+
169
173
  <!-- OPENCOLLECTIVE-INDIVIDUALS:START -->
170
174
  No backers yet. Be the first!
171
175
  <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
172
176
 
173
- Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
174
-
175
177
  ### Open Collective for Organizations
176
178
 
179
+ Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
180
+
181
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
182
+
177
183
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:START -->
178
184
  No sponsors yet. Be the first!
179
185
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
180
186
 
181
- Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
187
+ [kettle-readme-backers]: https://github.com/kettle-rb/kettle-dev/blob/main/exe/kettle-readme-backers
182
188
 
183
189
  ### Another way to support open-source
184
190
 
@@ -513,7 +519,7 @@ Thanks for RTFM. ☺️
513
519
  [📌gitmoji]:https://gitmoji.dev
514
520
  [📌gitmoji-img]:https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
515
521
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
516
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-3.828-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
522
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.225-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
517
523
  [🔐security]: SECURITY.md
518
524
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
519
525
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/Rakefile.example CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # kettle-dev Rakefile v1.1.25 - 2025-09-18
3
+ # kettle-dev Rakefile v1.1.26 - 2025-09-20
4
4
  # Ruby 2.3 (Safe Navigation) or higher required
5
5
  #
6
6
  # MIT License (see License.txt)
@@ -18,26 +18,44 @@ module Kettle
18
18
 
19
19
  public
20
20
 
21
- DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
22
- README_PATH = File.expand_path("../../../README.md", __dir__)
21
+ # Default README is the one in the current working directory of the host project
22
+ README_PATH = File.expand_path("README.md", Dir.pwd)
23
23
  README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
24
24
  COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
25
25
  # Deprecated constant maintained for backwards compatibility in tests/specs.
26
- # Prefer OpenCollectiveConfig.yaml_path going forward.
27
- OC_YML_PATH = OpenCollectiveConfig.yaml_path
26
+ # Prefer OpenCollectiveConfig.yaml_path going forward, but resolve to the host project root.
27
+ OC_YML_PATH = OpenCollectiveConfig.yaml_path(Dir.pwd)
28
+
29
+ private
30
+
31
+ # Emit a debug log line when kettle-dev debugging is enabled.
32
+ # Controlled by KETTLE_DEV_DEBUG=true (or DEBUG=true as fallback).
33
+ # @param msg [String]
34
+ # @return [void]
35
+ def debug_log(msg)
36
+ return unless Kettle::Dev::DEBUGGING
37
+ Kernel.warn("[readme_backers] #{msg}")
38
+ rescue StandardError
39
+ # never raise from a standard error within debug logging
40
+ end
41
+
42
+ public
28
43
 
29
44
  # Ruby 2.3 compatibility: Struct keyword_init added in Ruby 2.5
30
45
  # Switch to struct when dropping ruby < 2.5
31
46
  # Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
32
47
  # Fallback for Ruby < 2.5 where Struct keyword_init is unsupported
33
48
  class Backer
34
- attr_accessor :name, :image, :website, :profile
49
+ ROLE = "BACKER"
50
+ attr_accessor :name, :image, :website, :profile, :oc_type, :oc_index
35
51
 
36
- def initialize(name: nil, image: nil, website: nil, profile: nil, **_ignored)
52
+ def initialize(name: nil, image: nil, website: nil, profile: nil, oc_type: nil, oc_index: nil, **_ignored)
37
53
  @name = name
38
54
  @image = image
39
55
  @website = website
40
56
  @profile = profile
57
+ @oc_type = oc_type # "backer" or "organization"
58
+ @oc_index = oc_index # Integer index within type for OC URL generation
41
59
  end
42
60
  end
43
61
 
@@ -71,24 +89,86 @@ module Kettle
71
89
  end
72
90
 
73
91
  def run!
92
+ validate
93
+ debug_log("Starting run: handle=#{@handle.inspect}, readme=#{@readme_path}")
94
+ debug_log("Resolved OSC tag base=#{readme_osc_tag.inspect}")
74
95
  readme = File.read(@readme_path)
75
96
 
76
97
  # Identify previous entries for diffing/mentions
77
98
  b_start, b_end = detect_backer_tags(readme)
78
- prev_backer_identities = extract_section_identities(readme, b_start, b_end)
79
99
  s_start_prev, s_end_prev = detect_sponsor_tags(readme)
100
+ debug_log("Backer tags present=#{b_start != :not_found && b_end != :not_found}; Sponsor tags present=#{s_start_prev != :not_found && s_end_prev != :not_found}")
101
+ prev_backer_identities = extract_section_identities(readme, b_start, b_end)
80
102
  prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
81
103
 
82
- # Backers (individuals)
83
- backers = fetch_members("backers.json")
104
+ # Fetch all BACKER-role members once and partition by tier
105
+ debug_log("Fetching OpenCollective members JSON for handle=#{@handle} ...")
106
+ raw = fetch_all_backers_raw
107
+ debug_log("Fetched #{Array(raw).size} members (role=#{Backer::ROLE}) before tier partitioning")
108
+ # Build OpenCollective type-index map to generate stable avatar/website links
109
+ index_map = build_oc_index_map(raw)
110
+ if Kettle::Dev::DEBUGGING
111
+ tier_counts = Array(raw).group_by { |h| (h["tier"] || "").to_s.strip }.transform_values(&:size)
112
+ debug_log("Tier distribution: #{tier_counts}")
113
+ empty_tier = Array(raw).select { |h| h["tier"].to_s.strip.empty? }
114
+ unless empty_tier.empty?
115
+ debug_log("Members with empty tier: count=#{empty_tier.size}; showing up to 5 samples:")
116
+ empty_tier.first(5).each_with_index do |m, i|
117
+ debug_log(" [empty-tier ##{i + 1}] name=#{m["name"].inspect}, isActive=#{m["isActive"].inspect}, profile=#{m["profile"].inspect}, website=#{m["website"].inspect}")
118
+ end
119
+ end
120
+ other_tiers = Array(raw).map { |h| h["tier"].to_s.strip }.reject { |t| t.empty? || t.casecmp("Backer").zero? || t.casecmp("Sponsor").zero? }
121
+ unless other_tiers.empty?
122
+ counts = other_tiers.group_by { |t| t }.transform_values(&:size)
123
+ debug_log("Non-standard tiers present (excluding Backer/Sponsor): #{counts}")
124
+ end
125
+ end
126
+ backers_hashes = Array(raw).select { |h| h["tier"].to_s.strip.casecmp("Backer").zero? }
127
+ sponsors_hashes = Array(raw).select { |h| h["tier"].to_s.strip.casecmp("Sponsor").zero? }
128
+
129
+ backers = map_hashes_to_backers(backers_hashes, index_map, force_type: "backer")
130
+ sponsors = map_hashes_to_backers(sponsors_hashes, index_map, force_type: "organization")
131
+ debug_log("Partitioned counts => Backers=#{backers.size}, Sponsors=#{sponsors.size}")
132
+ if Kettle::Dev::DEBUGGING && backers.empty? && sponsors.empty? && Array(raw).any?
133
+ debug_log("No Backer or Sponsor tiers matched among #{Array(raw).size} BACKER-role records. If tiers are empty, they will not appear in Backers/Sponsors sections.")
134
+ end
135
+
136
+ # Additional dynamic tiers (exclude Backer/Sponsor)
137
+ extra_map = {}
138
+ Array(raw).group_by { |h| h["tier"].to_s.strip }.each do |tier, members|
139
+ normalized = tier.empty? ? "Donors" : tier
140
+ next if normalized.casecmp("Backer").zero? || normalized.casecmp("Sponsor").zero?
141
+ extra_map[normalized] = map_hashes_to_backers(members, index_map)
142
+ end
143
+ debug_log("Extra tiers detected: #{extra_map.keys.sort}") unless extra_map.empty?
144
+
84
145
  backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
85
- updated = replace_between_tags(readme, b_start, b_end, backers_md)
146
+ sponsors_md_base = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
147
+
148
+ extra_tiers_md = generate_extra_tiers_markdown(extra_map)
149
+ sponsors_md = if extra_tiers_md.empty?
150
+ sponsors_md_base
151
+ else
152
+ [sponsors_md_base, "", extra_tiers_md].join("\n")
153
+ end
154
+
155
+ # Update backers section
156
+ # If the identities in the existing block match the identities derived from current data,
157
+ # treat as no-change to avoid rewriting formatting (e.g., Markdown -> HTML OC anchors).
158
+ semantically_same_backers = semantically_same_section?(prev_backer_identities, backers)
159
+ updated = if semantically_same_backers
160
+ :no_change
161
+ else
162
+ replace_between_tags(readme, b_start, b_end, backers_md)
163
+ end
86
164
  case updated
87
165
  when :not_found
166
+ debug_log("Backers tag block not found; skipping backers section update")
88
167
  updated_readme = readme
89
168
  backers_changed = false
90
169
  new_backers = []
91
170
  when :no_change
171
+ debug_log("Backers section unchanged (identities match or generated markdown matches existing block)")
92
172
  updated_readme = readme
93
173
  backers_changed = false
94
174
  new_backers = []
@@ -96,19 +176,35 @@ module Kettle
96
176
  updated_readme = updated
97
177
  backers_changed = true
98
178
  new_backers = compute_new_members(prev_backer_identities, backers)
179
+ debug_log("Backers section updated; new_backers=#{new_backers.size}")
99
180
  end
100
181
 
101
- # Sponsors (organizations)
102
- sponsors = fetch_members("sponsors.json")
103
- sponsors_md = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
182
+ # Update sponsors section (with extra tiers appended when present)
104
183
  s_start, s_end = detect_sponsor_tags(updated_readme)
105
- updated2 = replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
184
+ # If there is no sponsors section but there is a backers section, append extra tiers to backers instead.
185
+ if s_start == :not_found && !extra_tiers_md.empty? && b_start != :not_found
186
+ debug_log("Sponsors tags not found; appending extra tiers under Backers section")
187
+ backers_md_with_extra = [backers_md, "", extra_tiers_md].join("\n")
188
+ updated = replace_between_tags(updated_readme, b_start, b_end, backers_md_with_extra)
189
+ updated_readme = updated unless updated == :no_change || updated == :not_found
190
+ end
191
+
192
+ # Sponsors: apply the same semantic no-change rule
193
+ prev_s_ids = extract_section_identities(updated_readme, s_start, s_end)
194
+ semantically_same_sponsors = semantically_same_section?(prev_s_ids, sponsors)
195
+ updated2 = if semantically_same_sponsors
196
+ :no_change
197
+ else
198
+ replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
199
+ end
106
200
  case updated2
107
201
  when :not_found
202
+ debug_log("Sponsors tag block not found; skipping sponsors section update")
108
203
  sponsors_changed = false
109
204
  final = updated_readme
110
205
  new_sponsors = []
111
206
  when :no_change
207
+ debug_log("Sponsors section unchanged (identities match or generated markdown matches existing block)")
112
208
  sponsors_changed = false
113
209
  final = updated_readme
114
210
  new_sponsors = []
@@ -116,6 +212,7 @@ module Kettle
116
212
  sponsors_changed = true
117
213
  final = updated2
118
214
  new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
215
+ debug_log("Sponsors section updated; new_sponsors=#{new_sponsors.size}")
119
216
  end
120
217
 
121
218
  if !backers_changed && !sponsors_changed
@@ -123,9 +220,11 @@ module Kettle
123
220
  ts = tag_strings
124
221
  warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
125
222
  "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
223
+ debug_log("Missing tags: looked for #{ts}")
126
224
  # Do not exit the process during tests or library use; just return.
127
225
  return
128
226
  end
227
+ debug_log("No changes detected after processing; Backers=#{backers.size}, Sponsors=#{sponsors.size}, ExtraTiers=#{extra_map.keys.size}")
129
228
  puts "No changes to backers or sponsors sections in #{@readme_path}."
130
229
  return
131
230
  end
@@ -146,9 +245,9 @@ module Kettle
146
245
  env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
147
246
  return env unless env.strip.empty?
148
247
 
149
- if File.file?(OpenCollectiveConfig.yaml_path)
248
+ if File.file?(OC_YML_PATH)
150
249
  begin
151
- yml = YAML.safe_load(File.read(OpenCollectiveConfig.yaml_path))
250
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
152
251
  if yml.is_a?(Hash)
153
252
  from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
154
253
  from_yml = from_yml.to_s if from_yml
@@ -174,11 +273,13 @@ module Kettle
174
273
  end
175
274
 
176
275
  def resolve_handle
177
- OpenCollectiveConfig.handle(required: true)
276
+ OpenCollectiveConfig.handle(required: true, root: Dir.pwd)
178
277
  end
179
278
 
180
- def fetch_members(path)
181
- url = URI("https://opencollective.com/#{@handle}/#{path}")
279
+ def fetch_all_backers_raw
280
+ api_path = "members/all.json"
281
+ url = URI("https://opencollective.com/#{@handle}/#{api_path}")
282
+ debug_log("GET #{url}")
182
283
  response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |conn|
183
284
  conn.read_timeout = 10
184
285
  conn.open_timeout = 5
@@ -186,36 +287,169 @@ module Kettle
186
287
  req["User-Agent"] = "kettle-dev/README-backers"
187
288
  conn.request(req)
188
289
  end
189
- return [] unless response.is_a?(Net::HTTPSuccess)
290
+ unless response.is_a?(Net::HTTPSuccess)
291
+ body_len = (response.respond_to?(:body) && response.body) ? response.body.bytesize : 0
292
+ code = response.respond_to?(:code) ? response.code : response.class.name
293
+ warn("OpenCollective API non-success for #{api_path}: status=#{code}, body_len=#{body_len}")
294
+ debug_log("Response body (truncated 500 bytes): #{response.body.to_s[0, 500]}") if Kettle::Dev::DEBUGGING && body_len.to_i > 0
295
+ return []
296
+ end
190
297
 
191
298
  parsed = JSON.parse(response.body)
192
- Array(parsed).map do |h|
299
+ all = Array(parsed)
300
+ filtered = all.select { |h| h["role"].to_s.upcase == Backer::ROLE }
301
+ debug_log("Parsed #{all.size} records; filtered BACKER => #{filtered.size}")
302
+ filtered
303
+ rescue JSON::ParserError => e
304
+ warn("Error parsing #{api_path} JSON: #{e.message}")
305
+ debug_log("Body that failed to parse (truncated 500): #{response&.body.to_s[0, 500]}")
306
+ []
307
+ rescue StandardError => e
308
+ warn("Error fetching #{api_path}: #{e.class}: #{e.message}")
309
+ debug_log(e.backtrace.join("\n"))
310
+ []
311
+ end
312
+
313
+ # Build a deterministic OpenCollective index map used to construct avatar/website URLs.
314
+ # Rules:
315
+ # - Iterate through the raw BACKER-role array in order received from OC.
316
+ # - Maintain two independent counters: one for "backer" (users) and one for "organization".
317
+ # - Derive a stable identity key for each member by preferring profile URL, then website URL, then name; all downcased.
318
+ # - Assign the current counter as the index for that type and increment the counter.
319
+ # - Return a Hash mapping the identity key to { type: "backer"|"organization", index: Integer }.
320
+ # This lets generate_markdown output links like:
321
+ # https://opencollective.com/<handle>/<type>/<index>/website and avatar.svg
322
+ def build_oc_index_map(hashes)
323
+ counts = {"backer" => 0, "organization" => 0}
324
+ map = {}
325
+ Array(hashes).each do |h|
326
+ type = (h["type"].to_s.upcase == "ORGANIZATION") ? "organization" : "backer"
327
+ key = if h["profile"].to_s.strip != ""
328
+ h["profile"].to_s.strip.downcase
329
+ elsif h["website"].to_s.strip != ""
330
+ h["website"].to_s.strip.downcase
331
+ else
332
+ h["name"].to_s.strip.downcase
333
+ end
334
+ idx = counts[type]
335
+ counts[type] = idx + 1
336
+ map[key] = {type: type, index: idx}
337
+ end
338
+ # Helpful debug summary so users can see which index maps to which backer.
339
+ if Kettle::Dev::DEBUGGING
340
+ samples = map.first(5).map { |k, v| "#{v[:type]}##{v[:index]} => #{k}" }
341
+ debug_log("Built OC index map: backer_count=#{counts["backer"]}, organization_count=#{counts["organization"]}; sample=#{samples}")
342
+ end
343
+ map
344
+ end
345
+
346
+ def map_hashes_to_backers(hashes, index_map = nil, force_type: nil)
347
+ forced_counter = 0
348
+ Array(hashes).map do |h|
349
+ key = if h["profile"].to_s.strip != ""
350
+ h["profile"].to_s.strip.downcase
351
+ elsif h["website"].to_s.strip != ""
352
+ h["website"].to_s.strip.downcase
353
+ else
354
+ h["name"].to_s.strip.downcase
355
+ end
356
+ oc = index_map ? index_map[key] : nil
357
+ # Determine oc_type/index with optional forced type override (used for sections)
358
+ oc_type = nil
359
+ oc_index = nil
360
+ if force_type
361
+ if oc && oc[:type] == force_type && !oc[:index].nil?
362
+ oc_type = oc[:type]
363
+ oc_index = oc[:index]
364
+ else
365
+ oc_type = force_type
366
+ oc_index = forced_counter
367
+ forced_counter += 1
368
+ end
369
+ else
370
+ oc_type = oc ? oc[:type] : nil
371
+ oc_index = oc ? oc[:index] : nil
372
+ end
193
373
  Backer.new(
194
374
  name: h["name"],
195
- image: (h["image"].to_s.strip.empty? ? nil : h["image"]),
375
+ image: begin
376
+ # Prefer OpenCollective's "avatar" key; fallback to legacy "image"
377
+ img = h["avatar"]
378
+ img = h["image"] if img.to_s.strip.empty?
379
+ img.to_s.strip.empty? ? nil : img
380
+ end,
196
381
  website: (h["website"].to_s.strip.empty? ? nil : h["website"]),
197
382
  profile: (h["profile"].to_s.strip.empty? ? nil : h["profile"]),
383
+ oc_type: oc_type,
384
+ oc_index: oc_index,
198
385
  )
199
386
  end
200
- rescue JSON::ParserError => e
201
- warn("Error parsing #{path} JSON: #{e.message}")
202
- []
203
- rescue StandardError => e
204
- warn("Error fetching #{path}: #{e.class}: #{e.message}")
205
- []
206
387
  end
207
388
 
208
389
  def generate_markdown(members, empty_message:, default_name:)
209
390
  return empty_message if members.nil? || members.empty?
210
391
 
211
392
  members.map do |m|
212
- image_url = m.image || DEFAULT_AVATAR
213
- link = m.website || m.profile || "#"
214
- name = (m.name && !m.name.strip.empty?) ? m.name : default_name
215
- "[![#{escape_text(name)}](#{image_url})](#{link})"
393
+ # Prefer deterministic OpenCollective avatar/link form when index is available
394
+ if m.oc_type && !m.oc_type.to_s.strip.empty? && !m.oc_index.nil?
395
+ type = m.oc_type
396
+ idx = m.oc_index
397
+ href = "https://opencollective.com/#{@handle}/#{type}/#{idx}/website"
398
+ img = "https://opencollective.com/#{@handle}/#{type}/#{idx}/avatar.svg"
399
+ %(<a href="#{href}" target="_blank"><img src="#{img}"></a>)
400
+ else
401
+ # Fallback to prior Markdown behavior
402
+ image_url = (m.image && !m.image.to_s.strip.empty?) ? m.image : nil
403
+ primary_link = (m.website && !m.website.to_s.strip.empty?) ? m.website : nil
404
+ fallback_link = (m.profile && !m.profile.to_s.strip.empty?) ? m.profile : nil
405
+ link = primary_link || fallback_link || "#"
406
+ name = (m.name && !m.name.strip.empty?) ? m.name : default_name
407
+ if image_url
408
+ "[![#{escape_text(name)}](#{image_url})](#{link})"
409
+ else
410
+ "[#{escape_text(name)}](#{link})"
411
+ end
412
+ end
216
413
  end.join(" ")
217
414
  end
218
415
 
416
+ # Build markdown for any additional tiers beyond Backer/Sponsor.
417
+ # Accepts a Hash of { tier_name => [Backer, ...] }.
418
+ # Returns an empty string when there are no extra tiers.
419
+ def generate_extra_tiers_markdown(extra_map)
420
+ return "" if extra_map.nil? || extra_map.empty?
421
+
422
+ blocks = []
423
+ extra_map.keys.sort.each do |tier|
424
+ members = extra_map[tier]
425
+ next if members.nil? || members.empty?
426
+ # For extra tiers, render using plain Markdown links to satisfy specs
427
+ # that expect either Markdown or a bare <a> tag (without nested <img>).
428
+ members_plain = Array(members).map do |m|
429
+ Backer.new(
430
+ name: m.name,
431
+ image: m.image,
432
+ website: m.website,
433
+ profile: m.profile,
434
+ )
435
+ end
436
+ # Build a single, well-formed block per tier with deterministic spacing:
437
+ # - Header
438
+ # - One empty line
439
+ # - Links line
440
+ block = [
441
+ "### Open Collective for #{tier}",
442
+ "",
443
+ generate_markdown(members_plain, empty_message: "", default_name: tier),
444
+ ].join("\n")
445
+ blocks << block
446
+ end
447
+ # Separate multiple tiers with a single blank line between blocks.
448
+ # The caller (replace_between_tags) will append one trailing newline before the end tag,
449
+ # yielding exactly two newlines after the links line within the section.
450
+ blocks.join("\n\n")
451
+ end
452
+
219
453
  def replace_between_tags(content, start_tag, end_tag, new_content)
220
454
  return :not_found if start_tag == :not_found || end_tag == :not_found
221
455
 
@@ -225,11 +459,14 @@ module Kettle
225
459
 
226
460
  before = content[0..start_index + start_tag.length - 1]
227
461
  after = content[end_index..-1]
228
- replacement = "#{start_tag}\n#{new_content}\n#{end_tag}"
229
462
  current_block = content[start_index..end_index + end_tag.length - 1]
230
- return :no_change if current_block == replacement
463
+ # Treat as unchanged if the block matches either single or double newline spacing
464
+ unchanged_a = "#{start_tag}\n#{new_content}\n#{end_tag}"
465
+ unchanged_b = "#{start_tag}\n#{new_content}\n\n#{end_tag}"
466
+ return :no_change if current_block == unchanged_a || current_block == unchanged_b
231
467
 
232
468
  trailing = after[end_tag.length..-1] || ""
469
+ # Use a single blank line between content and end tag for normalized output
233
470
  "#{before}\n#{new_content}\n#{end_tag}#{trailing}"
234
471
  end
235
472
 
@@ -262,14 +499,28 @@ module Kettle
262
499
 
263
500
  block = content[(start_index + start_tag.length)...end_index]
264
501
  identities = Set.new
502
+ # 1) Image-style link wrappers: [![ALT](IMG)](HREF)
265
503
  block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
266
504
  href = (m[0] || "").strip
267
505
  identities << href.downcase unless href.empty?
268
506
  end
507
+ # 2) Capture ALT text from image-style wrappers for name identity
269
508
  block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
270
509
  alt = (m[0] || "").strip
271
510
  identities << alt.downcase unless alt.empty?
272
511
  end
512
+ # 3) Plain markdown links: [TEXT](HREF)
513
+ block.to_s.scan(/\[([^!][^\]]*)\]\(([^\)]+)\)/) do |m|
514
+ text = (m[0] || "").strip
515
+ href = (m[1] || "").strip
516
+ identities << href.downcase unless href.empty?
517
+ identities << text.downcase unless text.empty?
518
+ end
519
+ # 4) HTML anchors: <a href="HREF">...</a>
520
+ block.to_s.scan(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>/i) do |m|
521
+ href = (m[0] || "").strip
522
+ identities << href.downcase unless href.empty?
523
+ end
273
524
  identities
274
525
  end
275
526
 
@@ -281,6 +532,71 @@ module Kettle
281
532
  end
282
533
  end
283
534
 
535
+ # Build the identity set for a list of members using the same precedence
536
+ # as compute_new_members/identity_for_member. Used to compare semantic
537
+ # equivalence of existing README sections vs new data, to avoid rewrites
538
+ # when only formatting differs.
539
+ # @param members [Array<Backer>]
540
+ # @return [Set<String>]
541
+ def identities_for_members(members)
542
+ set = Set.new
543
+ Array(members).each do |m|
544
+ ids = []
545
+ # Include deterministic OpenCollective href identity when available
546
+ if m.oc_type && !m.oc_type.to_s.strip.empty? && !m.oc_index.nil?
547
+ ids << "https://opencollective.com/#{@handle}/#{m.oc_type}/#{m.oc_index}/website"
548
+ end
549
+ # Also include the standard identity used elsewhere (profile/website/name)
550
+ ids << identity_for_member(m)
551
+ ids.compact.each do |id|
552
+ norm = id.to_s.strip.downcase
553
+ set << norm unless norm.empty?
554
+ end
555
+ end
556
+ set
557
+ end
558
+
559
+ # Determine if the new identity set is semantically the same as the previous
560
+ # block found in the README. We treat it as the same when all new identities
561
+ # are already present in the previous set (subset), allowing the previous set
562
+ # to contain additional identities such as plain-text names extracted from
563
+ # Markdown ALT text. This avoids unnecessary rewrites when only formatting
564
+ # changes (e.g., Markdown -> HTML OC anchors) but the underlying members are
565
+ # unchanged.
566
+ # @param previous [Set<String>]
567
+ # @param new_set [Set<String>]
568
+ # @return [Boolean]
569
+ def identities_semantically_same?(previous, new_set)
570
+ return false if new_set.nil? || new_set.empty?
571
+ return false if previous.nil? || previous.empty?
572
+ new_set.all? { |id| previous.include?(id) }
573
+ end
574
+
575
+ # Determine semantic sameness by ensuring each member has at least one
576
+ # identity present in the previous README identities set. This allows
577
+ # different formatting (OC anchors vs Markdown website/profile links)
578
+ # without rewriting when the underlying members are the same.
579
+ # @param previous [Set<String>]
580
+ # @param members [Array<Backer>]
581
+ # @return [Boolean]
582
+ def semantically_same_section?(previous, members)
583
+ return false if previous.nil? || previous.empty?
584
+ Array(members).all? do |m|
585
+ candidates = []
586
+ if m.oc_type && !m.oc_type.to_s.strip.empty? && !m.oc_index.nil?
587
+ candidates << "https://opencollective.com/#{@handle}/#{m.oc_type}/#{m.oc_index}/website"
588
+ end
589
+ candidates << m.profile
590
+ candidates << m.website
591
+ candidates << m.name
592
+ candidates.compact!
593
+ candidates.any? { |id|
594
+ s = id.to_s.strip.downcase
595
+ !s.empty? && previous.include?(s)
596
+ }
597
+ end
598
+ end
599
+
284
600
  def identity_for_member(m)
285
601
  if m.profile && !m.profile.strip.empty?
286
602
  m.profile.strip.downcase
@@ -20,14 +20,32 @@ module Kettle
20
20
  def run_cmd!(cmd)
21
21
  # For Bundler-invoked build/release, explicitly prefix SKIP_GEM_SIGNING so
22
22
  # the signing step is skipped even when Bundler scrubs ENV.
23
+ # Always do this on CI to avoid interactive prompts; locally only when explicitly requested.
23
24
  if ENV["SKIP_GEM_SIGNING"] && cmd =~ /\Abundle(\s+exec)?\s+rake\s+(build|release)\b/
24
25
  cmd = "SKIP_GEM_SIGNING=true #{cmd}"
25
26
  end
26
27
  puts "$ #{cmd}"
27
28
  # Pass a plain Hash for the environment to satisfy tests and avoid ENV object oddities
28
29
  env_hash = ENV.respond_to?(:to_hash) ? ENV.to_hash : ENV.to_h
29
- success = system(env_hash, cmd)
30
- abort("Command failed: #{cmd}") unless success
30
+
31
+ # Capture output so we can surface clear diagnostics on failure
32
+ stdout_str, stderr_str, status = Open3.capture3(env_hash, cmd)
33
+
34
+ # Echo command output to match prior behavior
35
+ $stdout.print(stdout_str) unless stdout_str.nil? || stdout_str.empty?
36
+ $stderr.print(stderr_str) unless stderr_str.nil? || stderr_str.empty?
37
+
38
+ unless status.success?
39
+ exit_code = status.exitstatus
40
+ # Keep the original prefix to avoid breaking any tooling/tests that grep for it,
41
+ # but add the exit status and a brief diagnostic tail from stderr.
42
+ diag = ""
43
+ unless stderr_str.to_s.empty?
44
+ tail = stderr_str.lines.last(20).join
45
+ diag = "\n--- STDERR (last 20 lines) ---\n#{tail}".rstrip
46
+ end
47
+ abort("Command failed: #{cmd} (exit #{exit_code})#{diag}")
48
+ end
31
49
  end
32
50
  end
33
51
 
@@ -260,7 +260,7 @@ module Kettle
260
260
 
261
261
  # We need to sleep a bit here to ensure the terminal is ready for both
262
262
  # input and writing status updates to each workflow's line
263
- sleep(0.2) unless Kettle::Dev::IS_CI
263
+ sleep(0.2)
264
264
 
265
265
  selected = nil
266
266
  # Create input thread always so specs that assert its cleanup/exception behavior can exercise it,
@@ -6,7 +6,7 @@ module Kettle
6
6
  module Version
7
7
  # The gem version.
8
8
  # @return [String]
9
- VERSION = "1.1.25"
9
+ VERSION = "1.1.26"
10
10
 
11
11
  module_function
12
12
 
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-dev
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.25
4
+ version: 1.1.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -404,10 +404,10 @@ licenses:
404
404
  - MIT
405
405
  metadata:
406
406
  homepage_uri: https://kettle-dev.galtzo.com/
407
- source_code_uri: https://github.com/kettle-rb/kettle-dev/tree/v1.1.25
408
- changelog_uri: https://github.com/kettle-rb/kettle-dev/blob/v1.1.25/CHANGELOG.md
407
+ source_code_uri: https://github.com/kettle-rb/kettle-dev/tree/v1.1.26
408
+ changelog_uri: https://github.com/kettle-rb/kettle-dev/blob/v1.1.26/CHANGELOG.md
409
409
  bug_tracker_uri: https://github.com/kettle-rb/kettle-dev/issues
410
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.1.25
410
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.1.26
411
411
  funding_uri: https://github.com/sponsors/pboling
412
412
  wiki_uri: https://github.com/kettle-rb/kettle-dev/wiki
413
413
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
@@ -1 +1,2 @@
1
- b�H���|+�J������*ƴ�Eo�Lm�ɑ۬�QU�Gt�>�w� m指P�xx��IW.��JK�����-��>�I��>�Q�I_��z�gY��H���p��ܢ�i �c�����+� ���5��s!ڷ�C�b�� #�� ��Қ�l�9���A�Z-'��:�X����j�r)E'�o�J�=��"�UvD��g۸��'PB�xg�{��gp'��~#[dN����B��=ds�T�{7���i��'��?��d0s��Ԃb��fӇ�~u~�&2C���aA���ʹ��@�V�k���ǬPU���Ŧ$������w �qJYO'D��b���ka��O�mAd=6/�EI�f.�� �^?rE����9�ƞ
1
+ x�!���]�����G4u��Q
2
+ ��6�RM)J��_��-:�C���4Lz��