end_of_life 0.5.0 → 1.0.0.alpha

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,35 @@
1
+ module EndOfLife
2
+ module Helpers::Time
3
+ include Helpers::Text
4
+
5
+ def relative_time_in_words(date)
6
+ days_away = (date - Date.today).to_i
7
+ return "today" if days_away.zero?
8
+
9
+ duration = duration_in_words(days_away.abs)
10
+
11
+ if days_away.positive?
12
+ "in #{duration}"
13
+ else
14
+ "#{duration} ago"
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def duration_in_words(number_of_days)
21
+ if number_of_days >= 365
22
+ years = (number_of_days / 365.0).floor
23
+ pluralize(years, "year")
24
+ elsif number_of_days >= 30
25
+ months = (number_of_days / 30.0).floor
26
+ pluralize(months, "month")
27
+ elsif number_of_days >= 14
28
+ weeks = (number_of_days / 7.0).floor
29
+ pluralize(weeks, "week")
30
+ else
31
+ pluralize(number_of_days, "day")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -22,7 +22,7 @@ module EndOfLife
22
22
  tempfile.rewind
23
23
 
24
24
  yield(tempfile)
25
- rescue Bundler::BundlerError
25
+ rescue Bundler::BundlerError, Errno::ENOENT # Bundler tries to read the version file, and in some versions it raises this error
26
26
  nil # NOTE: maybe a Null object would be cleaner
27
27
  end
28
28
  end
@@ -8,6 +8,8 @@ module EndOfLife
8
8
  silence_bundler do
9
9
  Bundler::LockfileParser.new(file_content)
10
10
  end
11
+ rescue Bundler::BundlerError # outside a bundler project
12
+ nil
11
13
  end
12
14
  end
13
15
  end
@@ -0,0 +1,15 @@
1
+ require "perfect_toml"
2
+
3
+ module EndOfLife
4
+ module Parsers
5
+ module MiseToml
6
+ extend self
7
+
8
+ def parse(file_content)
9
+ PerfectTOML.parse(file_content)["tools"]
10
+ rescue PerfectTOML::ParseError
11
+ nil
12
+ end
13
+ end
14
+ end
15
+ end
@@ -10,6 +10,7 @@ module EndOfLife
10
10
  line = line.strip
11
11
  next if line.start_with?("#") || line.empty?
12
12
 
13
+ line = line.split("#").first.strip # inline comments
13
14
  tool, version, * = line.split
14
15
 
15
16
  next if version == "latest"
@@ -1,21 +1,22 @@
1
1
  module EndOfLife
2
2
  module Product::Registry
3
- def scans_for(product_name, search_query:)
3
+ def scans_for(product_name, label: nil)
4
4
  product_registry[product_name.to_sym.downcase] = Product.new(
5
5
  product_name,
6
- search_query,
7
- version_detector_for(product_name)
6
+ version_detector_for(product_name),
7
+ label
8
8
  )
9
9
  end
10
10
 
11
11
  def find_product(name) = product_registry.fetch(name.to_sym.downcase)
12
12
 
13
13
  def products = product_registry.values
