Created
November 5, 2021 16:39
-
-
Save andynu/bd38f7c120936a1cdc10c927c43104e0 to your computer and use it in GitHub Desktop.
gem-versions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Presumes your projects are in subdirectories of the current directory | |
./gem-versions.rb rails # Show you what rails versions are used across your projects, noting the latest | |
./gem-versions.rb --outdated # show you all the outdated gems across all your projects |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
require 'awesome_print' | |
require 'bundler' | |
require 'bundler/lockfile_parser' | |
require 'json' | |
require 'net/http' | |
require 'nokogiri' | |
require 'optimist' | |
require 'pathname' | |
require 'pstore' | |
require 'rainbow' | |
require 'terminal-table' | |
require 'tty-tree' | |
require 'uri' | |
require 'open-uri' | |
def parse_lockfile(lockfile) | |
ENV['BUNDLE_GEMFILE'] = lockfile.dirname.join('Gemfile').to_s | |
Bundler::LockfileParser.new(File.read(lockfile)) | |
end | |
class Gem::Version | |
def parts | |
major, minor, *patch = version.split(/\./) | |
patch = patch.join('.') | |
return major, minor, patch | |
end | |
def diff_severity(other) | |
return :unknown if other.nil? | |
major, minor, patch = parts | |
o_major, o_minor, o_patch = other.parts | |
if major != o_major | |
:major | |
elsif minor != o_minor | |
:minor | |
elsif patch != o_patch | |
:patch | |
else | |
:equal | |
end | |
end | |
end | |
Project = Struct.new(:name, :lockfile, :lockparser) do | |
def [](gem_name) | |
gems.find{|gem| gem.name == gem_name } | |
end | |
def gems | |
lockparser.specs | |
end | |
def has_gem?(gem_name) | |
gems.any?{|gem| gem.name == gem_name} | |
end | |
def self.rubygems?(spec) | |
true # Internally this checks against other internal gem sources | |
end | |
end | |
def load_projects | |
lockfiles = Dir['*/Gemfile.lock'].map{|path| Pathname.new(path)} | |
lockfiles.map do |lockfile| | |
Project.new(lockfile.dirname.basename, lockfile, parse_lockfile(lockfile)) | |
end | |
end | |
class Cache | |
DB_FILE = File.expand_path('~/.gem-versions.latest-cache.pstore') | |
STALE_AFTER_SECS = 86400 # One day. | |
def initialize | |
@db = PStore.new(DB_FILE) | |
@db.transaction do |db| | |
db[:values] ||= {} | |
db[:updated_at] ||= {} | |
end | |
end | |
def [](key, &block) | |
updated_at = nil | |
value = nil | |
@db.transaction(true) do |db| | |
updated_at = db[:updated_at][key] | |
value = db[:values][key] | |
end | |
if !updated_at.nil? && updated_at > (Time.now - STALE_AFTER_SECS) | |
value | |
elsif block_given? | |
self[key] = yield | |
end | |
end | |
def []=(key, value) | |
@db.transaction do |db| | |
db[:values][key] = value | |
db[:updated_at][key] = Time.now | |
end | |
value | |
end | |
end | |
class LatestGemVersion | |
@@cache = Cache.new | |
def self.[](gem_name, source: :rubygems) | |
@@cache[gem_name] do | |
count = 0 | |
begin | |
count += 1 | |
warn 'cache miss %p (%s)' % [gem_name, source] | |
case source | |
when :rubygems | |
sleep 0.1 # for api rate limiting | |
LatestRubygemsGem[gem_name] | |
else | |
warn "Unhandled gem source '#{source}'" | |
end | |
rescue StandardError => e | |
ap e.message | |
if count < 3 | |
sleep 1 | |
retry | |
end | |
end | |
end | |
end | |
end | |
class LatestRubygemsGem | |
def self.[](gem_name) | |
response = JSON.parse(Net::HTTP.get(URI('https://rubygems.org/api/v1/versions/' + gem_name + '/latest.json'))) | |
response&.fetch('version', nil) | |
end | |
end | |
def print_gem_version(gem_name, version) | |
return nil if version.nil? | |
ver = version | |
latest = Gem::Version.new(LatestGemVersion[gem_name].to_s) rescue nil | |
severity = ver.diff_severity(latest) | |
sev_color = { major: :red, minor: :orange, patch: :yellow, equal: :green }[severity] || :white | |
if ver == latest || latest.nil? | |
ver_txt = ver.to_s | |
else | |
ver_txt = '%s [latest: %s]' % [ver, latest] | |
end | |
Rainbow(ver_txt).color(sev_color) | |
end | |
def print_table(projects, gems) | |
headers = ['project'] + gems | |
table = Terminal::Table.new headings: headers | |
projects.each do |project| | |
row = [project.name] | |
gems.each do |gem_name| | |
row << print_gem_version(gem_name, project[gem_name]&.version) | |
end | |
table << row | |
end | |
puts table | |
end | |
def print_group_table(projects, gem_name) | |
table = Terminal::Table.new headings: [:version, :projects] | |
by_ver = projects.group_by{|project| project[gem_name].version} | |
by_ver.keys.sort.each_with_index do |ver, i| | |
table << :separator unless i == 0 | |
projects = by_ver[ver].map(&:name).sort | |
table << [print_gem_version(gem_name, ver), projects.join("\n")] | |
end | |
puts table | |
end | |
def print_outdated(projects, gems) | |
by_gem_ver = {} | |
projects.sort_by(&:name).each do |proj| | |
proj.gems.each do |gem| | |
next unless gems.empty? || gems.include?(gem.name) | |
by_gem_ver[gem.name] ||= {} | |
by_gem_ver[gem.name][gem.version] ||= [] | |
by_gem_ver[gem.name][gem.version] << proj.name.to_s | |
end | |
end | |
max_gem_name_len = by_gem_ver.keys.map(&:size).max | |
by_gem_ver.keys.sort.each do |gem_name| | |
by_gem_ver[gem_name].keys.sort.each do |version| | |
projects = by_gem_ver[gem_name][version] | |
puts "%-#{max_gem_name_len}s\t%s\t%s" % [gem_name, print_gem_version(gem_name, version), projects.join(', ')] | |
end | |
puts | |
end | |
end | |
if __FILE__ == $0 | |
opts = Optimist.options do | |
opt :update_cache, 'updates the latest versions cache from rubygems.org' | |
opt :all, "List all projects even if they don't match any gems" | |
opt :version_group, 'List a gem grouped by versions', short: 'g', type: :string | |
opt :outdated, 'Show all gems and versions, noting which is latest' | |
opt :outdated_table, 'Show all gems and versions, noting which is latest (in table form)' | |
end | |
projects = load_projects | |
if opts[:update_cache] | |
# Update from Rubygems | |
gems = projects.flat_map(&:gems).select{|gem| Project.rubygems?(gem)}.map(&:name).uniq | |
gems.each { |gem_name| LatestGemVersion[gem_name, source: :rubygems] } | |
exit | |
end | |
gems = ARGV | |
gems = [opts[:version_group]] unless opts[:version_group].nil? | |
projects = projects.select{|prj| gems.any?{|gem| prj.has_gem?(gem) } } unless gems.empty? | |
if opts[:outdated] | |
print_outdated(projects, gems) | |
elsif opts[:version_group] | |
print_group_table(projects, opts[:version_group]) | |
else | |
gems = ['rails'] if gems.empty? | |
print_table(projects, gems) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment