end_of_life 0.4.1 → 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,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.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.1
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:
@@ -117,15 +131,30 @@ files:
117
131
  - end_of_life.gemspec
118
132
  - exe/end_of_life
119
133
  - exe/eol
120
- - lib/end_of_life.json
121
134
  - lib/end_of_life.rb
135
+ - lib/end_of_life/api.rb
122
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
123
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
124
149
  - 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
150
+ - lib/end_of_life/repository/search.rb
151
+ - lib/end_of_life/repository/search/query.rb
152
+ - lib/end_of_life/scanner.rb
128
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
129
158
  homepage: https://github.com/MatheusRich/end_of_life
130
159
  licenses:
131
160
  - MIT
@@ -147,7 +176,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
176
  - !ruby/object:Gem::Version
148
177
  version: '0'
149
178
  requirements: []
150
- rubygems_version: 3.6.2
179
+ rubygems_version: 3.7.1
151
180
  specification_version: 4
152
- summary: Lists repositories using end-of-life Ruby versions.
181
+ summary: Lists repositories using end-of-life software
153
182
  test_files: []
@@ -1,96 +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
- return if content.strip.empty?
11
-
12
- version = if file_name == ".ruby-version"
13
- parse_ruby_version_file(content)
14
- elsif file_name == "Gemfile.lock"
15
- parse_gemfile_lock_file(content)
16
- elsif file_name == "Gemfile"
17
- parse_gemfile_file(content)
18
- elsif file_name == ".tool-versions"
19
- parse_tool_versions_file(content)
20
- else
21
- raise ArgumentError, "Unsupported file #{file_name}"
22
- end
23
-
24
- # Gem::Version is pretty forgiving and will accept empty strings
25
- # as valid versions. This is a catch-all to ensure we don't return
26
- # a version 0, which always takes precedence over any other version
27
- # when comparing.
28
- return if version&.zero?
29
-
30
- version
31
- end
32
-
33
- private
34
-
35
- def parse_ruby_version_file(file_content)
36
- string_version = file_content.strip.delete_prefix("ruby-")
37
-
38
- RubyVersion.new(string_version)
39
- end
40
-
41
- def parse_gemfile_lock_file(file_content)
42
- with_silent_bundler do
43
- gemfile_lock_version = Bundler::LockfileParser.new(file_content).ruby_version
44
- return if gemfile_lock_version.nil?
45
-
46
- RubyVersion.new(gemfile_lock_version.delete_prefix("ruby "))
47
- end
48
- end
49
-
50
- def parse_gemfile_file(file_content)
51
- with_temp_gemfile(file_content) do |temp_gemfile|
52
- gemfile_version = temp_gemfile.ruby_version&.gem_version
53
- return if gemfile_version.nil?
54
-
55
- RubyVersion.new(gemfile_version)
56
- end
57
- end
58
-
59
- def parse_tool_versions_file(file_content)
60
- file_content
61
- .split("\n")
62
- .filter_map do |line|
63
- tool, version = line.strip.split
64
-
65
- tool == "ruby" && RubyVersion.new(version)
66
- end
67
- .first
68
- end
69
-
70
- def with_silent_bundler
71
- previous_ui = Bundler.ui
72
- Bundler.ui = Bundler::UI::Silent.new
73
-
74
- yield
75
- ensure
76
- Bundler.ui = previous_ui
77
- end
78
-
79
- def with_temp_gemfile(contents)
80
- # Bundler requires a file to parse, so we need to create a temporary file
81
- Tempfile.create("tempGemfile") do |tempfile|
82
- tempfile.write(contents)
83
- tempfile.rewind
84
- gemfile = with_silent_bundler do
85
- # This is security problem, since it runs the code inside the file
86
- Bundler::Definition.build(tempfile.path, nil, {})
87
- end
88
-
89
- yield(gemfile)
90
- rescue Bundler::BundlerError
91
- nil
92
- end
93
- end
94
- end
95
- end
96
- end