end_of_life 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ module EndOfLife
2
+ class Product
3
+ class Release < Data.define(:product, :version, :eol_date)
4
+ include Comparable
5
+
6
+ def self.ruby(version, eol_date: nil) = new(product: "ruby", version:, eol_date:)
7
+
8
+ def initialize(product:, version:, eol_date: nil)
9
+ product = Product.find(product.to_s)
10
+ eol_date = Date.parse(eol_date.to_s) if eol_date
11
+ super(product:, eol_date:, version: Gem::Version.new(version))
12
+ end
13
+
14
+ def eol?(at: Date.today)
15
+ if eol_date
16
+ eol_date <= at
17
+ else
18
+ self <= product.latest_eol_release(at: at)
19
+ end
20
+ end
21
+
22
+ ZERO = Gem::Version.new("0")
23
+ def zero? = version == ZERO
24
+
25
+ def <=>(other) = version <=> other.version
26
+
27
+ def to_s = version.to_s
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ module EndOfLife
2
+ class Product
3
+ def self.find(name) = EndOfLife.find_product(name)
4
+
5
+ attr_reader :name, :search_query, :version_detector
6
+
7
+ def initialize(name, search_query, version_detector)
8
+ @name = name.to_s.downcase
9
+ @search_query = search_query
10
+ @version_detector = version_detector
11
+ end
12
+
13
+ def eol_releases_at(date)
14
+ all_releases.filter { |release| release.eol_date <= date }
15
+ end
16
+
17
+ def latest_eol_release(at: Date.today)
18
+ eol_releases_at(at).max
19
+ end
20
+
21
+ def label = name.capitalize
22
+ def to_s = label
23
+
24
+ private
25
+
26
+ def all_releases
27
+ @all_releases ||= API.fetch_product(name)
28
+ .dig(:result, :releases)
29
+ .map { |json| Release.new(name, json[:latest][:name], json[:eolFrom]) }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,35 @@
1
+ require "stringio"
2
+
3
+ module EndOfLife
4
+ class Report < Data.define(:product, :repositories, :max_eol_date)
5
+ include Helpers::Terminal
6
+
7
+ def to_s
8
+ report = StringIO.new
9
+ report.puts
10
+
11
+ if repositories.empty?
12
+ report.puts "No repositories using EOL #{product}."
13
+ else
14
+ word = (repositories.size == 1) ? "repository" : "repositories"
15
+ report.puts "Found #{repositories.size} #{word} using EOL #{product} (<= #{product.latest_eol_release(at: max_eol_date)}):"
16
+ report.puts end_of_life_table(repositories)
17
+ end
18
+
19
+ report.string
20
+ end
21
+
22
+ def failure? = repositories.any?
23
+
24
+ private
25
+
26
+ def end_of_life_table(repositories)
27
+ headers = ["", "Repository", "#{product} version"]
28
+ rows = repositories.map.with_index(1) do |repo, i|
29
+ [i, repo.url, repo.min_release_of(product)]
30
+ end
31
+
32
+ table(headers, rows)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ module EndOfLife
2
+ class Repository
3
+ class Search
4
+ Query = Data.define(:options) do
5
+ def to_s
6
+ query = options[:product].search_query
7
+
8
+ query += if options[:repository]
9
+ " repo:#{options[:repository]}"
10
+ elsif options[:organizations]
11
+ options[:organizations].map { |org| " org:#{org}" }.join
12
+ else
13
+ " user:#{options[:user]}"
14
+ end
15
+
16
+ if options[:visibility]
17
+ query += " is:#{options[:visibility]}"
18
+ end
19
+
20
+ if options[:excludes]
21
+ words_to_exclude = options[:excludes].map { |word| "NOT #{word} " }.join
22
+
23
+ query += " #{words_to_exclude} in:name"
24
+ end
25
+
26
+ query
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ module EndOfLife
2
+ class Repository
3
+ class Search
4
+ include Dry::Monads[:result, :maybe]
5
+
6
+ attr_reader :options
7
+
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ def result
13
+ github_client.bind do |github|
14
+ github.auto_paginate = true
15
+ options[:user] ||= github.user.login
16
+
17
+ query = Query.new(options).to_s
18
+ items = github.search_repositories(query, {sort: :updated}).items
19
+
20
+ Success(
21
+ items.filter_map do |repo|
22
+ next if repo.archived && options[:skip_archived]
23
+
24
+ Repository.new(
25
+ full_name: repo.full_name,
26
+ url: repo.html_url,
27
+ github_client: github
28
+ )
29
+ end
30
+ )
31
+ rescue => e
32
+ Failure("Unexpected error: #{e}")
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def github_client
39
+ Maybe(ENV["GITHUB_TOKEN"])
40
+ .fmap { |token| Octokit::Client.new(access_token: token) }
41
+ .or { Failure("Please set GITHUB_TOKEN environment variable") }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,61 +1,7 @@
1
1
  module EndOfLife
