end_of_life 0.4.1 → 0.5.1

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.1"
4
+ VERSION = "0.5.1"
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.1
4
+ version: 0.5.1
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
@@ -23,6 +23,26 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.3.0
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '3'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 2.3.0
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '3'
26
46
  - !ruby/object:Gem::Dependency
27
47
  name: dry-monads
28
48
  requirement: !ruby/object:Gem::Requirement
@@ -93,8 +113,23 @@ dependencies:
93
113
  - - "~>"
94
114
  - !ruby/object:Gem::Version
95
115
  version: 0.12.0
96
- description: Searches your GitHub repositores and lists the ones using end-of-life,
97
- i.e. unmaintained, Ruby versions.
116
+ - !ruby/object:Gem::Dependency
117
+ name: zeitwerk
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '2.7'
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '2.7'
130
+ description: |
131
+ Searches your GitHub repositores and lists the ones using end-of-life, i.e.
132
+ unmaintained, software.
98
133
  email:
99
134
  - matheusrichardt@gmail.com
100
135
  executables:
@@ -117,15 +152,30 @@ files:
117
152
  - end_of_life.gemspec
118
153
  - exe/end_of_life
119
154
  - exe/eol
120
- - lib/end_of_life.json
121
155
  - lib/end_of_life.rb
156
+ - lib/end_of_life/api.rb
122
157
  - lib/end_of_life/cli.rb
158
+ - lib/end_of_life/helpers/silent_bundler.rb
159
+ - lib/end_of_life/helpers/terminal.rb
160
+ - lib/end_of_life/in_memory_file.rb
123
161
  - lib/end_of_life/options.rb
162
+ - lib/end_of_life/parsers/gemfile.rb
163
+ - lib/end_of_life/parsers/gemfile_lock.rb
164
+ - lib/end_of_life/parsers/ruby_version.rb
165
+ - lib/end_of_life/parsers/tool_versions.rb
166
+ - lib/end_of_life/product.rb
167
+ - lib/end_of_life/product/registry.rb
168
+ - lib/end_of_life/product/release.rb
169
+ - lib/end_of_life/report.rb
124
170
  - lib/end_of_life/repository.rb
125
- - lib/end_of_life/ruby_version.rb
126
- - lib/end_of_life/ruby_version/parser.rb
127
- - lib/end_of_life/terminal_helper.rb
171
+ - lib/end_of_life/repository/search.rb
172
+ - lib/end_of_life/repository/search/query.rb
173
+ - lib/end_of_life/scanner.rb
128
174
  - lib/end_of_life/version.rb
175
+ - lib/end_of_life/version_detector.rb
176
+ - lib/end_of_life/version_detectors.rb
177
+ - lib/end_of_life/version_detectors/rails.rb
178
+ - lib/end_of_life/version_detectors/ruby.rb
129
179
  homepage: https://github.com/MatheusRich/end_of_life
130
180
  licenses:
131
181
  - MIT
@@ -147,7 +197,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
197
  - !ruby/object:Gem::Version
148
198
  version: '0'
149
199
  requirements: []
150
- rubygems_version: 3.6.2
200
+ rubygems_version: 3.6.9
151
201
  specification_version: 4
152
- summary: Lists repositories using end-of-life Ruby versions.
202
+ summary: Lists repositories using end-of-life software
153
203
  test_files: []