stackharbinger 0.2.0 → 0.4.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.
data/lib/harbinger/cli.rb CHANGED
@@ -6,8 +6,15 @@ require "tty-table"
6
6
  require_relative "version"
7
7
  require "harbinger/analyzers/ruby_detector"
8
8
  require "harbinger/analyzers/rails_analyzer"
9
+ require "harbinger/analyzers/database_detector"
10
+ require "harbinger/analyzers/postgres_detector"
11
+ require "harbinger/analyzers/mysql_detector"
12
+ require "harbinger/analyzers/redis_detector"
13
+ require "harbinger/analyzers/mongo_detector"
9
14
  require "harbinger/eol_fetcher"
10
15
  require "harbinger/config_manager"
16
+ require "harbinger/exporters/json_exporter"
17
+ require "harbinger/exporters/csv_exporter"
11
18
 
12
19
  module Harbinger
13
20
  class CLI < Thor
@@ -20,7 +27,7 @@ module Harbinger
20
27
  option :save, type: :boolean, aliases: "-s", desc: "Save project to config for dashboard"
21
28
  option :recursive, type: :boolean, aliases: "-r", desc: "Recursively scan all subdirectories with Gemfiles"
22
29
  def scan
23
- project_path = options[:path] || Dir.pwd
30
+ project_path = File.expand_path(options[:path] || Dir.pwd)
24
31
 
25
32
  unless File.directory?(project_path)
26
33
  say "Error: #{project_path} is not a valid directory", :red
@@ -34,8 +41,11 @@ module Harbinger
34
41
  end
35
42
  end
36
43
 
37
- desc "show", "Show EOL status for tracked projects"
38
- def show
44
+ desc "show [PROJECT]", "Show EOL status for tracked projects"
45
+ option :verbose, type: :boolean, aliases: "-v", desc: "Show project paths"
46
+ option :format, type: :string, enum: %w[table json csv], default: "table", desc: "Output format (table, json, csv)"
47
+ option :output, type: :string, aliases: "-o", desc: "Output file path"
48
+ def show(project_filter = nil)
39
49
  config_manager = ConfigManager.new
40
50
  projects = config_manager.list_projects
41
51
 
@@ -45,27 +55,79 @@ module Harbinger
45
55
  return
46
56
  end
47
57
 
48
- say "Tracked Projects (#{projects.size})", :cyan
49
- say "=" * 80, :cyan
58
+ # Filter by project name or path if specified
59
+ if project_filter
60
+ projects = projects.select do |name, data|
61
+ name.downcase.include?(project_filter.downcase) ||
62
+ data["path"]&.downcase&.include?(project_filter.downcase)
63
+ end
64
+
65
+ if projects.empty?
66
+ say "No projects matching '#{project_filter}'", :yellow
67
+ return
68
+ end
69
+ end
70
+
71
+ # Handle export formats
72
+ if options[:format] != "table"
73
+ export_data(projects, options[:format], options[:output])
74
+ return
75
+ end
50
76
 
51
77
  fetcher = EolFetcher.new
52
78
  rows = []
53
79
 
80
+ # Track which columns have data
81
+ has_ruby = false
82
+ has_rails = false
83
+ has_postgres = false
84
+ has_mysql = false
85
+ has_redis = false
86
+ has_mongo = false
87
+
54
88
  projects.each do |name, data|
55
89
  ruby_version = data["ruby"]
56
90
  rails_version = data["rails"]
91
+ postgres_version = data["postgres"]
92
+ mysql_version = data["mysql"]
93
+ redis_version = data["redis"]
94
+ mongo_version = data["mongo"]
95
+
96
+ # Filter out gem-only database versions
97
+ postgres_version = nil if postgres_version&.include?("gem")
98
+ mysql_version = nil if mysql_version&.include?("gem")
99
+ redis_version = nil if redis_version&.include?("gem")
100
+ mongo_version = nil if mongo_version&.include?("gem")
101
+
102
+ # Skip projects with no matching products
103
+ ruby_present = ruby_version && !ruby_version.empty?
104
+ rails_present = rails_version && !rails_version.empty?
105
+ postgres_present = postgres_version && !postgres_version.empty?
106
+ mysql_present = mysql_version && !mysql_version.empty?
107
+ redis_present = redis_version && !redis_version.empty?
108
+ mongo_present = mongo_version && !mongo_version.empty?
109
+
110
+ next unless ruby_present || rails_present || postgres_present || mysql_present || redis_present || mongo_present
111
+
112
+ # Track which columns have data
113
+ has_ruby ||= ruby_present
114
+ has_rails ||= rails_present
115
+ has_postgres ||= postgres_present
116
+ has_mysql ||= mysql_present
117
+ has_redis ||= redis_present
118
+ has_mongo ||= mongo_present
57
119
 
