Skip to content

Commit 4ab2383

Browse files
committed
Add update mode and NEWS.md parsing
Read NEWS.md locally when "news" is requested, parse stdlib updates, collect GitHub release ranges per gem, and optionally update NEWS.md in-place with sub-bullets and footnote links when --update is passed
1 parent aec4b44 commit 4ab2383

1 file changed

Lines changed: 181 additions & 38 deletions

File tree

tool/update-NEWS-github-release.rb

Lines changed: 181 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "bundler/inline"
44
require "json"
55
require "net/http"
6+
require "set"
67
require "uri"
78

89
gemfile do
@@ -31,12 +32,12 @@ def fetch_default_gems_versions(ruby_version)
3132

3233
# versions has "default" and "bundled" keys, each containing Ruby version => version mappings
3334
selected_version = nil
34-
35+
3536
# Try both "default" and "bundled" categories
3637
["default", "bundled"].each do |category|
3738
category_versions = versions[category] || {}
3839
next if selected_version
39-
40+
4041
if category_versions.key?(ruby_version)
4142
selected_version = category_versions[ruby_version]
4243
else
@@ -49,7 +50,7 @@ def fetch_default_gems_versions(ruby_version)
4950
end
5051
end
5152
end
52-
53+
5354
next unless selected_version
5455

5556
name = g["gem"]
@@ -64,7 +65,7 @@ def fetch_default_gems_versions(ruby_version)
6465
# Load gem=>version map from a file or from stdgems.org if a Ruby version is given.
6566
def load_versions(arg)
6667
if arg.nil?
67-
abort "usage: #{File.basename($0)} FROM TO (each can be a file path or Ruby version like 3.4)"
68+
abort "usage: #{File.basename($0)} FROM [--update]"
6869
end
6970
if File.exist?(arg)
7071
File.readlines(arg).map(&:split).to_h
@@ -79,10 +80,25 @@ def load_versions(arg)
7980

8081
# Build a gem=>version map by parsing the "## Stdlib updates" section from Ruby's NEWS.md
8182
def fetch_versions_to_from_news(arg)
82-
url = arg.downcase == "news" ? "https://raw.githubusercontent.com/ruby/ruby/refs/heads/master/NEWS.md" : arg
83-
uri = URI.parse(url)
84-
body = Net::HTTP.get(uri)
83+
if arg.downcase == "news"
84+
body = read_local_news_md
85+
else
86+
uri = URI.parse(arg)
87+
body = Net::HTTP.get(uri)
88+
end
8589

90+
parse_stdlib_versions_from_news(body)
91+
end
92+
93+
def read_local_news_md
94+
news_path = File.join(__dir__, "..", "NEWS.md")
95+
unless File.exist?(news_path)
96+
abort "NEWS.md not found at #{news_path}"
97+
end
98+
File.read(news_path)
99+
end
100+
101+
def parse_stdlib_versions_from_news(body)
86102
# Extract the Stdlib updates section
87103
start_idx = body.index(/^## Stdlib updates$/)
88104
unless start_idx
@@ -119,53 +135,180 @@ def fetch_versions_to_from_news(arg)
119135
map
120136
end
121137

122-
versions_from = load_versions(ARGV[0])
123-
versions_to = load_versions("news")
124-
footnote_link = []
125-
126-
versions_to.each do |name, version|
127-
# Skip items which do not exist in the FROM map to reduce API calls
128-
next unless versions_from.key?(name)
129-
next if name == "RubyGems" || name == "bundler"
130-
131-
releases = []
132-
138+
def resolve_repo(name)
133139
case name
134140
when "minitest"
135-
repo = name
136-
org = "minitest"
141+
{ repo: name, org: "minitest" }
137142
when "test-unit"
138-
repo = name
139-
org = "test-unit"
143+
{ repo: name, org: "test-unit" }
140144
when "bundler"
141-
repo = "rubygems"
142-
org = "ruby"
145+
{ repo: "rubygems", org: "ruby" }
143146
else
144-
repo = name
145-
org = "ruby"
147+
{ repo: name, org: "ruby" }
146148
end
149+
end
147150

151+
def fetch_release_range(name, from_version, to_version, org, repo)
152+
releases = []
148153
Octokit.releases("#{org}/#{repo}").each do |release|
149154
releases << release.tag_name
150155
end
151156

152-
# Keep only version-like tags and sort descending by semantic version
157+
# Keep only version-like tags and sort ascending by semantic version
153158
releases = releases.select { |t| t =~ /^v\d/ || t =~ /^\d/ || t =~ /^bundler-\d/ }
154159
releases = releases.sort_by { |t| Gem::Version.new(t.sub(/^bundler-/, "").sub(/^v/, "").tr("_", ".")) }
155160

156-
start_index = releases.index("v#{versions_from[name]}") || releases.index(versions_from[name]) || releases.index("bundler-v#{versions_from[name]}")
157-
end_index = releases.index("v#{versions_to[name]}") || releases.index(versions_to[name]) || releases.index("bundler-v#{versions_to[name]}")
158-
release_range = releases[start_index+1..end_index] if start_index && end_index
161+
start_index = releases.index("v#{from_version}") || releases.index(from_version) || releases.index("bundler-v#{from_version}")
162+
end_index = releases.index("v#{to_version}") || releases.index(to_version) || releases.index("bundler-v#{to_version}")
163+
return nil unless start_index && end_index
164+
165+
range = releases[start_index + 1..end_index]
166+
return nil if range.nil? || range.empty?
167+
168+
range
169+
end
170+
171+
def collect_gem_updates(versions_from, versions_to)
172+
results = []
173+
174+
versions_to.each do |name, version|
175+
# Skip items which do not exist in the FROM map to reduce API calls
176+
next unless versions_from.key?(name)
177+
next if name == "RubyGems" || name == "bundler"
178+
179+
info = resolve_repo(name)
180+
org = info[:org]
181+
repo = info[:repo]
182+
183+
release_range = fetch_release_range(name, versions_from[name], version, org, repo)
184+
next unless release_range
185+
186+
footnote_links = []
187+
release_range.each do |rel|
188+
footnote_links << {
189+
ref: "#{name}-#{rel.sub(/^bundler-/, '')}",
190+
url: "https://github.com/#{org}/#{repo}/releases/tag/#{rel}",
191+
tag: rel.sub(/^bundler-/, ''),
192+
}
193+
end
194+
195+
results << {
196+
name: name,
197+
version: version,
198+
from_version: versions_from[name],
199+
release_range: release_range,
200+
footnote_links: footnote_links,
201+
}
202+
end
203+
204+
results
205+
end
206+
207+
def print_results(results)
208+
footnote_lines = []
209+
210+
results.each do |r|
211+
puts "* #{r[:name]} #{r[:version]}"
212+
links = r[:release_range].map { |rel|
213+
"[#{rel.sub(/^bundler-/, '')}][#{r[:name]}-#{rel.sub(/^bundler-/, '')}]"
214+
}
215+
puts " * #{r[:from_version]} to #{links.join(', ')}"
216+
r[:footnote_links].each do |fl|
217+
footnote_lines << "[#{fl[:ref]}]: #{fl[:url]}"
218+
end
219+
end
220+
221+
puts footnote_lines.join("\n")
222+
end
223+
224+
def update_news_md(results)
225+
news_path = File.join(__dir__, "..", "NEWS.md")
226+
unless File.exist?(news_path)
227+
abort "NEWS.md not found at #{news_path}"
228+
end
229+
content = File.read(news_path)
230+
lines = content.lines
231+
232+
# Build a lookup: gem name => result
233+
result_by_name = {}
234+
results.each { |r| result_by_name[r[:name]] = r }
235+
236+
new_lines = []
237+
i = 0
238+
while i < lines.length
239+
line = lines[i]
240+
241+
# Check if this line is a gem bullet like "* gemname x.y.z"
242+
if line =~ /^\* ([A-Za-z0-9_\-]+)\s+(\d+(?:\.\d+){0,3})\b/
243+
gem_name = $1
244+
gem_name_normalized = gem_name == "RubyGems" ? "RubyGems" : gem_name
159245

160-
next unless release_range
161-
next if release_range.empty?
246+
new_lines << line
162247

163-
puts "* #{name} #{version}"
164-
puts " * #{versions_from[name]} to #{release_range.map { |rel|
165-
"[#{rel.sub(/^bundler-/, '')}][#{name}-#{rel.sub(/^bundler-/, '')}]"}.join(", ")}"
166-
release_range.each do |rel|
167-
footnote_link << "[#{name}-#{rel.sub(/^bundler-/, '')}]: https://github.com/#{org}/#{repo}/releases/tag/#{rel}"
248+
if result_by_name.key?(gem_name_normalized)
249+
r = result_by_name[gem_name_normalized]
250+
251+
# Skip any existing sub-bullet lines that follow (lines starting with spaces + *)
252+
while i + 1 < lines.length && lines[i + 1] =~ /^\s+\*/
253+
i += 1
254+
end
255+
256+
# Insert the version diff sub-bullet
257+
links = r[:release_range].map { |rel|
258+
"[#{rel.sub(/^bundler-/, '')}][#{r[:name]}-#{rel.sub(/^bundler-/, '')}]"
259+
}
260+
sub_bullet = " * #{r[:from_version]} to #{links.join(', ')}\n"
261+
new_lines << sub_bullet
262+
end
263+
else
264+
new_lines << line
265+
end
266+
i += 1
267+
end
268+
269+
# Collect all new footnote links
270+
all_footnotes = []
271+
results.each do |r|
272+
r[:footnote_links].each do |fl|
273+
all_footnotes << "[#{fl[:ref]}]: #{fl[:url]}"
274+
end
168275
end
276+
277+
# Remove any existing footnote links that we are about to add (avoid duplicates)
278+
existing_refs = Set.new(all_footnotes.map { |f| f[/^\[([^\]]+)\]:/, 1] })
279+
new_lines = new_lines.reject do |line|
280+
if line =~ /^\[([^\]]+)\]:\s+https:\/\/github\.com\//
281+
existing_refs.include?($1)
282+
else
283+
false
284+
end
285+
end
286+
287+
# Ensure the file ends with a newline before adding footnotes
288+
unless new_lines.last&.end_with?("\n")
289+
new_lines << "\n"
290+
end
291+
292+
# Append footnote links at the end of the file
293+
all_footnotes.each do |footnote|
294+
new_lines << "#{footnote}\n"
295+
end
296+
297+
File.write(news_path, new_lines.join)
298+
puts "Updated #{news_path} with #{results.length} gem update entries and #{all_footnotes.length} footnote links."
169299
end
170300

171-
puts footnote_link.join("\n")
301+
# --- Main ---
302+
303+
update_mode = ARGV.delete("--update")
304+
305+
versions_from = load_versions(ARGV[0])
306+
versions_to = load_versions("news")
307+
308+
results = collect_gem_updates(versions_from, versions_to)
309+
310+
print_results(results)
311+
312+
if update_mode
313+
update_news_md(results)
314+
end

0 commit comments

Comments
 (0)