stackharbinger 0.3.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
@@ -9,8 +9,12 @@ require "harbinger/analyzers/rails_analyzer"
9
9
  require "harbinger/analyzers/database_detector"
10
10
  require "harbinger/analyzers/postgres_detector"
11
11
  require "harbinger/analyzers/mysql_detector"
12
+ require "harbinger/analyzers/redis_detector"
13
+ require "harbinger/analyzers/mongo_detector"
12
14
  require "harbinger/eol_fetcher"
13
15
  require "harbinger/config_manager"
16
+ require "harbinger/exporters/json_exporter"
17
+ require "harbinger/exporters/csv_exporter"
14
18
 
15
19
  module Harbinger
16
20
  class CLI < Thor
@@ -23,7 +27,7 @@ module Harbinger
23
27
  option :save, type: :boolean, aliases: "-s", desc: "Save project to config for dashboard"
24
28
  option :recursive, type: :boolean, aliases: "-r", desc: "Recursively scan all subdirectories with Gemfiles"
25
29
  def scan
26
- project_path = options[:path] || Dir.pwd
30
+ project_path = File.expand_path(options[:path] || Dir.pwd)
27
31
 
28
32
  unless File.directory?(project_path)
29
33
  say "Error: #{project_path} is not a valid directory", :red
@@ -37,8 +41,11 @@ module Harbinger
37
41
  end
38
42
  end
39
43
 
40
- desc "show", "Show EOL status for tracked projects"
41
- 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)
42
49
  config_manager = ConfigManager.new
43
50
  projects = config_manager.list_projects
44
51
 
@@ -48,29 +55,79 @@ module Harbinger
48
55
  return
49
56
  end
50
57
 
51
- say "Tracked Projects (#{projects.size})", :cyan
52
- 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
53
76
 
54
77
  fetcher = EolFetcher.new
55
78
  rows = []
56
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
+
57
88
  projects.each do |name, data|
58
89
  ruby_version = data["ruby"]
59
90
  rails_version = data["rails"]
60
91
  postgres_version = data["postgres"]
61
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
62
119
 
63
120
  # Determine worst EOL status
64
121
  worst_status = :green
65
122
  status_text = "✓ Current"
66
123
 
67
- if ruby_version && !ruby_version.empty?
124
+ if ruby_present
68
125
  ruby_eol = fetcher.eol_date_for("ruby", ruby_version)
69
126
  if ruby_eol
70
127
  days = days_until(ruby_eol)
71
128
  status = eol_color(days)
72
129
  worst_status = status if status_priority(status) > status_priority(worst_status)
73
- if days < 0
130
+ if days.negative?
74
131
  status_text = "✗ Ruby EOL"
75
132
  elsif days < 180
76
133
  status_text = "⚠ Ruby ending soon"
@@ -78,13 +135,13 @@ module Harbinger
78
135
  end
79
136
  end
80
137
 
81
- if rails_version && !rails_version.empty?
138
+ if rails_present
82
139
  rails_eol = fetcher.eol_date_for("rails", rails_version)
83
140
  if rails_eol
84
141
  days = days_until(rails_eol)
85
142
  status = eol_color(days)
86
143
  worst_status = status if status_priority(status) > status_priority(worst_status)
87
- if days < 0
144
+ if days.negative?
88
145
  status_text = "✗ Rails EOL"
89
146
  elsif days < 180 && !status_text.include?("EOL")
90
147
  status_text = "⚠ Rails ending soon"
@@ -92,13 +149,13 @@ module Harbinger
92
149
  end
93
150
  end
94
151
 
95
- if postgres_version && !postgres_version.empty? && !postgres_version.include?("gem")
152
+ if postgres_present
96
153
  postgres_eol = fetcher.eol_date_for("postgresql", postgres_version)
97
154
  if postgres_eol
98
155
  days = days_until(postgres_eol)
99
156
  status = eol_color(days)
100
157
  worst_status = status if status_priority(status) > status_priority(worst_status)
101
- if days < 0
158
+ if days.negative?
102
159
  status_text = "✗ PostgreSQL EOL"
103
160
  elsif days < 180 && !status_text.include?("EOL")
104
161
  status_text = "⚠ PostgreSQL ending soon"
@@ -106,13 +163,13 @@ module Harbinger
106
163
  end
107
164
  end
108
165
 
109
- if mysql_version && !mysql_version.empty? && !mysql_version.include?("gem")
166
+ if mysql_present
110
167
  mysql_eol = fetcher.eol_date_for("mysql", mysql_version)
111
168
  if mysql_eol
112
169
  days = days_until(mysql_eol)
113
170
  status = eol_color(days)
114
171
  worst_status = status if status_priority(status) > status_priority(worst_status)
115
- if days < 0
172
+ if days.negative?
116
173
  status_text = "✗ MySQL EOL"
117
174
  elsif days < 180 && !status_text.include?("EOL")
118
175
  status_text = "⚠ MySQL ending soon"
@@ -120,33 +177,99 @@ module Harbinger
120
177
  end
121
178
  end
122
179
 
123
- ruby_display = ruby_version && !ruby_version.empty? ? ruby_version : "-"
124
- rails_display = rails_version && !rails_version.empty? ? rails_version : "-"
125
- postgres_display = postgres_version && !postgres_version.empty? ? postgres_version : "-"
126
- mysql_display = mysql_version && !mysql_version.empty? ? mysql_version : "-"
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
127
193
 
128
- rows << [name, ruby_display, rails_display, postgres_display, mysql_display, colorize_status(status_text, worst_status)]
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
+ }
129
220
  end
130
221
 
222
+ if rows.empty?
223
+ say "No projects with detected versions.", :yellow
224
+ say "Use 'harbinger scan --save' to add projects", :cyan
225
+ return
226
+ end
227
+
228
+ say "Tracked Projects (#{rows.size})", :cyan
229
+ say "=" * 80, :cyan
230
+
131
231
  # Sort by status priority (worst first), then by name
132
232
  rows.sort_by! do |row|
133
- status = row[5] # Status is now in column 5 (0-indexed)
134
- priority = if status.include?("✗")
135
- 0
136
- elsif status.include?("⚠")
137
- 1
138
- else
139
- 2
140
- end
141
- [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
142
265
  end
143
266
 
144
267
  table = TTY::Table.new(
145
- header: ["Project", "Ruby", "Rails", "PostgreSQL", "MySQL", "Status"],
146
- rows: rows
268
+ header: headers,
269
+ rows: table_rows
147
270
  )
148
271
 
149
- puts table.render(:unicode, padding: [0, 1])
272
+ puts table.render(:unicode, padding: [0, 1], resize: false)
150
273
 
151
274
  say "\nUse 'harbinger scan --path <project>' to update a project", :cyan
152
275
  end
@@ -156,7 +279,7 @@ module Harbinger
156
279
  say "Updating EOL data...", :cyan
157
280
 
158
281
  fetcher = EolFetcher.new
159
- products = %w[ruby rails postgresql mysql]
282
+ products = %w[ruby rails postgresql mysql redis mongodb]
160
283
 
161
284
  products.each do |product|
162
285
  say "Fetching #{product}...", :white
@@ -172,6 +295,21 @@ module Harbinger
172
295
  say "\nEOL data updated successfully!", :green
173
296
  end
174
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
+
175
313
  desc "rescan", "Re-scan all tracked projects and update versions"
176
314
  option :verbose, type: :boolean, aliases: "-v", desc: "Show detailed output for each project"
177
315
  def rescan
@@ -212,17 +350,22 @@ module Harbinger
212
350
  rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
213
351
  postgres_detector = Analyzers::PostgresDetector.new(project_path)
214
352
  mysql_detector = Analyzers::MysqlDetector.new(project_path)
353
+ redis_detector = Analyzers::RedisDetector.new(project_path)
354
+ mongo_detector = Analyzers::MongoDetector.new(project_path)
215
355
 
216
356
  ruby_version = ruby_detector.detect
217
357
  rails_version = rails_analyzer.detect
218
358
  postgres_version = postgres_detector.detect
219
359
  mysql_version = mysql_detector.detect
360
+ redis_version = redis_detector.detect
361
+ mongo_version = mongo_detector.detect
220
362
 
221
363
  # Save to config
222
364
  config_manager.save_project(
223
365
  name: name,
224
366
  path: project_path,
225
- versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version, mysql: mysql_version }.compact
367
+ versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
368
+ mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
226
369
  )
227
370
  end
228
371
 
@@ -230,7 +373,7 @@ module Harbinger
230
373
  end
231
374
 
232
375
  say "\n✓ Updated #{updated_count} project(s)", :green
233
- say "✓ Removed #{removed_count} project(s) with missing directories", :yellow if removed_count > 0
376
+ say "✓ Removed #{removed_count} project(s) with missing directories", :yellow if removed_count.positive?
234
377
  say "\nView updated projects with: harbinger show", :cyan
235
378
  end
236
379
 
@@ -244,9 +387,19 @@ module Harbinger
244
387
  def scan_recursive(base_path)
245
388
  say "Scanning #{base_path} recursively for Ruby projects...", :cyan
246
389
 
247
- # 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
+
248
400
  gemfile_dirs = Dir.glob(File.join(base_path, "**/Gemfile"))
249
401
  .map { |f| File.dirname(f) }
402
+ .reject { |dir| excluded_patterns.any? { |pattern| dir.include?("/#{pattern}") } }
250
403
  .sort
251
404
 
252
405
  if gemfile_dirs.empty?
@@ -264,10 +417,10 @@ module Harbinger
264
417
  say "\n" unless index == gemfile_dirs.length - 1
265
418
  end
266
419
 
267
- if options[:save]
268
- say "\n✓ Saved #{gemfile_dirs.length} project(s) to config", :green
269
- say "View all tracked projects with: harbinger show", :cyan
270
- 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
271
424
  end
272
425
 
273
426
  def scan_single(project_path)
@@ -278,16 +431,22 @@ module Harbinger
278
431
  rails_analyzer = Analyzers::RailsAnalyzer.new(project_path)
279
432
  postgres_detector = Analyzers::PostgresDetector.new(project_path)
280
433
  mysql_detector = Analyzers::MysqlDetector.new(project_path)
434
+ redis_detector = Analyzers::RedisDetector.new(project_path)
435
+ mongo_detector = Analyzers::MongoDetector.new(project_path)
281
436
 
282
437
  ruby_version = ruby_detector.detect
283
438
  rails_version = rails_analyzer.detect
284
439
  postgres_version = postgres_detector.detect
285
440
  mysql_version = mysql_detector.detect
441
+ redis_version = redis_detector.detect
442
+ mongo_version = mongo_detector.detect
286
443
 
287
444
  ruby_present = ruby_detector.ruby_detected?
288
445
  rails_present = rails_analyzer.rails_detected?
289
446
  postgres_present = postgres_detector.database_detected?
290
447
  mysql_present = mysql_detector.database_detected?
448
+ redis_present = redis_detector.redis_detected?
449
+ mongo_present = mongo_detector.mongo_detected?
291
450
 
292
451
  # Display results
293
452
  say "\nDetected versions:", :green
@@ -319,31 +478,42 @@ module Harbinger
319
478
  say " MySQL: Present (version not detected)", :yellow
320
479
  end
321
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
491
+ end
492
+
322
493
  # Fetch and display EOL dates
323
- if ruby_version || rails_version || postgres_version || mysql_version
494
+ if ruby_version || rails_version || postgres_version || mysql_version || redis_version || mongo_version
324
495
  say "\nFetching EOL data...", :cyan
325
496
  fetcher = EolFetcher.new
326
497
 
327
- if ruby_version
328
- display_eol_info(fetcher, "Ruby", ruby_version)
329
- end
498
+ display_eol_info(fetcher, "Ruby", ruby_version) if ruby_version
330
499
 
331
- if rails_version
332
- display_eol_info(fetcher, "Rails", rails_version)
333
- end
500
+ display_eol_info(fetcher, "Rails", rails_version) if rails_version
334
501
 
335
502
  if postgres_version && !postgres_version.include?("gem")
336
503
  display_eol_info(fetcher, "PostgreSQL", postgres_version)
337
504
  end
338
505
 
339
- if mysql_version && !mysql_version.include?("gem")
340
- display_eol_info(fetcher, "MySQL", mysql_version)
341
- end
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")
342
511
  end
343
512
 
344
513
  # Save to config if --save flag is used
345
514
  if options[:save] && !options[:recursive]
346
- save_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version)
515
+ save_project_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version,
516
+ redis_version, mongo_version)
347
517
  elsif options[:save] && options[:recursive]
348
518
  # In recursive mode, save without the confirmation message for each project
349
519
  config_manager = ConfigManager.new
@@ -351,19 +521,22 @@ module Harbinger
351
521
  config_manager.save_project(
352
522
  name: project_name,
353
523
  path: project_path,
354
- versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version, mysql: mysql_version }.compact
524
+ versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
525
+ mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
355
526
  )
356
527
  end
357
528
  end
358
529
 
359
- def save_to_config(project_path, ruby_version, rails_version, postgres_version = nil, mysql_version = nil)
530
+ def save_project_to_config(project_path, ruby_version, rails_version, postgres_version, mysql_version,
531
+ redis_version, mongo_version)
360
532
  config_manager = ConfigManager.new
361
533
  project_name = File.basename(project_path)
362
534
 
363
535
  config_manager.save_project(
364
536
  name: project_name,
365
537
  path: project_path,
366
- versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version, mysql: mysql_version }.compact
538
+ versions: { ruby: ruby_version, rails: rails_version, postgres: postgres_version,
539
+ mysql: mysql_version, redis: redis_version, mongo: mongo_version }.compact
367
540
  )
368
541
 
369
542
  say "\n✓ Saved to config as '#{project_name}'", :green
@@ -393,7 +566,7 @@ module Harbinger
393
566
  end
394
567
 
395
568
  def eol_color(days)
396
- if days < 0
569
+ if days.negative?
397
570
  :red
398
571
  elsif days < 180 # < 6 months
399
572
  :yellow
@@ -403,7 +576,7 @@ module Harbinger
403
576
  end
404
577
 
405
578
  def eol_status(days)
406
- if days < 0
579
+ if days.negative?
407
580
  "ALREADY EOL (#{days.abs} days ago)"
408
581
  elsif days < 30
409
582
  "ENDING SOON (#{days} days remaining)"
@@ -437,5 +610,23 @@ module Harbinger
437
610
  text
438
611
  end
439
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
440
631
  end
441
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,16 +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]}"
43
40
  major = version_parts[0]
41
+ major_minor = version_parts[1] ? "#{major}.#{version_parts[1]}" : nil
44
42
 
45
- # Try major.minor first (e.g., "8.0" for MySQL, "3.2" for Ruby)
46
- entry = data.find { |item| item["cycle"] == major_minor }
47
- return entry["eol"] if entry
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
48
 
49
- # Fall back to major only (e.g., "16" for PostgreSQL)
49
+ # Try major only (e.g., "16" for PostgreSQL)
50
50
  entry = data.find { |item| item["cycle"] == major }
51
- entry ? entry["eol"] : nil
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?
57
+
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
52
61
  end
53
62
 
54
63
  private
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "harbinger/eol_fetcher"
5
+
6
+ module Harbinger
7
+ module Exporters
8
+ # Base class for exporters that transform project data into various formats
9
+ class BaseExporter
10
+ COMPONENTS = %w[ruby rails postgres mysql redis mongo].freeze
11
+ PRODUCT_NAMES = {
12
+ "ruby" => "ruby",
13
+ "rails" => "rails",
14
+ "postgres" => "postgresql",
15
+ "mysql" => "mysql",
16
+ "redis" => "redis",
17
+ "mongo" => "mongodb"
18
+ }.freeze
19
+
20
+ def initialize(projects, fetcher: nil)
21
+ @projects = projects
22
+ @fetcher = fetcher || EolFetcher.new
23
+ end
24
+
25
+ def export
26
+ raise NotImplementedError, "Subclasses must implement #export"
27
+ end
28
+
29
+ protected
30
+
31
+ def build_export_data
32
+ @projects.filter_map do |name, data|
33
+ components = build_components(data)
34
+ next if components.empty?
35
+
36
+ {
37
+ name: name,
38
+ path: data["path"],
39
+ components: components,
40
+ overall_status: determine_overall_status(components)
41
+ }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def build_components(data)
48
+ COMPONENTS.filter_map do |component|
49
+ version = data[component]
50
+ next if version.nil? || version.empty?
51
+ next if version.include?("gem")
52
+
53
+ product = PRODUCT_NAMES[component]
54
+ eol_date = @fetcher.eol_date_for(product, version)
55
+ days = eol_date ? days_until(eol_date) : nil
56
+ status = days ? calculate_status(days) : "unknown"
57
+
58
+ {
59
+ name: component,
60
+ version: version,
61
+ eol_date: eol_date,
62
+ days_remaining: days,
63
+ status: status
64
+ }
65
+ end
66
+ end
67
+
68
+ def calculate_status(days)
69
+ if days.negative?
70
+ "eol"
71
+ elsif days < 180
72
+ "warning"
73
+ else
74
+ "safe"
75
+ end
76
+ end
77
+
78
+ def determine_overall_status(components)
79
+ statuses = components.map { |c| c[:status] }
80
+ return "unknown" if statuses.empty? || statuses.all? { |s| s == "unknown" }
81
+
82
+ if statuses.include?("eol")
83
+ "eol"
84
+ elsif statuses.include?("warning")
85
+ "warning"
86
+ else
87
+ "safe"
88
+ end
89
+ end
90
+
91
+ def days_until(date_string)
92
+ eol_date = Date.parse(date_string)
93
+ (eol_date - Date.today).to_i
94
+ end
95
+ end
96
+ end
97
+ end