58
120
  # Determine worst EOL status
59
121
  worst_status = :green
60
122
  status_text = "✓ Current"
61
123
 
62
- if ruby_version && !ruby_version.empty?
124
+ if ruby_present
63
125
  ruby_eol = fetcher.eol_date_for("ruby", ruby_version)
64
126
  if ruby_eol
65
127
  days = days_until(ruby_eol)
66
128
  status = eol_color(days)
67
129
  worst_status = status if status_priority(status) > status_priority(worst_status)
68
- if days < 0
130
+ if days.negative?
69
131
  status_text = "✗ Ruby EOL"
70
132
  elsif days < 180
71
133
  status_text = "⚠ Ruby ending soon"
@@ -73,13 +135,13 @@ module Harbinger
73
135
  end
74
136
  end
75
137
 
76
- if rails_version && !rails_version.empty?
138
+ if rails_present
77
139
  rails_eol = fetcher.eol_date_for("rails", rails_version)
78
140
  if rails_eol
79
141
  days = days_until(rails_eol)
80
142
  status = eol_color(days)
81
143
  worst_status = status if status_priority(status) > status_priority(worst_status)
82
- if days < 0
144
+ if days.negative?
83
145
  status_text = "✗ Rails EOL"
84
146
  elsif days < 180 && !status_text.include?("EOL")
85
147
  status_text = "⚠ Rails ending soon"
@@ -87,31 +149,127 @@ module Harbinger
87
149
  end
88
150
  end
89
151
 
90
- ruby_display = ruby_version && !ruby_version.empty? ? ruby_version : "-"
91
- rails_display = rails_version && !rails_version.empty? ? rails_version : "-"
152
+ if postgres_present
153
+ postgres_eol = fetcher.eol_date_for("postgresql", postgres_version)
154
+ if postgres_eol
155
+ days = days_until(postgres_eol)
156
+ status = eol_color(days)
157
+ worst_status = status if status_priority(status) > status_priority(worst_status)
158
+ if days.negative?
159
+ status_text = "✗ PostgreSQL EOL"
160
+ elsif days < 180 && !status_text.include?("EOL")
161
+ status_text = "⚠ PostgreSQL ending soon"
162
+ end
163
+ end
164
+ end
165
+
166
+ if mysql_present
167
+ mysql_eol = fetcher.eol_date_for("mysql", mysql_version)
168
+ if mysql_eol
169
+ days = days_until(mysql_eol)
170
+ status = eol_color(days)
171
+ worst_status = status if status_priority(status) > status_priority(worst_status)
172
+ if days.negative?
173
+ status_text = "✗ MySQL EOL"
174
+ elsif days < 180 && !status_text.include?("EOL")
175
+ status_text = "⚠ MySQL ending soon"
176
+ end
177
+ end
178
+ end
179
+
180
+ if redis_present
181
+ redis_eol = fetcher.eol_date_for("redis", redis_version)
182
+ if redis_eol
183
+ days = days_until(redis_eol)
184
+ status = eol_color(days)
185
+ worst_status = status if status_priority(status) > status_priority(worst_status)
186
+ if days.negative?
187
+ status_text = "✗ Redis EOL"
188
+ elsif days < 180 && !status_text.include?("EOL")
189
+ status_text = "⚠ Redis ending soon"
190
+ end
191
+ end
192
+ end
193
+
194
+ if mongo_present
195
+ mongo_eol = fetcher.eol_date_for("mongodb", mongo_version)
196
+ if mongo_eol
197
+ days = days_until(mongo_eol)
198
+ status = eol_color(days)
199
+ worst_status = status if status_priority(status) > status_priority(worst_status)
200
+ if days.negative?
201
+ status_text = "✗ MongoDB EOL"
202
+ elsif days < 180 && !status_text.include?("EOL")
203
+ status_text = "⚠ MongoDB ending soon"
204
+ end
205
+ end
206
+ end
207
+
208
+ rows << {
209
+ name: name,
210
+ path: File.dirname(data["path"] || ""),
211
+ ruby: ruby_present ? ruby_version : "-",
212
+ rails: rails_present ? rails_version : "-",
213
+ postgres: postgres_present ? postgres_version : "-",
214
+ mysql: mysql_present ? mysql_version : "-",
215
+ redis: redis_present ? redis_version : "-",
216
+ mongo: mongo_present ? mongo_version : "-",
217
+ status: colorize_status(status_text, worst_status),
218
+ status_raw: status_text
219
+ }
220
+ end
92
221
 
93
- rows << [name, ruby_display, rails_display, colorize_status(status_text, worst_status)]
222
+ if rows.empty?
223
+ say "No projects with detected versions.", :yellow
224
+ say "Use 'harbinger scan --save' to add projects", :cyan
225
+ return
94
226
  end
95
227
 
228
+ say "Tracked Projects (#{rows.size})", :cyan
229
+ say "=" * 80, :cyan
230
+
96
231
  # Sort by status priority (worst first), then by name
97
232
  rows.sort_by! do |row|
98
- status = row[3]
99
- priority = if status.include?("✗")
100
- 0
101
- elsif status.include?("⚠")
102
- 1
103
- else
104
- 2
105
- end
106
- [priority, row[0]]
233
+ priority = if row[:status_raw].include?("✗")
234
+ 0
235
+ elsif row[:status_raw].include?("⚠")
236
+ 1
237
+ else
238
+ 2
239
+ end
240
+ [priority, row[:name]]
241
+ end
242
+
243
+ # Build dynamic headers and rows
244
+ headers = ["Project"]
245
+ headers << "Path" if options[:verbose]
246
+ headers << "Ruby" if has_ruby
247
+ headers << "Rails" if has_rails
248
+ headers << "PostgreSQL" if has_postgres
249
+ headers << "MySQL" if has_mysql
250
+ headers << "Redis" if has_redis
251
+ headers << "MongoDB" if has_mongo
252
+ headers << "Status"
253
+
254
+ table_rows = rows.map do |row|
255
+ table_row = [row[:name]]
256
+ table_row << row[:path] if options[:verbose]
257
+ table_row << row[:ruby] if has_ruby
258
+ table_row << row[:rails] if has_rails
259
+ table_row << row[:postgres] if has_postgres
260
+ table_row << row[:mysql] if has_mysql
261
+ table_row << row[:redis] if has_redis
262
+ table_row << row[:mongo] if has_mongo
263
+ table_row << row[:status]
264
+ table_row
107
265
  end
108
266
 
109
267
  table = TTY::Table.new(
110
- header: ["Project", "Ruby", "Rails", "Status"],
111
- rows: rows
268
+ header: headers,
269
+ rows: table_rows
112
270
  )
113
271
 
114
- puts table.render(:unicode, padding: [0, 1])
272
+ puts table.render(:unicode, padding: [0, 1], resize: false)
115
273
 
116
274
  say "\nUse 'harbinger scan --path <project>' to update a project", :cyan
117
275
  end
@@ -121,7 +279,7 @@ module Harbinger
121
279
  say "Updating EOL data...", :cyan
122
280
 
123
281
  fetcher = EolFetcher.new
124
- products = %w[ruby rails]
282
+ products = %w[ruby rails postgresql mysql redis mongodb]
125
283
 
126
284
  products.each do |product|
127
285
  say "Fetching #{product}...", :white
@@ -137,6 +295,88 @@ module Harbinger
137
295
  say "\nEOL data updated successfully!", :green
138
296
  end
139
297
 
298
+ desc "remove PROJECT", "Remove a project from tracking"
299
+ def remove(project_name)
300
+ config_manager = ConfigManager.new
301
+ project = config_manager.get_project(project_name)
302
+
303
+ if project
304
+ config_manager.remove_project(project_name)
305
+ say "Removed '#{project_name}' (#{project["path"]})", :green
306
+ else
307
+ say "Project '#{project_name}' not found", :yellow
308
+ say "\nTracked projects:", :cyan
309
+ config_manager.list_projects.keys.sort.each { |name| say " #{name}" }
310
+ end
311
+ end
312
+
313
+ desc "rescan", "Re-scan all tracked projects and update versions"
314
+ option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for each project"
315
+ def rescan
316
+ config_manager = ConfigManager.new
317
+ projects = config_manager.list_projects
318
+
319
+ if projects.empty?
320
+ say "No projects tracked yet.", :yellow
321
+ say "Use 'harbinger scan --save' to add projects", :cyan
322
+ return
323
+ end
324
+
325
+ say "Re-scanning #{projects.size} tracked project(s)...\n\n", :cyan
326
+
327
+ updated_count = 0
328
+ removed_count = 0
329
+
330
+ projects.each_with_index do |(name, data), index|
331
+ project_path = data["path"]
332
+
333
+ unless File.directory?(project_path)
334
+ say "[#{index + 1}/#{projects.size}] #{name}: Path not found, removing from config", :yellow
335
+ config_manager.remove_project(name)
336
+ removed_count += 1
337
+ next
338
+ end
339
+
340
+ if options[:verbose]
341
+ say "=" * 60, :cyan
342
+ say "[#{index + 1}/#{projects.size}] Re-scanning #{name}", :cyan
343
+ say "=" * 60, :cyan
344
+ scan_single(project_path)
345
+ else
346
+ say "[#{index + 1}/#{projects.size}] #{name}...", :white
347
+
348
+ # Detect versions quietly
349
+ ruby_detector = Analyzers::RubyDetector.new(project_path)
350
+ rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
351
+ postgres_detector = Analyzers::PostgresDetector.new(project_path)
352
+ mysql_detector = Analyzers::MysqlDetector.new(project_path)
353
+ redis_detector = Analyzers::RedisDetector.new(project_path)
354
+ mongo_detector = Analyzers::MongoDetector.new(project_path)
355
+
356
+ ruby_version = ruby_detector.detect
357
+ rails_version = rails_analyzer.detect
358
+ postgres_version = postgres_detector.detect
359
+ mysql_version = mysql_detector.detect
360
+ redis_version = redis_detector.detect
361
+ mongo_version = mongo_detector.detect
362
+
363
+ # Save to config
364
+ config_manager.save_project(
365
+ name: name,
366
+ path: project_path,
367
+ versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
368
+ mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
369
+ )
370
+ end
371
+
372
+ updated_count += 1
373
+ end
374
+
375
+ say "\n✓ Updated #{updated_count} project(s)", :green
376
+ say "✓ Removed #{removed_count} project(s) with missing directories", :yellow if removed_count.positive?
377
+ say "\nView updated projects with: harbinger show", :cyan
378
+ end
379
+
140
380
  desc "version", "Show harbinger version"
141
381
  def version
142
382
  say "Harbinger version #{Harbinger::VERSION}", :cyan
@@ -147,9 +387,19 @@ module Harbinger
147
387
  def scan_recursive(base_path)
148
388
  say "Scanning #{base_path} recursively for Ruby projects...", :cyan
149
389
 
150
- # Find all directories with Gemfiles
390
+ # Find all directories with Gemfiles, excluding common non-project directories
391
+ excluded_patterns = %w[
392
+ vendor/
393
+ node_modules/
394
+ tmp/
395
+ .git/
396
+ spec/fixtures/
397
+ test/fixtures/
398
+ ]
399
+
151
400
  gemfile_dirs = Dir.glob(File.join(base_path, "**/Gemfile"))
152
401
  .map { |f| File.dirname(f) }
402
+ .reject { |dir| excluded_patterns.any? { |pattern| dir.include?("/#{pattern}") } }
153
403
  .sort
154
404
 
155
405
  if gemfile_dirs.empty?
@@ -167,10 +417,10 @@ module Harbinger
167
417
  say "\n" unless index == gemfile_dirs.length - 1
168
418
  end
169
419
 
170
- if options[:save]
171
- say "\n✓ Saved #{gemfile_dirs.length} project(s) to config", :green
172
- say "View all tracked projects with: harbinger show", :cyan
173
- end
420
+ return unless options[:save]
421
+
422
+ say "\n✓ Saved #{gemfile_dirs.length} project(s) to config", :green
423
+ say "View all tracked projects with: harbinger show", :cyan
174
424
  end
175
425
 
176
426
  def scan_single(project_path)
@@ -179,48 +429,91 @@ module Harbinger
179
429
  # Detect versions
180
430
  ruby_detector = Analyzers::RubyDetector.new(project_path)
181
431
  rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
432
+ postgres_detector = Analyzers::PostgresDetector.new(project_path)
433
+ mysql_detector = Analyzers::MysqlDetector.new(project_path)
434
+ redis_detector = Analyzers::RedisDetector.new(project_path)
435
+ mongo_detector = Analyzers::MongoDetector.new(project_path)
182
436
 
183
437
  ruby_version = ruby_detector.detect
184
438
  rails_version = rails_analyzer.detect
439
+ postgres_version = postgres_detector.detect
440
+ mysql_version = mysql_detector.detect
441
+ redis_version = redis_detector.detect
442
+ mongo_version = mongo_detector.detect
185
443
 
186
444
  ruby_present = ruby_detector.ruby_detected?
187
445
  rails_present = rails_analyzer.rails_detected?
446
+ postgres_present = postgres_detector.database_detected?
447
+ mysql_present = mysql_detector.database_detected?
448
+ redis_present = redis_detector.redis_detected?
449
+ mongo_present = mongo_detector.mongo_detected?
188
450
 
189
451
  # Display results
190
452
  say "\nDetected versions:", :green
191
453
  if ruby_version
192
- say " Ruby: #{ruby_version}", :white
454
+ say " Ruby: #{ruby_version}", :white
193
455
  elsif ruby_present
194
- say " Ruby: Present (version not specified - add .ruby-version or ruby declaration in Gemfile)", :yellow
456
+ say " Ruby: Present (version not specified - add .ruby-version or ruby declaration in Gemfile)", :yellow
195
457
  else
196
- say " Ruby: Not a Ruby project", :red
458
+ say " Ruby: Not a Ruby project", :red
197
459
  end
198
460
 
199
461
  if rails_version
200
- say " Rails: #{rails_version}", :white
462
+ say " Rails: #{rails_version}", :white
201
463
  elsif rails_present
202
- say " Rails: Present (version not found in Gemfile.lock)", :yellow
464
+ say " Rails: Present (version not found in Gemfile.lock)", :yellow
203
465
  else
204
- say " Rails: Not detected", :yellow
466
+ say " Rails: Not detected", :yellow
467
+ end
468
+
469
+ if postgres_version
470
+ say " PostgreSQL: #{postgres_version}", :white
471
+ elsif postgres_present
472
+ say " PostgreSQL: Present (version not detected)", :yellow
473
+ end
474
+
475
+ if mysql_version
476
+ say " MySQL: #{mysql_version}", :white
477
+ elsif mysql_present
478
+ say " MySQL: Present (version not detected)", :yellow
479
+ end
480
+
481
+ if redis_version
482
+ say " Redis: #{redis_version}", :white
483
+ elsif redis_present
484
+ say " Redis: Present (version not detected)", :yellow
485
+ end
486
+
487
+ if mongo_version
488
+ say " MongoDB: #{mongo_version}", :white
489
+ elsif mongo_present
490
+ say " MongoDB: Present (version not detected)", :yellow
205
491
  end
206
492
 
207
493
  # Fetch and display EOL dates
208
- if ruby_version || rails_version
494
+ if ruby_version || rails_version || postgres_version || mysql_version || redis_version || mongo_version
209
495
  say "\nFetching EOL data...", :cyan
210
496
  fetcher = EolFetcher.new
211
497
 
212
- if ruby_version
213
- display_eol_info(fetcher, "Ruby", ruby_version)
214
- end
498
+ display_eol_info(fetcher, "Ruby", ruby_version) if ruby_version
499
+
500
+ display_eol_info(fetcher, "Rails", rails_version) if rails_version
215
501
 
216
- if rails_version
217
- display_eol_info(fetcher, "Rails", rails_version)
502
+ if postgres_version && !postgres_version.include?("gem")
503
+ display_eol_info(fetcher, "PostgreSQL", postgres_version)
218
504
  end
505
+
506
+ display_eol_info(fetcher, "MySQL", mysql_version) if mysql_version && !mysql_version.include?("gem")
507
+
508
+ display_eol_info(fetcher, "Redis", redis_version) if redis_version && !redis_version.include?("gem")
509
+
510
+ display_eol_info(fetcher, "MongoDB", mongo_version) if mongo_version && !mongo_version.include?("gem")
219
511
  end
220
512
 
221
513
  # Save to config if --save flag is used
222
514
  if options[:save] && !options[:recursive]
223
- save_to_config(project_path, ruby_version, rails_version)
515
+ save_project_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version,
516
+ redis_version, mongo_version)
224
517
  elsif options[:save] && options[:recursive]
225
518
  # In recursive mode, save without the confirmation message for each project
226
519
  config_manager = ConfigManager.new
@@ -228,19 +521,22 @@ module Harbinger
228
521
  config_manager.save_project(
229
522
  name: project_name,
230
523
  path: project_path,
231
- versions: { ruby: ruby_version, rails: rails_version }.compact
524
+ versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
525
+ mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
232
526
  )
233
527
  end
234
528
  end
235
529
 
236
- def save_to_config(project_path, ruby_version, rails_version)
530
+ def save_project_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version,
531
+ redis_version, mongo_version)
237
532
  config_manager = ConfigManager.new
238
533
  project_name = File.basename(project_path)
239
534
 
240
535
  config_manager.save_project(
241
536
  name: project_name,
242
537
  path: project_path,
243
- versions: { ruby: ruby_version, rails: rails_version }.compact
538
+ versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
539
+ mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
244
540
  )
245
541
 
246
542
  say "\n✓ Saved to config as '#{project_name}'", :green
@@ -270,7 +566,7 @@ module Harbinger
270
566
  end
271
567
 
272
568
  def eol_color(days)
273
- if days < 0
569
+ if days.negative?
274
570
  :red
275
571
  elsif days < 180 # < 6 months
276
572
  :yellow
@@ -280,7 +576,7 @@ module Harbinger
280
576
  end
281
577
 
282
578
  def eol_status(days)
283
- if days < 0
579
+ if days.negative?
284
580
  "ALREADY EOL (#{days.abs} days ago)"
285
581
  elsif days < 30
286
582
  "ENDING SOON (#{days} days remaining)"
@@ -314,5 +610,23 @@ module Harbinger
314
610
  text
315
611
  end
316
612
  end
613
+
614
+ def export_data(projects, format, output_path)
615
+ exporter = case format
616
+ when "json"
617
+ Exporters::JsonExporter.new(projects)
618
+ when "csv"
619
+ Exporters::CsvExporter.new(projects)
620
+ end
621
+
622
+ result = exporter.export
623
+
624
+ if output_path
625
+ File.write(output_path, result)
626
+ say "Exported to #{output_path}", :green
627
+ else
628
+ puts result
629
+ end
630
+ end
317
631
  end
318
632
  end
@@ -18,16 +18,14 @@ module Harbinger
18
18
  cache_file = cache_file_path(product)
19
19
 
20
20
  # Return fresh cache if available
21
- if cache_fresh?(cache_file)
22
- return read_cache(cache_file)
23
- end
21
+ return read_cache(cache_file) if cache_fresh?(cache_file)
24
22
 
25
23
  # Try to fetch from API
26
24
  begin
27
25
  data = fetch_from_api(product)
28
26
  write_cache(cache_file, data)
29
27
  data
30
- rescue StandardError => e
28
+ rescue StandardError
31
29
  # Fall back to stale cache if API fails
32
30
  read_cache(cache_file) if File.exist?(cache_file)
33
31
  end
@@ -39,11 +37,27 @@ module Harbinger
39
37
 
40
38
  # Extract major.minor from version (e.g., "3.2.1" -> "3.2")
41
39
  version_parts = version.split(".")
42
- major_minor = "#{version_parts[0]}.#{version_parts[1]}"
40
+ major = version_parts[0]
41
+ major_minor = version_parts[1] ? "#{major}.#{version_parts[1]}" : nil
42
+
43
+ # Try exact major.minor first (e.g., "8.0" for MySQL, "3.2" for Ruby)
44
+ if major_minor
45
+ entry = data.find { |item| item["cycle"] == major_minor }
46
+ return entry["eol"] if entry
47
+ end
48
+
49
+ # Try major only (e.g., "16" for PostgreSQL)
50
+ entry = data.find { |item| item["cycle"] == major }
51
+ return entry["eol"] if entry
52
+
53
+ # For major-only versions, find the latest minor version in that major series
54
+ # (e.g., version "7" should match "7.4" which is the latest 7.x)
55
+ matching_entries = data.select { |item| item["cycle"].to_s.start_with?("#{major}.") }
56
+ return nil if matching_entries.empty?
43
57
 
44
- # Find matching cycle
45
- entry = data.find { |item| item["cycle"] == major_minor }
46
- entry ? entry["eol"] : nil
58
+ # Sort by cycle version and get the latest (highest minor version)
59
+ latest = matching_entries.max_by { |item| item["cycle"].to_s.split(".").map(&:to_i) }
60
+ latest ? latest["eol"] : nil
47
61
  end
48
62
 
49
63
  private