|
| 1 | +#!/usr/bin/env ruby |
| 2 | + |
| 3 | +require "bundler/inline" |
| 4 | +require "json" |
| 5 | +require "net/http" |
| 6 | +require "uri" |
| 7 | + |
| 8 | +gemfile do |
| 9 | + source "https://rubygems.org" |
| 10 | + gem "octokit" |
| 11 | + gem "faraday-retry" |
| 12 | +end |
| 13 | + |
| 14 | +Octokit.configure do |c| |
| 15 | + c.access_token = ENV["GITHUB_TOKEN"] |
| 16 | + c.auto_paginate = true |
| 17 | + c.per_page = 100 |
| 18 | +end |
| 19 | + |
| 20 | +# Build a gem=>version map from stdgems.org stdgems.json for a given Ruby version (e.g., "3.4") |
| 21 | +def fetch_default_gems_versions(ruby_version) |
| 22 | + uri = URI.parse("https://stdgems.org/stdgems.json") |
| 23 | + json = JSON.parse(Net::HTTP.get(uri)) |
| 24 | + gems = json["gems"] || [] |
| 25 | + |
| 26 | + map = {} |
| 27 | + gems.each do |g| |
| 28 | + # Only include default gems (skip ones marked removed) |
| 29 | + next if g["removed"] |
| 30 | + versions = g["versions"] || {} |
| 31 | + |
| 32 | + # versions has "default" and "bundled" keys, each containing Ruby version => version mappings |
| 33 | + selected_version = nil |
| 34 | + |
| 35 | + # Try both "default" and "bundled" categories |
| 36 | + ["default", "bundled"].each do |category| |
| 37 | + category_versions = versions[category] || {} |
| 38 | + next if selected_version |
| 39 | + |
| 40 | + if category_versions.key?(ruby_version) |
| 41 | + selected_version = category_versions[ruby_version] |
| 42 | + else |
| 43 | + # Fall back to the highest patch version matching the given major.minor |
| 44 | + major_minor = /^#{Regexp.escape(ruby_version)}\./ |
| 45 | + candidates = category_versions.select { |k, _| k.match?(major_minor) } |
| 46 | + if !candidates.empty? |
| 47 | + # Sort keys as Gem::Version to pick the highest patch |
| 48 | + selected_version = candidates.sort_by { |k, _| Gem::Version.new(k) }.last[1] |
| 49 | + end |
| 50 | + end |
| 51 | + end |
| 52 | + |
| 53 | + next unless selected_version |
| 54 | + |
| 55 | + name = g["gem"] |
| 56 | + # Normalize name to match existing special cases |
| 57 | + name = "RubyGems" if name == "rubygems" |
| 58 | + map[name] = selected_version |
| 59 | + end |
| 60 | + |
| 61 | + map |
| 62 | +end |
| 63 | + |
| 64 | +# Load gem=>version map from a file or from stdgems.org if a Ruby version is given. |
| 65 | +def load_versions(arg) |
| 66 | + if arg.nil? |
| 67 | + abort "usage: #{File.basename($0)} FROM TO (each can be a file path or Ruby version like 3.4)" |
| 68 | + end |
| 69 | + if File.exist?(arg) |
| 70 | + File.readlines(arg).map(&:split).to_h |
| 71 | + elsif arg.match?(/^\d+\.\d+(?:\.\d+)?$/) |
| 72 | + fetch_default_gems_versions(arg) |
| 73 | + elsif arg.downcase == "news" || arg =~ %r{https?://.*/NEWS\.md} |
| 74 | + fetch_versions_to_from_news(arg) |
| 75 | + else |
| 76 | + abort "Invalid argument: #{arg}. Provide a file path or a Ruby version (e.g., 3.4)." |
| 77 | + end |
| 78 | +end |
| 79 | + |
| 80 | +# Build a gem=>version map by parsing the "## Stdlib updates" section from Ruby's NEWS.md |
| 81 | +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) |
| 85 | + |
| 86 | + # Extract the Stdlib updates section |
| 87 | + start_idx = body.index(/^## Stdlib updates$/) |
| 88 | + unless start_idx |
| 89 | + # Try a more lenient search if anchors differ |
| 90 | + start_idx = body.index("## Stdlib\nupdates") || body.index("## Stdlib updates") |
| 91 | + end |
| 92 | + abort "Stdlib updates section not found in NEWS.md" unless start_idx |
| 93 | + |
| 94 | + section = body[start_idx..-1] |
| 95 | + # Stop at the next top-level section header (skip the current header line) |
| 96 | + first_line_len = section.lines.first ? section.lines.first.length : 0 |
| 97 | + stop_idx = section.index(/^##\s+/, first_line_len) |
| 98 | + section = stop_idx ? section[0...stop_idx] : section |
| 99 | + |
| 100 | + map = {} |
| 101 | + |
| 102 | + # Normalize lines and collect bullet entries like: "* gemname x.y.z" |
| 103 | + section.each_line do |line| |
| 104 | + line = line.strip |
| 105 | + next unless line.start_with?("*") |
| 106 | + # Remove leading bullet |
| 107 | + entry = line.sub(/^\*\s+/, "") |
| 108 | + |
| 109 | + # Some lines can include descriptions or links; we only take simple "name version" |
| 110 | + # Accept names with hyphens/underscores and versions like 1.2.3 or 1.2.3.4 |
| 111 | + if entry =~ /^([A-Za-z0-9_\-]+)\s+(\d+(?:\.\d+){0,3})\b/ |
| 112 | + name = $1 |
| 113 | + ver = $2 |
| 114 | + name = "RubyGems" if name.downcase == "rubygems" |
| 115 | + map[name] = ver |
| 116 | + end |
| 117 | + end |
| 118 | + |
| 119 | + map |
| 120 | +end |
| 121 | + |
| 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 | + |
| 133 | + case name |
| 134 | + when "minitest" |
| 135 | + repo = name |
| 136 | + org = "minitest" |
| 137 | + when "test-unit" |
| 138 | + repo = name |
| 139 | + org = "test-unit" |
| 140 | + when "bundler" |
| 141 | + repo = "rubygems" |
| 142 | + org = "ruby" |
| 143 | + else |
| 144 | + repo = name |
| 145 | + org = "ruby" |
| 146 | + end |
| 147 | + |
| 148 | + Octokit.releases("#{org}/#{repo}").each do |release| |
| 149 | + releases << release.tag_name |
| 150 | + end |
| 151 | + |
| 152 | + # Keep only version-like tags and sort descending by semantic version |
| 153 | + releases = releases.select { |t| t =~ /^v\d/ || t =~ /^\d/ || t =~ /^bundler-\d/ } |
| 154 | + releases = releases.sort_by { |t| Gem::Version.new(t.sub(/^bundler-/, "").sub(/^v/, "").tr("_", ".")) } |
| 155 | + |
| 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 |
| 159 | + |
| 160 | + next unless release_range |
| 161 | + next if release_range.empty? |
| 162 | + |
| 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}" |
| 168 | + end |
| 169 | +end |
| 170 | + |
| 171 | +puts footnote_link.join("\n") |
0 commit comments