Skip to content

Instantly share code, notes, and snippets.

@andynu
Created November 5, 2021 16:39
Show Gist options
  • Save andynu/bd38f7c120936a1cdc10c927c43104e0 to your computer and use it in GitHub Desktop.
Save andynu/bd38f7c120936a1cdc10c927c43104e0 to your computer and use it in GitHub Desktop.
gem-versions
# 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
#!/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