Class: Kettle::Dev::ReadmeBackers

Inherits:
Object
  • Object
show all
Defined in:
lib/kettle/dev/readme_backers.rb

Defined Under Namespace

Classes: Backer

Constant Summary collapse

README_PATH =

Default README is the one in the current working directory of the host project

File.expand_path("README.md", Dir.pwd)
README_OSC_TAG_DEFAULT =
"OPENCOLLECTIVE"
COMMIT_SUBJECT_DEFAULT =
"💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
OC_YML_PATH =

Deprecated constant maintained for backwards compatibility in tests/specs.
Prefer OpenCollectiveConfig.yaml_path going forward, but resolve to the host project root.

OpenCollectiveConfig.yaml_path(Dir.pwd)

Instance Method Summary collapse

Constructor Details

#initialize(handle: nil, readme_path: README_PATH) ⇒ ReadmeBackers

Returns a new instance of ReadmeBackers.



62
63
64
65
# File 'lib/kettle/dev/readme_backers.rb', line 62

def initialize(handle: nil, readme_path: README_PATH)
  @handle = handle || resolve_handle
  @readme_path = readme_path
end

Instance Method Details

#run!Object



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/kettle/dev/readme_backers.rb', line 91

def run!
  validate
  debug_log("Starting run: handle=#{@handle.inspect}, readme=#{@readme_path}")
  debug_log("Resolved OSC tag base=#{readme_osc_tag.inspect}")
  readme = File.read(@readme_path)

  # Identify previous entries for diffing/mentions
  b_start, b_end = detect_backer_tags(readme)
  s_start_prev, s_end_prev = detect_sponsor_tags(readme)
  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}")
  prev_backer_identities = extract_section_identities(readme, b_start, b_end)
  prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)

  # Fetch all BACKER-role members once and partition by tier
  debug_log("Fetching OpenCollective members JSON for handle=#{@handle} ...")
  raw = fetch_all_backers_raw
  debug_log("Fetched #{Array(raw).size} members (role=#{Backer::ROLE}) before tier partitioning")
  # Build OpenCollective type-index map to generate stable avatar/website links
  index_map = build_oc_index_map(raw)
  if Kettle::Dev::DEBUGGING
    tier_counts = Array(raw).group_by { |h| (h["tier"] || "").to_s.strip }.transform_values(&:size)
    debug_log("Tier distribution: #{tier_counts}")
    empty_tier = Array(raw).select { |h| h["tier"].to_s.strip.empty? }
    unless empty_tier.empty?
      debug_log("Members with empty tier: count=#{empty_tier.size}; showing up to 5 samples:")
      empty_tier.first(5).each_with_index do |m, i|
        debug_log("  [empty-tier ##{i + 1}] name=#{m["name"].inspect}, isActive=#{m["isActive"].inspect}, profile=#{m["profile"].inspect}, website=#{m["website"].inspect}")
      end
    end
    other_tiers = Array(raw).map { |h| h["tier"].to_s.strip }.reject { |t| t.empty? || t.casecmp("Backer").zero? || t.casecmp("Sponsor").zero? }
    unless other_tiers.empty?
      counts = other_tiers.group_by { |t| t }.transform_values(&:size)
      debug_log("Non-standard tiers present (excluding Backer/Sponsor): #{counts}")
    end
  end
  backers_hashes = Array(raw).select { |h| h["tier"].to_s.strip.casecmp("Backer").zero? }
  sponsors_hashes = Array(raw).select { |h| h["tier"].to_s.strip.casecmp("Sponsor").zero? }

  backers = map_hashes_to_backers(backers_hashes, index_map, force_type: "backer")
  sponsors = map_hashes_to_backers(sponsors_hashes, index_map, force_type: "organization")
  debug_log("Partitioned counts => Backers=#{backers.size}, Sponsors=#{sponsors.size}")
  if Kettle::Dev::DEBUGGING && backers.empty? && sponsors.empty? && Array(raw).any?
    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.")
  end

  # Additional dynamic tiers (exclude Backer/Sponsor)
  extra_map = {}
  Array(raw).group_by { |h| h["tier"].to_s.strip }.each do |tier, members|
    normalized = tier.empty? ? "Donors" : tier
    next if normalized.casecmp("Backer").zero? || normalized.casecmp("Sponsor").zero?
    extra_map[normalized] = map_hashes_to_backers(members, index_map)
  end
  debug_log("Extra tiers detected: #{extra_map.keys.sort}") unless extra_map.empty?

  backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
  sponsors_md_base = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")

  extra_tiers_md = generate_extra_tiers_markdown(extra_map)
  sponsors_md = if extra_tiers_md.empty?
    sponsors_md_base
  else
    [sponsors_md_base, "", extra_tiers_md].join("\n")
  end

  # Update backers section
  # If the identities in the existing block match the identities derived from current data,
  # treat as no-change to avoid rewriting formatting (e.g., Markdown -> HTML OC anchors).
  semantically_same_backers = semantically_same_section?(prev_backer_identities, backers)
  updated = if semantically_same_backers
    :no_change
  else
    replace_between_tags(readme, b_start, b_end, backers_md)
  end
  case updated
  when :not_found
    debug_log("Backers tag block not found; skipping backers section update")
    updated_readme = readme
    backers_changed = false
    new_backers = []
  when :no_change
    debug_log("Backers section unchanged (identities match or generated markdown matches existing block)")
    updated_readme = readme
    backers_changed = false
    new_backers = []
  else
    updated_readme = updated
    backers_changed = true
    new_backers = compute_new_members(prev_backer_identities, backers)
    debug_log("Backers section updated; new_backers=#{new_backers.size}")
  end

  # Update sponsors section (with extra tiers appended when present)
  s_start, s_end = detect_sponsor_tags(updated_readme)
  # If there is no sponsors section but there is a backers section, append extra tiers to backers instead.
  if s_start == :not_found && !extra_tiers_md.empty? && b_start != :not_found
    debug_log("Sponsors tags not found; appending extra tiers under Backers section")
    backers_md_with_extra = [backers_md, "", extra_tiers_md].join("\n")
    updated = replace_between_tags(updated_readme, b_start, b_end, backers_md_with_extra)
    updated_readme = updated unless updated == :no_change || updated == :not_found
  end

  # Sponsors: apply the same semantic no-change rule
  prev_s_ids = extract_section_identities(updated_readme, s_start, s_end)
  semantically_same_sponsors = semantically_same_section?(prev_s_ids, sponsors)
  updated2 = if semantically_same_sponsors
    :no_change
  else
    replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
  end
  case updated2
  when :not_found
    debug_log("Sponsors tag block not found; skipping sponsors section update")
    sponsors_changed = false
    final = updated_readme
    new_sponsors = []
  when :no_change
    debug_log("Sponsors section unchanged (identities match or generated markdown matches existing block)")
    sponsors_changed = false
    final = updated_readme
    new_sponsors = []
  else
    sponsors_changed = true
    final = updated2
    new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
    debug_log("Sponsors section updated; new_sponsors=#{new_sponsors.size}")
  end

  if !backers_changed && !sponsors_changed
    if b_start == :not_found && s_start == :not_found
      ts = tag_strings
      warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
        "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
      debug_log("Missing tags: looked for #{ts}")
      # Do not exit the process during tests or library use; just return.
      return
    end
    debug_log("No changes detected after processing; Backers=#{backers.size}, Sponsors=#{sponsors.size}, ExtraTiers=#{extra_map.keys.size}")
    puts "No changes to backers or sponsors sections in #{@readme_path}."
    return
  end

  File.write(@readme_path, final)
  msgs = []
  msgs << "backers" if backers_changed
  msgs << "sponsors" if sponsors_changed
  puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."

  # Compose and perform commit with mentions if in a git repo
  perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)
end

#validatevoid

This method returns an undefined value.

Validate environment preconditions for running the updater.
Ensures README_UPDATER_TOKEN is present. If missing, prints guidance and raises.

Raises:

  • (RuntimeError)

    when README_UPDATER_TOKEN is not provided



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/kettle/dev/readme_backers.rb', line 72

def validate
  token = ENV["README_UPDATER_TOKEN"].to_s
  if token.strip.empty?
    repo = ENV["REPO"] || ENV["GITHUB_REPOSITORY"]
    org = repo&.to_s&.split("/")&.first
    org_url = if org && !org.strip.empty?
      "https://github.com/organizations/#{org}/settings/secrets/actions"
    else
      "https://github.com/organizations/YOUR_ORG/settings/secrets/actions"
    end
    $stderr.puts "ERROR: README_UPDATER_TOKEN is not set."
    $stderr.puts "Please create an organization-level Actions secret named README_UPDATER_TOKEN at:"
    $stderr.puts "  #{org_url}"
    $stderr.puts "Then update the workflow to reference it, or provide README_UPDATER_TOKEN in the environment."
    raise 'Missing ENV["README_UPDATER_TOKEN"]'
  end
  nil
end