|
| 1 | +# rubocop:disable Metrics/BlockLength |
| 2 | +namespace :gem_freshness do |
| 3 | + desc 'Generate a report of gem freshness' |
| 4 | + task report: :environment do |
| 5 | + require 'bundler' |
| 6 | + require 'net/http' |
| 7 | + require 'oj' |
| 8 | + require 'date' |
| 9 | + require 'rubygems/version' |
| 10 | + |
| 11 | + GEMFILE = ENV.fetch('GEMFILE', 'Gemfile') |
| 12 | + LOCKFILE = ENV.fetch('LOCKFILE', 'Gemfile.lock') |
| 13 | + |
| 14 | + def http_get_json(url) |
| 15 | + uri = URI(url) |
| 16 | + res = Net::HTTP.get_response(uri) |
| 17 | + return nil unless res.is_a?(Net::HTTPSuccess) |
| 18 | + |
| 19 | + Oj.load(res.body) |
| 20 | + rescue StandardError |
| 21 | + nil |
| 22 | + end |
| 23 | + |
| 24 | + def rubygems_latest_info(name) |
| 25 | + http_get_json("https://rubygems.org/api/v1/gems/#{name}.json") |
| 26 | + end |
| 27 | + |
| 28 | + def rubygems_versions(name) |
| 29 | + http_get_json("https://rubygems.org/api/v1/versions/#{name}.json") |
| 30 | + end |
| 31 | + |
| 32 | + def parse_date(date) |
| 33 | + return nil if date.nil? || date.to_s.empty? |
| 34 | + |
| 35 | + DateTime.parse(date).to_date |
| 36 | + rescue StandardError |
| 37 | + nil |
| 38 | + end |
| 39 | + |
| 40 | + def fmt_date(date) |
| 41 | + date ? date.strftime('%Y-%m-%d') : '-' |
| 42 | + end |
| 43 | + |
| 44 | + unless File.exist?(GEMFILE) && File.exist?(LOCKFILE) |
| 45 | + warn "Missing #{GEMFILE} or #{LOCKFILE} in current directory." |
| 46 | + exit 1 |
| 47 | + end |
| 48 | + |
| 49 | + definition = Bundler::Definition.build(GEMFILE, LOCKFILE, nil) |
| 50 | + direct_names = definition.dependencies.to_set(&:name) |
| 51 | + |
| 52 | + lock = Bundler::LockfileParser.new(Bundler.read_file(LOCKFILE)) |
| 53 | + locked_specs = lock.specs.sort_by(&:name) |
| 54 | + |
| 55 | + rows = [] |
| 56 | + total = 0 |
| 57 | + outdated = 0 |
| 58 | + no_ruby_gems_data = 0 |
| 59 | + |
| 60 | + locked_specs.each do |spec| |
| 61 | + name = spec.name |
| 62 | + current_v = Gem::Version.new(spec.version.to_s) |
| 63 | + |
| 64 | + latest_info = rubygems_latest_info(name) |
| 65 | + versions = rubygems_versions(name) |
| 66 | + |
| 67 | + total += 1 |
| 68 | + |
| 69 | + if latest_info.nil? || versions.nil? |
| 70 | + no_ruby_gems_data += 1 |
| 71 | + rows << { |
| 72 | + type: direct_names.include?(name) ? 'direct' : 'transitive', |
| 73 | + name: name, |
| 74 | + current: current_v.to_s, |
| 75 | + current_date: nil, |
| 76 | + latest: nil, |
| 77 | + latest_date: nil, |
| 78 | + status: 'Could not fetch RubyGems data' |
| 79 | + } |
| 80 | + next |
| 81 | + end |
| 82 | + |
| 83 | + latest_v_str = latest_info['version'] |
| 84 | + latest_v = latest_v_str ? Gem::Version.new(latest_v_str) : nil |
| 85 | + |
| 86 | + # versions endpoint returns array like: |
| 87 | + # [{"number":"x.y.z","created_at":"...","prerelease":false,...}, ...] |
| 88 | + by_number = {} |
| 89 | + versions.each do |v| |
| 90 | + num = v['number'] |
| 91 | + by_number[num] = parse_date(v['created_at']) if num |
| 92 | + end |
| 93 | + |
| 94 | + current_date = by_number[current_v.to_s] |
| 95 | + latest_date = parse_date(latest_info['version_created_at']) || (latest_v ? by_number[latest_v.to_s] : nil) |
| 96 | + |
| 97 | + status = |
| 98 | + if latest_v && latest_v > current_v |
| 99 | + outdated += 1 |
| 100 | + 'OUTDATED' |
| 101 | + else |
| 102 | + 'ok' |
| 103 | + end |
| 104 | + |
| 105 | + rows << { |
| 106 | + type: direct_names.include?(name) ? 'direct' : 'transitive', |
| 107 | + name: name, |
| 108 | + current: current_v.to_s, |
| 109 | + current_date: current_date, |
| 110 | + latest: latest_v&.to_s, |
| 111 | + latest_date: latest_date, |
| 112 | + status: status |
| 113 | + } |
| 114 | + end |
| 115 | + |
| 116 | + # Markdown output |
| 117 | + puts '# Ruby dependencies report' |
| 118 | + puts |
| 119 | + puts "- Generated: `#{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}`" |
| 120 | + puts "- Gemfile: `#{GEMFILE}`" |
| 121 | + puts "- Lockfile: `#{LOCKFILE}`" |
| 122 | + puts |
| 123 | + |
| 124 | + puts '| Type | Gem | Current | Current released | Latest | Latest released | Status |' |
| 125 | + puts '|---|---|---:|---:|---:|---:|---|' |
| 126 | + |
| 127 | + rows.each do |r| |
| 128 | + current_date = fmt_date(r[:current_date]) |
| 129 | + latest_date = fmt_date(r[:latest_date]) |
| 130 | + puts "| #{r[:type]} | `#{r[:name]}` | `#{r[:current]}` | #{current_date} | #{r[:latest] ? "`#{r[:latest]}`" : '-'} | #{latest_date} | #{r[:status]} |" |
| 131 | + end |
| 132 | + |
| 133 | + puts |
| 134 | + puts '## Summary' |
| 135 | + puts |
| 136 | + puts "- Total gems: **#{total}**" |
| 137 | + puts "- Outdated: **#{outdated}**" |
| 138 | + puts "- No RubyGems API data: **#{no_ruby_gems_data}**" |
| 139 | + puts |
| 140 | + end |
| 141 | +end |
| 142 | +# rubocop:enable Metrics/BlockLength |
0 commit comments