14
+ def products_pattern(suffix: nil) = /\A(?:#{products.map(&:name).join("|")})#{suffix}/i
14
15
 
15
16
  private
16
17
 
17
18
  def product_registry
18
- @@product_registry ||= {}
19
+ @product_registry ||= {}
19
20
  end
20
21
 
21
22
  def version_detector_for(product) = VersionDetectors.for_product(product)
@@ -3,6 +3,17 @@ module EndOfLife
3
3
  class Release < Data.define(:product, :version, :eol_date)
4
4
  include Comparable
5
5
 
6
+ def self.parse!(string)
7
+ product, version = string.split("@", 2)
8
+ raise ArgumentError, "Invalid product release format: #{string}" if product.to_s.empty? || version.to_s.empty?
9
+
10
+ begin
11
+ new(product:, version:)
12
+ rescue ArgumentError
13
+ raise ArgumentError, "Malformed version number string: #{version}"
14
+ end
15
+ end
16
+
6
17
  def self.ruby(version, eol_date: nil) = new(product: "ruby", version:, eol_date:)
7
18
 
8
19
  def initialize(product:, version:, eol_date: nil)
@@ -19,12 +30,22 @@ module EndOfLife
19
30
  end
20
31
  end
21
32
 
33
+ def supported?(...) = !eol?(...)
34
+
35
+ def latest_release_in_cycle
36
+ product.all_releases.filter { |r| r.version.to_s.start_with?(cycle_version.to_s) }.max
37
+ end
38
+
39
+ def cycle_version
40
+ Gem::Version.new(version.segments.first(2).join("."))
41
+ end
42
+
22
43
  ZERO = Gem::Version.new("0")
23
44
  def zero? = version == ZERO
24
45
 
25
46
  def <=>(other) = version <=> other.version
26
47
 
27
- def to_s = version.to_s
48
+ def to_s = "#{product.name}@#{version}"
28
49
  end
29
50
  end
30
51
  end
@@ -2,31 +2,32 @@ module EndOfLife
2
2
  class Product
3
3
  def self.find(name) = EndOfLife.find_product(name)
4
4
 
5
- attr_reader :name, :search_query, :version_detector
5
+ attr_reader :name, :version_detector, :label
6
6
 
7
- def initialize(name, search_query, version_detector)
7
+ def initialize(name, version_detector, label = nil)
8
8
  @name = name.to_s.downcase
9
- @search_query = search_query
10
9
  @version_detector = version_detector
10
+ @label = label || @name.capitalize
11
11
  end
12
12
 
13
13
  def eol_releases_at(date)
14
- all_releases.filter { |release| release.eol_date <= date }
14
+ all_releases.filter { |release| release.eol_date.nil? || release.eol_date <= date }
15
15
  end
16
16
 
17
17
  def latest_eol_release(at: Date.today)
18
18
  eol_releases_at(at).max
19
19
  end
20
20
 
21
- def label = name.capitalize
22
- def to_s = label
23
-
24
- private
25
-
26
21
  def all_releases
27
22
  @all_releases ||= API.fetch_product(name)
28
23
  .dig(:result, :releases)
29
24
  .map { |json| Release.new(name, json[:latest][:name], json[:eolFrom]) }
30
25
  end
26
+
27
+ def search_query
28
+ version_detector.relevant_files.map { |f| %(filename:"#{f}") }.join(" ")
29
+ end
30
+
31
+ def to_s = label
31
32
  end
32
33
  end
@@ -14,11 +14,22 @@ module EndOfLife
14
14
  github.auto_paginate = true
15
15
  options[:user] ||= github.user.login
16
16
 
17
+ # GitHub doesn't have a way to get repos that contain specific files.
18
+ # The language filter sort of works, but it might miss some repos that
19
+ # use a language, but it's not the main one.
20
+ #
21
+ # We have to use the code search endpoint to find files matching the
22
+ # product we're interested in and then extract the repositories from
23
+ # the results.
17
24
  query = Query.new(options).to_s
18
- items = github.search_repositories(query, {sort: :updated}).items
25
+ repo_names = github.search_code(query).items.map { |item| item.repository.full_name }.uniq
26
+ return Success([]) if repo_names.empty?
27
+
28
+ repos_query = repo_names.map { |name| "repo:#{name}" }.join(" ")
29
+ repos = github.search_repositories(repos_query, {sort: :updated}).items
19
30
 
20
31
  Success(
21
- items.filter_map do |repo|
32
+ repos.filter_map do |repo|
22
33
  next if repo.archived && options[:skip_archived]
23
34
 
24
35
  Repository.new(
@@ -1,3 +1,6 @@
1
+ require "base64"
2
+ require "octokit"
3
+
1
4
  module EndOfLife
2
5
  class Repository
3
6
  class << self
@@ -0,0 +1,37 @@
1
+ require "stringio"
2
+
3
+ module EndOfLife
4
+ module Scanner
5
+ class Report < Data.define(:product, :repositories, :max_eol_date)
6
+ include Helpers::Text
7
+ include Helpers::Terminal
8
+
9
+ def to_s
10
+ report = StringIO.new
11
+ report.puts
12
+
13
+ if repositories.empty?
14
+ report.puts "No repositories using EOL #{product}."
15
+ else
16
+ report.puts "Found #{pluralize(repositories.size, "repository", "repositories")} using EOL #{product} (<= #{product.latest_eol_release(at: max_eol_date)}):"
17
+ report.puts end_of_life_table(repositories)
18
+ end
19
+
20
+ report.string
21
+ end
22
+
23
+ def failure? = repositories.any?
24
+
25
+ private
26
+
27
+ def end_of_life_table(repositories)
28
+ headers = ["", "Repository", "#{product} version"]
29
+ rows = repositories.map.with_index(1) do |repo, i|
30
+ [i, repo.url, repo.min_release_of(product)]
31
+ end
32
+
33
+ table(headers, rows)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,28 +1,34 @@
1
+ require "async"
2
+ require "dry-monads"
3
+
1
4
  module EndOfLife
2
5
  module Scanner
3
6
  include Helpers::Terminal
7
+ include Helpers::Text
4
8
  extend self
5
9
 
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
+ def scan(product, options)
11
+ fetch_repositories(product, options)
12
+ .fmap { |repositories| filter_repositories_with_eol_products(repositories, product, options[:max_eol_date]) }
13
+ .fmap { |repositories| output_report(repositories, product, options[:max_eol_date]) }
10
14
  .or { |error| abort "\n#{error_msg(error)}" }
11
15
  end
12
16
 
13
17
  private
14
18
 
15
- def fetch_repositories(options)
16
- with_loading_spinner("Searching repositories with #{options[:product].label}...") do |spinner|
17
- result = Repository.search(options)
19
+ def fetch_repositories(product, options)
20
+ with_loading_spinner("Searching repositories that might use #{product.label}...") do |spinner|
21
+ result = Repository.search(options.merge(product:))
18
22
  spinner.error if result.failure?
19
23
 
20
24
  result
21
25
  end
22
26
  end
23
27
 
24
- def filter_repositories_with_eol_products(repositories, product:, max_eol_date:)
25
- with_loading_spinner("Searching for EOL #{product} in your repositories...") do
28
+ def filter_repositories_with_eol_products(repositories, product, max_eol_date)
29
+ return [] if repositories.empty?
30
+
31
+ with_loading_spinner("Scanning #{pluralize(repositories.size, "repository", "repositories")} for EOL #{product.label}...") do
26
32
  Sync do
27
33
  repositories
28
34
  .map { |repo| Async { [repo, repo.using_eol?(product, at: max_eol_date)] } }.map(&:wait)
@@ -31,7 +37,7 @@ module EndOfLife
31
37
  end
32
38
  end
33
39
 
34
- def output_report(repositories, product:, max_eol_date:)
40
+ def output_report(repositories, product, max_eol_date)
35
41
  report = Report.new(product, repositories, max_eol_date)
36
42
  puts report
37
43
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndOfLife
4
- VERSION = "0.5.0"
4
+ VERSION = "1.0.0.alpha"
5
5
  end
@@ -4,8 +4,8 @@ module EndOfLife
4
4
  @file_detectors ||= {}
5
5
  end
6
6
 
7
- def detects_from(file, &block)
8
- file_detectors[file] = block
7
+ def detects_from(*files, &block)
8
+ files.each { |file| file_detectors[file] = block }
9
9
  end
10
10
 
11
11
  def relevant_files = file_detectors.keys
@@ -0,0 +1,31 @@
1
+ require "bundler"
2
+
3
+ module EndOfLife
4
+ module VersionDetectors
5
+ module Nodejs
6
+ extend VersionDetector
7
+
8
+ detects_from ".node-version", ".nvmrc" do |file_content|
9
+ string_version = Gem::Version.new(file_content.strip.delete_prefix("v"))
10
+
11
+ Product::Release.new(product: "nodejs", version: string_version)
12
+ rescue ArgumentError
13
+ nil
14
+ end
15
+
16
+ detects_from ".tool-versions" do |file_content|
17
+ tool_versions = Parsers::ToolVersions.parse(file_content)
18
+ version = tool_versions["nodejs"] or next
19
+
20
+ Product::Release.new(product: "nodejs", version:)
21
+ end
22
+
23
+ detects_from "mise.toml" do |file_content|
24
+ tools = Parsers::MiseToml.parse(file_content) or next
25
+ version = tools["node"] or next
26
+
27
+ Product::Release.new(product: "nodejs", version:)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -12,7 +12,7 @@ module EndOfLife
12
12
  end
13
13
 
14
14
  detects_from "Gemfile.lock" do |file_content|
15
- gemfile_lock_version = Parsers::GemfileLock.parse(file_content).ruby_version or next
15
+ gemfile_lock_version = Parsers::GemfileLock.parse(file_content)&.ruby_version or next
16
16
 
17
17
  Product::Release.ruby(gemfile_lock_version.delete_prefix("ruby "))
18
18
  end
@@ -30,6 +30,13 @@ module EndOfLife
30
30
 
31
31
  Product::Release.ruby(ruby_version)
32
32
  end
33
+
34
+ detects_from "mise.toml" do |file_content|
35
+ tools = Parsers::MiseToml.parse(file_content) or next
36
+ ruby_version = tools["ruby"] or next
37
+
38
+ Product::Release.ruby(ruby_version)
39
+ end
33
40
  end
34
41
  end
35
42
  end
@@ -6,8 +6,6 @@ module EndOfLife
6
6
  detector_class = "#{self}::#{camelize(product.to_s)}"
7
7
 
8
8
  const_get(detector_class)
9
- rescue NameError
10
- raise ArgumentError, "Undefined version detector for product: #{product}"
11
9
  end
12
10
 
13
11
  private
data/lib/end_of_life.rb CHANGED
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "async"
4
- require "dry-monads"
5
- require "json"
6
- require "base64"
7
- require "octokit"
8
3
  require "zeitwerk"
9
4
 
10
5
  Zeitwerk::Loader.for_gem.tap { |it|
@@ -15,6 +10,7 @@ module EndOfLife
15
10
  extend Product::Registry
16
11
  extend Helpers::Terminal
17
12
 
18
- scans_for :ruby, search_query: "language:ruby"
19
- scans_for :rails, search_query: "language:ruby"
13
+ scans_for :ruby
14
+ scans_for :rails
15
+ scans_for :nodejs, label: "Node.js"
20
16
  end
metadata CHANGED
@@ -1,14 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: end_of_life
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 1.0.0.alpha
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matheus Richard
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-10-24 00:00:00.000000000 Z
11
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: argument_parser
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.1.0
12
27
  - !ruby/object:Gem::Dependency
13
28
  name: async
14
29
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +38,40 @@ dependencies:
23
38
  - - ">="
24
39
  - !ruby/object:Gem::Version
25
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: base64
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: bundler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 2.7.2
62
+ - - "<"
63
+ - !ruby/object:Gem::Version
64
+ version: '3'
65
+ type: :runtime
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 2.7.2
72
+ - - "<"
73
+ - !ruby/object:Gem::Version
74
+ version: '3'
26
75
  - !ruby/object:Gem::Dependency
27
76
  name: dry-monads
28
77
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +86,20 @@ dependencies:
37
86
  - - "~>"
38
87
  - !ruby/object:Gem::Version
39
88
  version: '1.3'
89
+ - !ruby/object:Gem::Dependency
90
+ name: perfect_toml
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 0.9.0
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: 0.9.0
40
103
  - !ruby/object:Gem::Dependency
41
104
  name: octokit
42
105
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +114,20 @@ dependencies:
51
114
  - - "~>"
52
115
  - !ruby/object:Gem::Version
53
116
  version: '9.0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: faraday-retry
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.0'
124
+ type: :runtime
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '2.0'
54
131
  - !ruby/object:Gem::Dependency
55
132
  name: pastel
56
133
  requirement: !ruby/object:Gem::Requirement
@@ -107,8 +184,9 @@ dependencies:
107
184
  - - "~>"
108
185
  - !ruby/object:Gem::Version
109
186
  version: '2.7'
110
- description: Searches your GitHub repositores and lists the ones using end-of-life,
111
- i.e. unmaintained, software versions.
187
+ description: |
188
+ Searches your GitHub repositores and lists the ones using end-of-life, i.e.
189
+ unmaintained, software.
112
190
  email:
113
191
  - matheusrichardt@gmail.com
114
192
  executables:
@@ -118,7 +196,7 @@ extensions: []
118
196
  extra_rdoc_files: []
119
197
  files:
120
198
  - ".rspec"
121
- - ".tool-versions"
199
+ - ".standard.yml"
122
200
  - CHANGELOG.md
123
201
  - Gemfile
124
202
  - Gemfile.lock
@@ -126,6 +204,7 @@ files:
126
204
  - README.md
127
205
  - Rakefile
128
206
  - bin/console
207
+ - bin/end_of_life
129
208
  - bin/setup
130
209
  - demo.gif
131
210
  - end_of_life.gemspec
@@ -133,26 +212,31 @@ files:
133
212
  - exe/eol
134
213
  - lib/end_of_life.rb
135
214
  - lib/end_of_life/api.rb
215
+ - lib/end_of_life/check.rb
136
216
  - lib/end_of_life/cli.rb
217
+ - lib/end_of_life/cli/command/registry.rb
137
218
  - lib/end_of_life/helpers/silent_bundler.rb
138
219
  - lib/end_of_life/helpers/terminal.rb
220
+ - lib/end_of_life/helpers/text.rb
221
+ - lib/end_of_life/helpers/time.rb
139
222
  - lib/end_of_life/in_memory_file.rb
140
- - lib/end_of_life/options.rb
141
223
  - lib/end_of_life/parsers/gemfile.rb
142
224
  - lib/end_of_life/parsers/gemfile_lock.rb
225
+ - lib/end_of_life/parsers/mise_toml.rb
143
226
  - lib/end_of_life/parsers/ruby_version.rb
144
227
  - lib/end_of_life/parsers/tool_versions.rb
145
228
  - lib/end_of_life/product.rb
146
229
  - lib/end_of_life/product/registry.rb
147
230
  - lib/end_of_life/product/release.rb
148
- - lib/end_of_life/report.rb
149
231
  - lib/end_of_life/repository.rb
150
232
  - lib/end_of_life/repository/search.rb
151
233
  - lib/end_of_life/repository/search/query.rb
152
234
  - lib/end_of_life/scanner.rb
235
+ - lib/end_of_life/scanner/report.rb
153
236
  - lib/end_of_life/version.rb
154
237
  - lib/end_of_life/version_detector.rb
155
238
  - lib/end_of_life/version_detectors.rb
239
+ - lib/end_of_life/version_detectors/nodejs.rb
156
240
  - lib/end_of_life/version_detectors/rails.rb
157
241
  - lib/end_of_life/version_detectors/ruby.rb
158
242
  homepage: https://github.com/MatheusRich/end_of_life
@@ -162,6 +246,7 @@ metadata:
162
246
  homepage_uri: https://github.com/MatheusRich/end_of_life
163
247
  source_code_uri: https://github.com/MatheusRich/end_of_life
164
248
  changelog_uri: https://github.com/MatheusRich/end_of_life/blob/main/CHANGELOG.md
249
+ post_install_message:
165
250
  rdoc_options: []
166
251
  require_paths:
167
252
  - lib
@@ -172,11 +257,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
172
257
  version: 3.2.0
173
258
  required_rubygems_version: !ruby/object:Gem::Requirement
174
259
  requirements:
175
- - - ">="
260
+ - - ">"
176
261
  - !ruby/object:Gem::Version
177
- version: '0'
262
+ version: 1.3.1
178
263
  requirements: []
179
- rubygems_version: 3.7.1
264
+ rubygems_version: 3.4.19
265
+ signing_key:
180
266
  specification_version: 4
181
267
  summary: Lists repositories using end-of-life software
182
268
  test_files: []
data/.tool-versions DELETED
@@ -1 +0,0 @@
1
- ruby 3.4.2