33require "bundler/inline"
44require "json"
55require "net/http"
6+ require "set"
67require "uri"
78
89gemfile 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.
6566def 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
8182def 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
120136end
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."
169299end
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