2
2
  class Repository
3
3
  class << self
4
- include Dry::Monads[:result, :maybe]
5
-
6
- def fetch(options)
7
- github_client.bind do |github|
8
- github.auto_paginate = true
9
- options[:user] ||= github.user.login
10
-
11
- query = search_query_for(options)
12
- items = github.search_repositories(query, {sort: :updated}).items
13
-
14
- Success(
15
- items.filter_map do |repo|
16
- next if repo.archived && options[:skip_archived]
17
-
18
- Repository.new(
19
- full_name: repo.full_name,
20
- url: repo.html_url,
21
- github_client: github
22
- )
23
- end
24
- )
25
- rescue => e
26
- Failure("Unexpected error: #{e}")
27
- end
28
- end
29
-
30
- def github_client
31
- Maybe(ENV["GITHUB_TOKEN"])
32
- .fmap { |token| Octokit::Client.new(access_token: token) }
33
- .or { Failure("Please set GITHUB_TOKEN environment variable") }
34
- end
35
-
36
- def search_query_for(options)
37
- query = "language:ruby"
38
-
39
- query += if options[:repository]
40
- " repo:#{options[:repository]}"
41
- elsif options[:organizations]
42
- options[:organizations].map { |org| " org:#{org}" }.join
43
- else
44
- " user:#{options[:user]}"
45
- end
46
-
47
- if options[:visibility]
48
- query += " is:#{options[:visibility]}"
49
- end
50
-
51
- if options[:excludes]
52
- words_to_exclude = options[:excludes].map { |word| "NOT #{word} " }.join
53
-
54
- query += " #{words_to_exclude} in:name"
55
- end
56
-
57
- query
58
- end
4
+ def search(options) = Search.new(options).result
59
5
  end
60
6
 
61
7
  attr_reader :full_name, :url
@@ -64,39 +10,33 @@ module EndOfLife
64
10
  @full_name = full_name
65
11
  @url = url
66
12
  @github_client = github_client
13
+ @product_releases = {}
67
14
  end
68
15
 
69
- def eol_ruby?(at: Date.today)
70
- ruby_version&.eol?(at: at)
16
+ def using_eol?(product, at: Date.today)
17
+ min_release_of(product)&.eol?(at: at)
71
18
  end
72
19
 
73
- def ruby_version
74
- return @ruby_version if defined?(@ruby_version)
75
-
76
- @ruby_version = ruby_versions.min
77
- end
20
+ def min_release_of(product) = releases_for(product).min
78
21
 
79
22
  private
80
23
 
81
- def ruby_versions
82
- return @ruby_versions if defined?(@ruby_versions)
83
-
84
- @ruby_versions = fetch_ruby_version_files.filter_map { |file|
85
- parse_version_file(file)
86
- }
24
+ def releases_for(product)
25
+ @product_releases[product] ||= product.version_detector.detect_all(
26
+ fetch_files(product.version_detector.relevant_files)
27
+ )
87
28
  end
88
29
 
89
- POSSIBLE_RUBY_VERSION_FILES = [
90
- ".ruby-version",
91
- "Gemfile.lock",
92
- "Gemfile",
93
- ".tool-versions"
94
- ]
95
- def fetch_ruby_version_files
30
+ def fetch_files(file_paths)
96
31
  Sync do
97
- POSSIBLE_RUBY_VERSION_FILES
32
+ file_paths
98
33
  .map { |file_path| Async { fetch_file(file_path) } }
99
- .filter_map(&:wait)
34
+ .filter_map { |task|
35
+ file = task.wait
36
+ next if file.nil?
37
+
38
+ InMemoryFile.new(file.path, decode_file(file))
39
+ }
100
40
  end
101
41
  end
102
42
 
@@ -106,10 +46,6 @@ module EndOfLife
106
46
  nil
107
47
  end
108
48
 
109
- def parse_version_file(file)
110
- RubyVersion.from_file(file_name: file.name, content: decode_file(file))
111
- end
112
-
113
49
  def decode_file(file)
114
50
  return file.content if file.encoding.nil?
115
51
  return Base64.decode64(file.content) if file.encoding == "base64"
@@ -0,0 +1,41 @@
1
+ module EndOfLife
2
+ module Scanner
3
+ include Helpers::Terminal
4
+ extend self
5
+
6
+ def scan(options)
7
+ fetch_repositories(options)
8
+ .fmap { |repositories| filter_repositories_with_eol_products(repositories, **options.slice(:product, :max_eol_date)) }
9
+ .fmap { |repositories| output_report(repositories, **options.slice(:product, :max_eol_date)) }
10
+ .or { |error| abort "\n#{error_msg(error)}" }
11
+ end
12
+
13
+ private
14
+
15
+ def fetch_repositories(options)
16
+ with_loading_spinner("Searching repositories with #{options[:product].label}...") do |spinner|
17
+ result = Repository.search(options)
18
+ spinner.error if result.failure?
19
+
20
+ result
21
+ end
22
+ end
23
+
24
+ def filter_repositories_with_eol_products(repositories, product:, max_eol_date:)
25
+ with_loading_spinner("Searching for EOL #{product} in your repositories...") do
26
+ Sync do
27
+ repositories
28
+ .map { |repo| Async { [repo, repo.using_eol?(product, at: max_eol_date)] } }.map(&:wait)
29
+ .filter_map { |repo, contains_eol| contains_eol ? repo : nil }
30
+ end
31
+ end
32
+ end
33
+
34
+ def output_report(repositories, product:, max_eol_date:)
35
+ report = Report.new(product, repositories, max_eol_date)
36
+ puts report
37
+
38
+ exit(1) if report.failure?
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndOfLife
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -0,0 +1,27 @@
1
+ module EndOfLife
2
+ module VersionDetector
3
+ def file_detectors
4
+ @file_detectors ||= {}
5
+ end
6
+
7
+ def detects_from(file, &block)
8
+ file_detectors[file] = block
9
+ end
10
+
11
+ def relevant_files = file_detectors.keys
12
+
13
+ def detect_all(files)
14
+ files.filter_map { |file| detect(file) }
15
+ end
16
+
17
+ def detect(file)
18
+ return if file.read.strip.empty?
19
+ detector = file_detectors[File.basename(file.path)] or return
20
+ version = detector.call(file.read) or return
21
+
22
+ return if version.zero?
23
+
24
+ version
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ module EndOfLife
2
+ module VersionDetectors
3
+ module Rails
4
+ extend VersionDetector
5
+
6
+ detects_from "Gemfile.lock" do |file_content|
7
+ gemfile_lock = Parsers::GemfileLock.parse(file_content) or next
8
+ rails = gemfile_lock.specs.find { |it| it.name == "rails" } or next
9
+
10
+ Product::Release.new(product: "rails", version: rails.version)
11
+ end
12
+
13
+ detects_from "Gemfile" do |file_content|
14
+ gemfile = Parsers::Gemfile.parse(file_content) or next
15
+ rails_dep = gemfile.dependencies.find { |it| it.name == "rails" } or next
16
+ exact_version = rails_dep
17
+ .requirement
18
+ .requirements
19
+ .filter { |op, _| op == "=" } # standard:disable Style/HashSlice
20
+ .max_by { |_, version| version } or next
21
+
22
+ Product::Release.new(product: "rails", version: exact_version.last)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ require "bundler"
2
+
3
+ module EndOfLife
4
+ module VersionDetectors
5
+ module Ruby
6
+ extend VersionDetector
7
+
8
+ detects_from ".ruby-version" do |file_content|
9
+ string_version = Parsers::RubyVersion.parse(file_content)
10
+
11
+ Product::Release.ruby(string_version)
12
+ end
13
+
14
+ detects_from "Gemfile.lock" do |file_content|
15
+ gemfile_lock_version = Parsers::GemfileLock.parse(file_content).ruby_version or next
16
+
17
+ Product::Release.ruby(gemfile_lock_version.delete_prefix("ruby "))
18
+ end
19
+
20
+ detects_from "Gemfile" do |file_content|
21
+ gemfile = Parsers::Gemfile.parse(file_content)
22
+ gemfile_version = gemfile&.ruby_version&.gem_version or next
23
+
24
+ Product::Release.ruby(gemfile_version)
25
+ end
26
+
27
+ detects_from ".tool-versions" do |file_content|
28
+ tool_versions = Parsers::ToolVersions.parse(file_content)
29
+ ruby_version = tool_versions["ruby"] or next
30
+
31
+ Product::Release.ruby(ruby_version)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,17 @@
1
+ module EndOfLife
2
+ module VersionDetectors
3
+ extend self
4
+
5
+ def for_product(product)
6
+ detector_class = "#{self}::#{camelize(product.to_s)}"
7
+
8
+ const_get(detector_class)
9
+ rescue NameError
10
+ raise ArgumentError, "Undefined version detector for product: #{product}"
11
+ end
12
+
13
+ private
14
+
15
+ def camelize(word) = Zeitwerk::Inflector.new.camelize(word, nil)
16
+ end
17
+ end
data/lib/end_of_life.rb CHANGED
@@ -5,13 +5,16 @@ require "dry-monads"
5
5
  require "json"
6
6
  require "base64"
7
7
  require "octokit"
8
- require_relative "end_of_life/options"
9
- require_relative "end_of_life/repository"
10
- require_relative "end_of_life/ruby_version"
11
- require_relative "end_of_life/terminal_helper"
12
- require_relative "end_of_life/version"
13
- require_relative "end_of_life/cli"
8
+ require "zeitwerk"
9
+
10
+ Zeitwerk::Loader.for_gem.tap { |it|
11
+ it.inflector.inflect("cli" => "CLI", "api" => "API")
12
+ }.setup
14
13
 
15
14
  module EndOfLife
16
- extend TerminalHelper
15
+ extend Product::Registry
16
+ extend Helpers::Terminal
17
+
18
+ scans_for :ruby, search_query: "language:ruby"
19
+ scans_for :rails, search_query: "language:ruby"
17
20
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: end_of_life
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matheus Richard
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-05-23 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: async
@@ -93,8 +93,22 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: 0.12.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: zeitwerk
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '2.7'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '2.7'
96
110
  description: Searches your GitHub repositores and lists the ones using end-of-life,
97
- i.e. unmaintained, Ruby versions.
111
+ i.e. unmaintained, software versions.
98
112
  email:
99
113
  - matheusrichardt@gmail.com
100
114
  executables:
@@ -104,7 +118,6 @@ extensions: []
104
118
  extra_rdoc_files: []
105
119
  files:
106
120
  - ".rspec"
107
- - ".standard.yml"
108
121
  - ".tool-versions"
109
122
  - CHANGELOG.md
110
123
  - Gemfile
@@ -118,15 +131,30 @@ files:
118
131
  - end_of_life.gemspec
119
132
  - exe/end_of_life
120
133
  - exe/eol
121
- - lib/end_of_life.json
122
134
  - lib/end_of_life.rb
135
+ - lib/end_of_life/api.rb
123
136
  - lib/end_of_life/cli.rb
137
+ - lib/end_of_life/helpers/silent_bundler.rb
138
+ - lib/end_of_life/helpers/terminal.rb
139
+ - lib/end_of_life/in_memory_file.rb
124
140
  - lib/end_of_life/options.rb
141
+ - lib/end_of_life/parsers/gemfile.rb
142
+ - lib/end_of_life/parsers/gemfile_lock.rb
143
+ - lib/end_of_life/parsers/ruby_version.rb
144
+ - lib/end_of_life/parsers/tool_versions.rb
145
+ - lib/end_of_life/product.rb
146
+ - lib/end_of_life/product/registry.rb
147
+ - lib/end_of_life/product/release.rb
148
+ - lib/end_of_life/report.rb
125
149
  - lib/end_of_life/repository.rb
126
- - lib/end_of_life/ruby_version.rb
127
- - lib/end_of_life/ruby_version/parser.rb
128
- - lib/end_of_life/terminal_helper.rb
150
+ - lib/end_of_life/repository/search.rb
151
+ - lib/end_of_life/repository/search/query.rb
152
+ - lib/end_of_life/scanner.rb
129
153
  - lib/end_of_life/version.rb
154
+ - lib/end_of_life/version_detector.rb
155
+ - lib/end_of_life/version_detectors.rb
156
+ - lib/end_of_life/version_detectors/rails.rb
157
+ - lib/end_of_life/version_detectors/ruby.rb
130
158
  homepage: https://github.com/MatheusRich/end_of_life
131
159
  licenses:
132
160
  - MIT
@@ -148,7 +176,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
176
  - !ruby/object:Gem::Version
149
177
  version: '0'
150
178
  requirements: []
151
- rubygems_version: 3.6.2
179
+ rubygems_version: 3.7.1
152
180
  specification_version: 4
153
- summary: Lists repositories using end-of-life Ruby versions.
181
+ summary: Lists repositories using end-of-life software
154
182
  test_files: []
data/.standard.yml DELETED
@@ -1 +0,0 @@
1
- ruby_version: 2.7.1
@@ -1,88 +0,0 @@
1
- require "bundler"
2
- require "tempfile"
3
-
4
- module EndOfLife
5
- class RubyVersion
6
- module Parser
7
- extend self
8
-
9
- def parse_file(file_name:, content:)
10
- if file_name == ".ruby-version"
11
- parse_ruby_version_file(content)
12
- elsif file_name == "Gemfile.lock"
13
- parse_gemfile_lock_file(content)
14
- elsif file_name == "Gemfile"
15
- parse_gemfile_file(content)
16
- elsif file_name == ".tool-versions"
17
- parse_tool_versions_file(content)
18
- else
19
- raise ArgumentError, "Unsupported file #{file_name}"
20
- end
21
- end
22
-
23
- private
24
-
25
- def parse_ruby_version_file(file_content)
26
- string_version = file_content.strip.delete_prefix("ruby-")
27
-
28
- RubyVersion.new(string_version)
29
- end
30
-
31
- def parse_gemfile_lock_file(file_content)
32
- with_silent_bundler do
33
- gemfile_lock_version = Bundler::LockfileParser.new(file_content).ruby_version
34
- return if gemfile_lock_version.nil?
35
-
36
- RubyVersion.new(gemfile_lock_version.delete_prefix("ruby "))
37
- end
38
- end
39
-
40
- def parse_gemfile_file(file_content)
41
- return if file_content.empty?
42
-
43
- with_temp_gemfile(file_content) do |temp_gemfile|
44
- gemfile_version = temp_gemfile.ruby_version&.gem_version
45
- return nil if gemfile_version.nil?
46
-
47
- RubyVersion.new(gemfile_version)
48
- end
49
- end
50
-
51
- def parse_tool_versions_file(file_content)
52
- file_content
53
- .split("\n")
54
- .filter_map do |line|
55
- tool, version = line.strip.split
56
-
57
- tool == "ruby" && RubyVersion.new(version)
58
- end
59
- .first
60
- end
61
-
62
- def with_silent_bundler
63
- previous_ui = Bundler.ui
64
- Bundler.ui = Bundler::UI::Silent.new
65
-
66
- yield
67
- ensure
68
- Bundler.ui = previous_ui
69
- end
70
-
71
- def with_temp_gemfile(contents)
72
- # Bundler requires a file to parse, so we need to create a temporary file
73
- Tempfile.create("tempGemfile") do |tempfile|
74
- tempfile.write(contents)
75
- tempfile.rewind
76
- gemfile = with_silent_bundler do
77
- # This is security problem, since it runs the code inside the file
78
- Bundler::Definition.build(tempfile.path, nil, {})
79
- end
80
-
81
- yield(gemfile)
82
- rescue Bundler::BundlerError
83
- nil
84
- end
85
- end
86
- end
87
- end
88
- end