podrpt 0.1.0 → 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 912c6a263f85b22b28f5ae69765387bf330eecf5900b9c9a895bace0436622c4
4
- data.tar.gz: 229b466805159032d6f09fff9d6af0478dd19f144c84bdf813f27fa398ee450c
3
+ metadata.gz: 716eda643906d4b60d7d4e7baba1e004440c84ae01452a16b118800ca48a22a6
4
+ data.tar.gz: b2e8b2fd267002cf11476c6b9c35916801ba9c04b87ded24699fead6eb79c4db
5
5
  SHA512:
6
- metadata.gz: 158bf9f7c90ede213f086a9294f1e900c69f0cc3239227d60176a16671ecc0c81d67d69f1356fd90c8df7548ec75aae271f80896ef60a3128e67979f9f333d19
7
- data.tar.gz: 7e1d97dfdab3508f6b54097855fa45fea86b1ac68f0ed732dbcfc7d7a46da75abb1b252237f0fde882631e39282a39a815a0ec461627982efc5fe91a1811bc39
6
+ metadata.gz: 8341dd94f68516d6c296ab9b2ba2139f18f52443bcd64c78a50953a286c0b2995f2623483cb050508561ea04a6eef779456a2d2c1b282cfa549e5e12217c7a4b
7
+ data.tar.gz: 3ee27c63e944aa812e46d9c27cb3ff2390787bfd70ad5d1972d97c8e54da947ae4c2844cbc2ccb865c87b793059b0d77b1a5d16e7441f5549aa7c54f9731dd71
data/.DS_Store ADDED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2025-09-27
3
+ ## [1.0.0] - 2025-09-27
4
4
 
5
- - Initial release
5
+ - first release of podrpt with all the features so far
data/lib/podrpt/cli.rb ADDED
@@ -0,0 +1,120 @@
1
+ module Podrpt
2
+ class CLI
3
+ def self.start(args)
4
+ command = args.shift || 'run'
5
+ case command
6
+ when 'run'
7
+ run_reporter(args)
8
+ when 'init'
9
+ initialize_configuration
10
+ when '--version', '-v'
11
+ puts Podrpt::VERSION
12
+ else
13
+ puts "Unknown command: '#{command}'. Use 'run' or 'init'."
14
+ exit 1
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def self.initialize_configuration
21
+ puts "🚀 Starting podrpt configuration..."
22
+ Podrpt::Configuration.create_risk_file
23
+ Podrpt::Configuration.create_allowlist_file
24
+
25
+ puts "\nNow, please enter the URL where the Webhook will be sent to Slack:"
26
+ print "> "
27
+ url = $stdin.gets.chomp
28
+ if url.empty?
29
+ puts "❌ An error occurred and this step was skipped, talk to the gem admin"
30
+ else
31
+ Podrpt::Configuration.save_slack_url(url)
32
+ end
33
+ puts "\nSetup complete! Edit the .yaml files and run 'podrpt run'."
34
+ end
35
+
36
+ def self.run_reporter(args)
37
+ options = parse_run_options(args)
38
+
39
+ analyzer = Podrpt::LockfileAnalyzer.new(options.project_dir)
40
+ all_pods_versions = analyzer.pod_versions
41
+ classified_pods = analyzer.classify_pods
42
+
43
+ initial_filter = classified_pods[:spec_repo].dup
44
+ initial_filter -= classified_pods[:dev_path]
45
+ current_pods = all_pods_versions.slice(*initial_filter.to_a)
46
+
47
+ allowlist_config = load_allowlist(File.join(options.project_dir, options.allowlist_yaml))
48
+ pods_for_report = apply_allowlist_filter(current_pods, allowlist_config)
49
+ puts "[podrpt] Lock totals: #{all_pods_versions.size} | Pre-allowlist: #{current_pods.size} | Final report: #{pods_for_report.size}"
50
+ options.total_pods_count = pods_for_report.size
51
+
52
+ risk_config = load_risk_config(File.join(options.project_dir, options.risk_yaml))
53
+ if options.sync_risk_yaml
54
+ sync_risk_yaml(File.join(options.project_dir, options.risk_yaml), pods_for_report, risk_config)
55
+ end
56
+
57
+ version_fetcher = Podrpt::VersionFetcher.new(options)
58
+ latest_versions = version_fetcher.fetch_latest_versions_in_bulk(pods_for_report.keys)
59
+
60
+ final_analysis = []
61
+ pods_for_report.sort_by { |name, _| name.downcase }.each do |name, version|
62
+ pod_risk_info = risk_config['pods'][name] || risk_config['default']
63
+ analysis = Podrpt::PodAnalysis.new(name: name, current_version: version, latest_version: latest_versions[name], risk: pod_risk_info['risk'], owners: pod_risk_info['owners'] || [])
64
+ if options.only_outdated && !is_outdated(analysis.current_version, analysis.latest_version)
65
+ next
66
+ end
67
+ final_analysis << analysis
68
+ end
69
+
70
+ reporter = Podrpt::ReportGenerator.new(final_analysis, options)
71
+ report_text = reporter.build_report_text
72
+
73
+ Podrpt::SlackNotifier.notify(options.slack_webhook_url, report_text, dry_run: options.dry_run)
74
+ end
75
+
76
+ def self.parse_run_options(args)
77
+ options = Podrpt::Options.new(
78
+ project_dir: Dir.pwd,
79
+ risk_yaml: 'PodsRisk.yaml',
80
+ allowlist_yaml: 'PodsAllowlist.yaml',
81
+ only_outdated: true,
82
+ trunk_workers: 8,
83
+ slack_webhook_url: ENV['SLACK_WEBHOOK_URL'] || Podrpt::Configuration.load_slack_url,
84
+ dry_run: false
85
+ )
86
+
87
+ OptionParser.new do |opts|
88
+ opts.banner = "Usage: podrpt run [options]"
89
+ opts.on("--project-dir DIR", "Project DIR") { |v| options.project_dir = v }
90
+ opts.on("--slack-webhook-url URL", "URL Webhook (overwriting config)") { |v| options.slack_webhook_url = v }
91
+ opts.on("--show-all", "Show all pods") { |v| options.only_outdated = false }
92
+ opts.on("--sync-risk-yaml", "Sync PodsRisk.yaml") { |v| options.sync_risk_yaml = v }
93
+ opts.on("--dry-run", "Simulates sending to Slack, printing the payload in the terminal") { |v| options.dry_run = v }
94
+ end.parse!(args)
95
+
96
+ unless options.slack_webhook_url || options.dry_run
97
+ puts "❌ ERROR: Slack URL not configured. Run 'podrpt init' or use --dry-run."
98
+ exit 1
99
+ end
100
+ options
101
+ end
102
+
103
+ def self.load_allowlist(path); return {} unless File.exist?(path); config = YAML.load_file(path); config&.key?('allowlist') ? config['allowlist'] : {}; rescue; {}; end
104
+ def self.apply_allowlist_filter(all_pods, allowlist_config)
105
+ return all_pods if allowlist_config.nil? || allowlist_config.empty?
106
+ allowed_sets = allowlist_config.transform_values(&:to_set); vendor_prefixes = allowed_sets.keys
107
+ all_pods.select { |pod_name, _| matched_prefix = vendor_prefixes.find { |p| pod_name.start_with?(p) }; matched_prefix ? allowed_sets[matched_prefix].include?(pod_name) : true }
108
+ end
109
+ def self.load_risk_config(path)
110
+ return { 'default' => { 'risk' => 500, 'owners' => [] }, 'pods' => {} } unless File.exist?(path)
111
+ config = YAML.load_file(path) || {}; config['default'] ||= { 'risk' => 500, 'owners' => [] }; config['pods'] ||= {}; config
112
+ end
113
+ def self.sync_risk_yaml(path, pods, config)
114
+ pods.keys.sort_by(&:downcase).each { |name| config['pods'][name] ||= config['default'].dup }
115
+ File.write(path, config.to_yaml)
116
+ puts "[podrpt] PodsRisk.yaml synced with #{config['pods'].size} pods."
117
+ end
118
+ def self.is_outdated(current, latest); latest && !latest.empty? && Podrpt::VersionComparer.compare(latest, current) > 0; end
119
+ end
120
+ end
@@ -0,0 +1,38 @@
1
+ require 'yaml'
2
+
3
+ module Podrpt
4
+ class Configuration
5
+ CONFIG_FILE = '.podrpt.yml'.freeze
6
+ RISK_FILE = 'PodsRisk.yaml'.freeze
7
+ ALLOWLIST_FILE = 'PodsAllowlist.yaml'.freeze
8
+
9
+ def self.save_slack_url(url)
10
+ config = File.exist?(CONFIG_FILE) ? YAML.load_file(CONFIG_FILE) || {} : {}
11
+ config['slack_webhook_url'] = url
12
+ File.write(CONFIG_FILE, config.to_yaml)
13
+ puts "✅ Slack URL saved to #{CONFIG_FILE}"
14
+ end
15
+
16
+ def self.load_slack_url
17
+ return nil unless File.exist?(CONFIG_FILE)
18
+ YAML.load_file(CONFIG_FILE)['slack_webhook_url']
19
+ end
20
+
21
+ def self.create_risk_file
22
+ return if File.exist?(RISK_FILE)
23
+ content = {
24
+ 'default' => { 'risk' => 500, 'owners' => [] },
25
+ 'pods' => { '' => { 'risk' => 0, 'owners' => [''] } }
26
+ }.to_yaml
27
+ File.write(RISK_FILE, content)
28
+ puts "✅ Risk file '#{RISK_FILE}' created."
29
+ end
30
+
31
+ def self.create_allowlist_file
32
+ return if File.exist?(ALLOWLIST_FILE)
33
+ content = { 'allowlist' => { '' => [''] } }.to_yaml
34
+ File.write(ALLOWLIST_FILE, content)
35
+ puts "✅ Allow list '#{ALLOWLIST_FILE}' created."
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ module Podrpt
2
+ class LockfileAnalyzer
3
+ def initialize(project_dir)
4
+ lockfile_path = File.join(project_dir, 'Podfile.lock')
5
+ raise "ERRO: #{lockfile_path} not found." unless File.exist?(lockfile_path)
6
+ @lockfile = Pod::Lockfile.from_file(Pathname.new(lockfile_path))
7
+ end
8
+
9
+ def pod_versions
10
+ pod_versions_map = {}
11
+ @lockfile.pod_names.each do |pod_name|
12
+ root_name = Pod::Specification.root_name(pod_name)
13
+ pod_versions_map[root_name] ||= @lockfile.version(pod_name).to_s
14
+ end
15
+ pod_versions_map
16
+ end
17
+
18
+ def classify_pods
19
+ lockfile_hash = @lockfile.to_hash
20
+ all_pods = (@lockfile.pod_names || []).map { |n| Pod::Specification.root_name(n) }.to_set
21
+ git_pods = Set.new
22
+ dev_pods = Set.new
23
+ (lockfile_hash['EXTERNAL SOURCES'] || {}).each do |name, details|
24
+ root_name = Pod::Specification.root_name(name)
25
+ git_pods.add(root_name) if details.key?(:git)
26
+ dev_pods.add(root_name) if details.key?(:path)
27
+ end
28
+ spec_repo_pods = all_pods - git_pods - dev_pods
29
+ { spec_repo: spec_repo_pods, git_source: git_pods, dev_path: dev_pods }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # lib/podrpt/models.rb
2
+
3
+ module Podrpt
4
+ Options = Struct.new(
5
+ :project_dir, :risk_yaml, :allowlist_yaml, :trunk_workers,
6
+ :only_outdated, :sync_risk_yaml, :total_pods_count, :slack_webhook_url,
7
+ :dry_run,
8
+ keyword_init: true
9
+ )
10
+ PodAnalysis = Struct.new(
11
+ :name, :current_version, :latest_version,
12
+ :risk, :owners, keyword_init: true
13
+ )
14
+ end
@@ -0,0 +1,35 @@
1
+ module Podrpt
2
+ class ReportGenerator
3
+ def initialize(pods_analysis, options)
4
+ @pods = pods_analysis
5
+ @options = options
6
+ end
7
+
8
+ def build_report_text
9
+ outdated_count = @pods.count { |p| is_outdated?(p.current_version, p.latest_version) }
10
+ total_pods_in_report = @options.total_pods_count
11
+ header = "_Generated: #{Time.now.utc.iso8601}_\n" \
12
+ "_Outdated: #{outdated_count}/#{total_pods_in_report}_"
13
+
14
+ h_pod, h_ver, h_risk, h_owners = "Pod", "Versions", "Risk", "Owners"
15
+ pod_names = @pods.map(&:name); versions = @pods.map { |p| versions_cell(p) }; risks = @pods.map { |p| risk_cell(p) }; owners = @pods.map { |p| p.owners.empty? ? "—" : p.owners.join(', ') }
16
+ w_pod = [h_pod.length, pod_names.map(&:length).max || 0].max; w_ver = [h_ver.length, versions.map(&:length).max || 0].max; w_risk = [h_risk.length, risks.map(&:length).max || 0].max; w_owners = [h_owners.length, owners.map(&:length).max || 0].max
17
+
18
+ row_formatter = ->(c1, c2, c3, c4) { "| #{c1.ljust(w_pod)} | #{c2.ljust(w_ver)} | #{c3.rjust(w_risk)} | #{c4.ljust(w_owners)} |" }
19
+
20
+ lines = [header, ""]; lines << row_formatter.call(h_pod, h_ver, h_risk, h_owners)
21
+ sep_pod = ":-" + ("-" * (w_pod - 1)); sep_ver = ":-" + ("-" * (w_ver - 1)); sep_risk = ("-" * (w_risk - 1)) + ":"; sep_owners = ":-" + ("-" * (w_owners - 1))
22
+ lines << "|#{sep_pod}|#{sep_ver}|#{sep_risk}|#{sep_owners}|"
23
+ @pods.each_with_index { |_, i| lines << row_formatter.call(pod_names[i], versions[i], risks[i], owners[i]) }
24
+
25
+ lines.join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def is_outdated?(current, latest); latest && !latest.empty? && Podrpt::VersionComparer.compare(latest, current) > 0; end
31
+ def versions_cell(pod); current, latest = pod.current_version, pod.latest_version; return "#{current} (latest unknown)" if latest.nil? || latest.empty?; is_outdated?(current, latest) ? "#{current} -> #{latest}" : "#{current} (latest)"; end
32
+ def risk_cell(pod); "#{pod.risk} #{risk_emoji(pod.risk)}"; end
33
+ def risk_emoji(risk_value); return "🟢" if risk_value.nil?; case risk_value; when ...401 then "🟢"; when 401..700 then "🟡"; else "🔴"; end; end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # lib/podrpt/slack_notifier.rb
2
+
3
+ module Podrpt
4
+ class SlackNotifier
5
+ def self.notify(webhook_url, report_text, dry_run: false)
6
+ if dry_run
7
+ puts "\n--- SLACK NOTIFICATION DRY RUN ---"
8
+ puts "Target URL: #{webhook_url || 'Nenhuma URL fornecida'}"
9
+ puts "--- Payload ---"
10
+ puts report_text
11
+ puts "----------------------------------"
12
+ puts "Dry run completed. No notification was sent."
13
+ return
14
+ end
15
+
16
+ unless webhook_url && !webhook_url.empty?
17
+ puts "ERRO: Slack URL not provided. Logging out."
18
+ exit 1
19
+ end
20
+
21
+ puts "Sending report to Slack..."
22
+ headers = { 'Content-Type' => 'application/json' }
23
+ payload = { text: "```\n#{report_text}\n```" }.to_json
24
+
25
+ begin
26
+ response = HTTParty.post(webhook_url, body: payload, headers: headers)
27
+ if response.success?
28
+ puts "Report sent successfully!"
29
+ else
30
+ puts "ERROR sending to Slack. Status: #{response.code}, Response: #{response.body}"
31
+ exit 1
32
+ end
33
+ rescue => e
34
+ puts "Connection ERROR when sending to Slack: #{e.message}"
35
+ exit 1
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Podrpt
4
- VERSION = "0.1.0"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -0,0 +1,18 @@
1
+ module Podrpt
2
+ module VersionComparer
3
+ def self.tokenize(version); (version || '').to_s.scan(/[A-Za-z]+|\d+/).map { |t| t.match?(/\d+/) ? t.to_i : t.downcase }; end
4
+ def self.compare(a, b)
5
+ ta, tb = tokenize(a), tokenize(b)
6
+ [ta.length, tb.length].max.times do |i|
7
+ va, vb = ta[i] || 0, tb[i] || 0
8
+ if va.is_a?(vb.class)
9
+ next if va == vb
10
+ return va > vb ? 1 : -1
11
+ else
12
+ return va.is_a?(Integer) ? 1 : -1
13
+ end
14
+ end
15
+ 0
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,29 @@
1
+ module Podrpt
2
+ class VersionFetcher
3
+ def initialize(options)
4
+ @options = options
5
+ @sources_manager = Pod::Config.instance.sources_manager
6
+ end
7
+
8
+ def fetch_latest_versions_in_bulk(pod_names)
9
+ return {} if pod_names.empty?
10
+ puts "Discovering the latest version for #{pod_names.length} pods..."
11
+ results = Concurrent::Map.new
12
+ pool = Concurrent::ThreadPoolExecutor.new(max_threads: @options.trunk_workers)
13
+ pod_names.each { |name| pool.post { results[name] = find_latest_version(name) } }
14
+ pool.shutdown
15
+ pool.wait_for_termination
16
+ Hash[results.each_pair.to_a]
17
+ end
18
+
19
+ private
20
+
21
+ def find_latest_version(pod_name)
22
+ set = @sources_manager.search(Pod::Dependency.new(pod_name))
23
+ set&.highest_version.to_s
24
+ rescue => e
25
+ warn " WARNING: Failed to fetch version for #{pod_name}: #{e.message}"
26
+ nil
27
+ end
28
+ end
29
+ end
data/podrpt-0.1.0.gem ADDED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: podrpt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Alves
@@ -74,6 +74,7 @@ executables:
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
+ - ".DS_Store"
77
78
  - CHANGELOG.md
78
79
  - CODE_OF_CONDUCT.md
79
80
  - Gemfile
@@ -82,7 +83,16 @@ files:
82
83
  - Rakefile
83
84
  - bin/podrpt
84
85
  - lib/podrpt.rb
86
+ - lib/podrpt/cli.rb
87
+ - lib/podrpt/configuration.rb
88
+ - lib/podrpt/lockfile_analyzer.rb
89
+ - lib/podrpt/models.rb
90
+ - lib/podrpt/report_generator.rb
91
+ - lib/podrpt/slack_notifier.rb
85
92
  - lib/podrpt/version.rb
93
+ - lib/podrpt/version_comparer.rb
94
+ - lib/podrpt/version_fetcher.rb
95
+ - podrpt-0.1.0.gem
86
96
  - sig/podrpt.rbs
87
97
  homepage: https://github.com/swiftdrew/podrpt
88
98
  licenses: