gem_changelog_diff 0.7.0 → 0.8.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: cb1f64ee5cd989dcb791da00dde70d752f297d0cc640e455cac541eedf09bc8b
4
- data.tar.gz: 303b4fa0ce947b0ad9dab918294674f8fb90881c9017d30279e6afbf8e02ca03
3
+ metadata.gz: c69caa73eea6c508419eff5debb1b5b75bd56edf29140519fb451a433ed67272
4
+ data.tar.gz: 310b2803925afeb1a107d5805d8f20e681c3257962bb762fcf0db11fb5c78c60
5
5
  SHA512:
6
- metadata.gz: 7f9897600fc4fd863d2f99d2152c6e2c4ae68669be8ec6fa37e824356dd952cd80367742587a91a53e13ffd6e1b4d5bbe7cc435e2fe96aac4970b9fe6dc9b443
7
- data.tar.gz: c3f9b5411ac024a224b0f0b25926a2f33dda76cf6a1a5689831c4a616788aa8659310a68bd379558ee9ed1fe9e5eeb2166127953147f8b30580d4be0a36d3453
6
+ metadata.gz: 919f9f8da6bd23c1b2e5210ac0d4bd7f66217f4dd897b735006605858552c757eeffdf962acb23f97ec307fc2c1957964acd787ee9704ccf9061758cde4adb16
7
+ data.tar.gz: 1808e80f094a1035fc34245dd21a3965fd63a35d794e66c9a66d74b4405deba9fb84e0f15360419561b160a1b21807d82f170141aa2a666060b0d0777f8a7a8b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-06-18
11
+
12
+ ### Added
13
+
14
+ - `UriResolver` class for source URI resolution with non-GitHub host detection
15
+ - Informative skip messages for gems hosted on GitLab, Codeberg, Bitbucket, and SourceHut
16
+ - Automatic redirect following for renamed GitHub repositories (up to 3 hops)
17
+ - `RepoNotFoundError` raised with descriptive messages for non-GitHub gems
18
+ - `TagMatcher` class for tag format normalization (`v1.2.3`, `1.2.3`, `gem_name-1.2.3`, `release-1.2.3`)
19
+ - GitHub API pagination for gems with 100+ releases (up to 1000 releases)
20
+ - Early termination when paginated releases pass the current version
21
+ - Per-request timeout (10s default) and total timeout (120s default)
22
+ - `--timeout` flag to set per-request timeout
23
+ - `request_timeout` and `total_timeout` config file options
24
+ - Interactive mode help hint: "(Space to select, Enter to confirm)"
25
+ - `JSON::ParserError` handling in gem report builder to prevent worker thread crashes
26
+ - Wider network error handling: `Errno::ETIMEDOUT`, `Errno::ECONNRESET`, `OpenSSL::SSL::SSLError`
27
+
28
+ ### Changed
29
+
30
+ - `RubygemsClient` delegates URI resolution to `UriResolver` (extracted `extract_github_repo`)
31
+ - Error message for missing repo changed from "Could not find GitHub repository" to "Could not determine source repository"
32
+ - `GithubClient` uses `TagMatcher` for version extraction (replaced `TAG_VERSION_REGEX`)
33
+ - `GithubClient` fetches 100 releases per page (was 30) with pagination support
34
+ - `GithubClient` returns empty array for HTTP 301 responses (redirects handled by `UriResolver`)
35
+ - All `Gem::Version.new` calls wrapped in `safe_gem_version` helper to prevent `ArgumentError` crashes
36
+ - `ConcurrentFetcher` wrapped in total timeout to prevent runaway operations
37
+ - HTTP requests in `Cache`, `RubygemsClient`, and `ChangelogParser` use configurable timeouts
38
+
10
39
  ## [0.7.0] - 2026-06-18
11
40
 
12
41
  ### Added
@@ -89,7 +118,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
89
118
  - Plain text formatter for changelog output
90
119
  - Full end-to-end pipeline: detect → lookup → fetch → format
91
120
 
92
- [Unreleased]: https://github.com/eclectic-coding/gem_changelog_diff/compare/v0.7.0...HEAD
121
+ [Unreleased]: https://github.com/eclectic-coding/gem_changelog_diff/compare/v0.8.0...HEAD
122
+ [0.8.0]: https://github.com/eclectic-coding/gem_changelog_diff/releases/tag/v0.8.0
93
123
  [0.7.0]: https://github.com/eclectic-coding/gem_changelog_diff/releases/tag/v0.7.0
94
124
  [0.6.0]: https://github.com/eclectic-coding/gem_changelog_diff/releases/tag/v0.6.0
95
125
  [0.5.0]: https://github.com/eclectic-coding/gem_changelog_diff/releases/tag/v0.5.0
data/README.md CHANGED
@@ -21,7 +21,8 @@ CLI that shows you the changelog diff for each gem before you `bundle update`, p
21
21
  - [Show Subcommand](#show-subcommand)
22
22
  - [Caching](#caching)
23
23
  - [Dry Run](#dry-run)
24
- - [Configuration File](#configuration-file)
24
+ - [Timeouts](#timeouts)
25
+ - [Configuration File](#configuration-file)
25
26
  - [Development](#development)
26
27
  - [Contributing](#contributing)
27
28
  - [License](#license)
@@ -55,6 +56,10 @@ gem_changelog_diff version # Print version
55
56
  gem_changelog_diff --version # Same as above
56
57
  ```
57
58
 
59
+ ### Non-GitHub Gems
60
+
61
+ Gems hosted on GitHub are fully supported. Gems on other platforms (GitLab, Codeberg, Bitbucket, SourceHut) are detected and skipped with an informative message. Renamed GitHub repositories are followed automatically via redirect.
62
+
58
63
  ### GitHub Authentication
59
64
 
60
65
  To avoid the 60 requests/hour unauthenticated rate limit, provide a GitHub personal access token:
@@ -157,6 +162,14 @@ gem_changelog_diff --dry-run --format json # JSON array of gem objects
157
162
  gem_changelog_diff --dry-run --format markdown # Markdown bullet list
158
163
  ```
159
164
 
165
+ ### Timeouts
166
+
167
+ ```bash
168
+ gem_changelog_diff --timeout 30 # Per-request timeout in seconds (default: 10)
169
+ ```
170
+
171
+ The total operation timeout (default: 120s) limits how long concurrent fetching can run. Both values are configurable via the config file.
172
+
160
173
  ### Configuration File
161
174
 
162
175
  Generate a config file template:
@@ -181,6 +194,8 @@ ignore_gems:
181
194
  - rake
182
195
  - bundler
183
196
  no_color: false
197
+ request_timeout: 10 # per-request timeout in seconds
198
+ total_timeout: 120 # total operation timeout in seconds
184
199
  ```
185
200
 
186
201
  CLI flags always take priority over config file values.
data/ROADMAP.md CHANGED
@@ -2,20 +2,6 @@
2
2
 
3
3
  Feature roadmap for gem_changelog_diff. Each section is auto-pruned by `bin/release` when that version ships.
4
4
 
5
- ## 0.8.0 -- Robustness & Edge Cases
6
-
7
- Handle the long tail of real-world gem repository patterns.
8
-
9
- - Source URI resolution: detect and skip GitLab/Codeberg gracefully, follow redirects for renamed repos, handle monorepo subdirectory URIs
10
- - Tag format normalization: `v1.2.3`, `1.2.3`, `gem_name-1.2.3`, `release-1.2.3`
11
- - Proper version comparison via `Gem::Version` (handles pre-release: `1.0.0.rc1`, `1.0.0.beta2`)
12
- - GitHub API pagination for gems with 100+ releases
13
- - Per-request timeout (10s default), total timeout (120s default), configurable via `--timeout`
14
-
15
- **New files:** `tag_matcher.rb`, `uri_resolver.rb`
16
-
17
- ---
18
-
19
5
  ## 0.9.0 -- Pre-1.0 Stabilization
20
6
 
21
7
  Freeze the public API. Harden the test suite. Prepare documentation for stable release.
@@ -84,10 +84,13 @@ module GemChangelogDiff
84
84
  end
85
85
 
86
86
  def fetch_from_network(uri, headers)
87
+ timeout = GemChangelogDiff.configuration.request_timeout
87
88
  request = Net::HTTP::Get.new(uri)
88
89
  headers.each { |k, v| request[k] = v }
89
90
 
90
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
91
+ Net::HTTP.start(uri.hostname, uri.port,
92
+ use_ssl: uri.scheme == "https",
93
+ open_timeout: timeout, read_timeout: timeout) do |http|
91
94
  http.request(request)
92
95
  end
93
96
  end
@@ -19,7 +19,9 @@ module GemChangelogDiff
19
19
 
20
20
  parse_entries(content, current_version, newest_version)
21
21
  rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
22
- Net::OpenTimeout, Net::ReadTimeout => e
22
+ Errno::ETIMEDOUT, Errno::ECONNRESET,
23
+ Net::OpenTimeout, Net::ReadTimeout,
24
+ OpenSSL::SSL::SSLError => e
23
25
  raise NetworkError, "GitHub API request failed: #{e.message}"
24
26
  end
25
27
 
@@ -47,9 +49,13 @@ module GemChangelogDiff
47
49
  headers = request_headers
48
50
  return @cache.get(uri, headers: headers) if @cache
49
51
 
52
+ timeout = GemChangelogDiff.configuration.request_timeout
50
53
  request = Net::HTTP::Get.new(uri)
51
54
  headers.each { |k, v| request[k] = v }
52
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
55
+ Net::HTTP.start(uri.hostname, uri.port,
56
+ use_ssl: true, open_timeout: timeout, read_timeout: timeout) do |http|
57
+ http.request(request)
58
+ end
53
59
  end
54
60
 
55
61
  def request_headers
@@ -63,21 +69,28 @@ module GemChangelogDiff
63
69
  end
64
70
 
65
71
  def parse_entries(content, current_version, newest_version)
66
- current = Gem::Version.new(current_version)
67
- newest = Gem::Version.new(newest_version)
68
- sections = split_sections(content)
72
+ current = safe_gem_version(current_version)
73
+ newest = safe_gem_version(newest_version)
74
+ return [] unless current && newest
69
75
 
76
+ sections = split_sections(content)
70
77
  matched = sections.filter_map { |v, body| build_entry(v, body, current, newest) }
71
- matched.sort_by { |e| Gem::Version.new(e[:tag_name]) }.reverse
78
+ matched.sort_by { |e| safe_gem_version(e[:tag_name]) || Gem::Version.new("0") }.reverse
72
79
  end
73
80
 
74
81
  def build_entry(version_str, body, current, newest)
75
- gem_version = Gem::Version.new(version_str)
76
- return unless gem_version > current && gem_version <= newest
82
+ gem_version = safe_gem_version(version_str)
83
+ return unless gem_version && gem_version > current && gem_version <= newest
77
84
 
78
85
  { tag_name: version_str, name: version_str, published_at: nil, body: body.strip }
79
86
  end
80
87
 
88
+ def safe_gem_version(version_str)
89
+ Gem::Version.new(version_str)
90
+ rescue ArgumentError
91
+ nil
92
+ end
93
+
81
94
  def split_sections(content)
82
95
  sections = []
83
96
  current_version = nil
@@ -26,13 +26,12 @@ module GemChangelogDiff
26
26
  class_option :interactive, type: :boolean, default: false, aliases: "-i",
27
27
  desc: "Interactively select gems to check"
28
28
  class_option :dry_run, type: :boolean, default: false, desc: "Show which gems would be checked"
29
+ class_option :timeout, type: :numeric, desc: "Per-request timeout in seconds (default: 10)"
29
30
 
30
31
  desc "check [GEM...]", "Show changelog diffs for outdated gems"
31
32
  def check(*gem_names)
32
- load_config
33
- configure_token
34
- gems = detect_gems
35
- gems = filter_gems(gems, gem_names)
33
+ setup_environment
34
+ gems = filter_gems(detect_gems, gem_names)
36
35
  return say("All gems are up to date!") if gems.empty?
37
36
 
38
37
  gems = Interactive.new(gems: gems).select if options[:interactive]
@@ -44,8 +43,7 @@ module GemChangelogDiff
44
43
 
45
44
  desc "show GEM FROM_VERSION TO_VERSION", "Show changelog between two versions of a gem"
46
45
  def show(gem_name, from_version, to_version)
47
- load_config
48
- configure_token
46
+ setup_environment
49
47
  gem = OutdatedGem.new(name: gem_name, current_version: from_version, newest_version: to_version)
50
48
  report = build_single_report(gem)
51
49
  formatter = Formatters.build(format: resolved_format, color: color_enabled?)
@@ -84,6 +82,12 @@ module GemChangelogDiff
84
82
 
85
83
  private
86
84
 
85
+ def setup_environment
86
+ load_config
87
+ configure_token
88
+ configure_timeout
89
+ end
90
+
87
91
  def load_config
88
92
  config = ConfigLoader.new.load
89
93
  GemChangelogDiff.configuration.apply(config)
@@ -96,6 +100,11 @@ module GemChangelogDiff
96
100
  GemChangelogDiff.configuration.github_token = token if token
97
101
  end
98
102
 
103
+ def configure_timeout
104
+ timeout = options[:timeout] || GemChangelogDiff.configuration.request_timeout
105
+ GemChangelogDiff.configuration.request_timeout = timeout
106
+ end
107
+
99
108
  def rails_credentials_token
100
109
  return unless defined?(Rails) && Rails.application.respond_to?(:credentials)
101
110
 
@@ -157,15 +166,26 @@ module GemChangelogDiff
157
166
 
158
167
  def build_gem_report(gem, rubygems_client, source_resolver)
159
168
  log "Checking #{gem.name}..."
169
+ fetch_gem_releases(gem, rubygems_client, source_resolver)
170
+ rescue GemChangelogDiff::Error => e
171
+ log_warning " Skipping #{gem.name}: #{e.message}"
172
+ gem_error(gem, e.message)
173
+ rescue JSON::ParserError => e
174
+ log_warning " Skipping #{gem.name}: malformed API response"
175
+ gem_error(gem, "Malformed API response: #{e.message}")
176
+ end
177
+
178
+ def fetch_gem_releases(gem, rubygems_client, source_resolver)
160
179
  repo = rubygems_client.repo_url(gem.name)
161
- return { gem: gem, releases: [], error: " Could not find GitHub repository." } if repo.nil?
180
+ return gem_error(gem, "Could not determine source repository.") unless repo
162
181
 
163
182
  log " Found repo: #{repo}"
164
183
  releases = source_resolver.resolve(repo, gem.current_version, gem.newest_version)
165
184
  { gem: gem, releases: releases }
166
- rescue GemChangelogDiff::Error => e
167
- log_warning " Skipping #{gem.name}: #{e.message}"
168
- { gem: gem, releases: [], error: " #{e.message}" }
185
+ end
186
+
187
+ def gem_error(gem, message)
188
+ { gem: gem, releases: [], error: " #{message}" }
169
189
  end
170
190
 
171
191
  def with_spinner
@@ -257,6 +277,12 @@ module GemChangelogDiff
257
277
 
258
278
  # Disable colored output
259
279
  # no_color: false
280
+
281
+ # Per-request timeout in seconds (default: 10)
282
+ # request_timeout: 10
283
+
284
+ # Total timeout in seconds (default: 120)
285
+ # total_timeout: 120
260
286
  YAML
261
287
  end
262
288
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module GemChangelogDiff
4
6
  class ConcurrentFetcher
5
7
  def initialize(concurrency: 4)
@@ -9,6 +11,15 @@ module GemChangelogDiff
9
11
  def fetch_all(items, &)
10
12
  return items.map(&) if @concurrency <= 1
11
13
 
14
+ total_timeout = GemChangelogDiff.configuration.total_timeout
15
+ Timeout.timeout(total_timeout, NetworkError, "Total timeout of #{total_timeout}s exceeded") do
16
+ run_workers(items, &)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def run_workers(items, &)
12
23
  results = Array.new(items.size)
13
24
  queue = Queue.new
14
25
  items.each_with_index { |item, i| queue << [item, i] }
@@ -18,8 +29,6 @@ module GemChangelogDiff
18
29
  results
19
30
  end
20
31
 
21
- private
22
-
23
32
  def spawn_workers(queue, results, &block)
24
33
  worker_count = [@concurrency, queue.size].min
25
34
  worker_count.times.map do
@@ -3,9 +3,11 @@
3
3
  module GemChangelogDiff
4
4
  class Configuration
5
5
  attr_accessor :github_token, :cache_enabled, :cache_ttl,
6
- :default_format, :concurrency, :ignore_gems, :no_color
6
+ :default_format, :concurrency, :ignore_gems, :no_color,
7
+ :request_timeout, :total_timeout
7
8
 
8
- VALID_KEYS = %i[github_token cache_enabled cache_ttl default_format concurrency ignore_gems no_color].freeze
9
+ VALID_KEYS = %i[github_token cache_enabled cache_ttl default_format concurrency
10
+ ignore_gems no_color request_timeout total_timeout].freeze
9
11
 
10
12
  def initialize
11
13
  @cache_enabled = true
@@ -14,6 +16,8 @@ module GemChangelogDiff
14
16
  @concurrency = 4
15
17
  @ignore_gems = []
16
18
  @no_color = false
19
+ @request_timeout = 10
20
+ @total_timeout = 120
17
21
  end
18
22
 
19
23
  def apply(hash)
@@ -6,39 +6,88 @@ require "json"
6
6
  module GemChangelogDiff
7
7
  class GithubClient
8
8
  RELEASES_URL = "https://api.github.com/repos/%<repo>s/releases"
9
- TAG_VERSION_REGEX = /\Av?(\d+\..+)\z/
10
9
  RATE_LIMIT_WARNING_THRESHOLD = 10
10
+ MAX_PAGES = 10
11
11
 
12
12
  def initialize(cache: nil)
13
13
  @cache = cache
14
14
  end
15
15
 
16
16
  def releases_between(repo, current_version, newest_version)
17
- releases = fetch_releases(repo)
17
+ gem_name = repo.split("/").last
18
+ @active_matcher = TagMatcher.new(gem_name: gem_name)
19
+ releases = fetch_releases(repo, current_version)
18
20
  filter_releases(releases, current_version, newest_version)
19
21
  end
20
22
 
21
23
  private
22
24
 
23
- def fetch_releases(repo)
24
- uri = URI(format(RELEASES_URL, repo: repo))
25
- uri.query = URI.encode_www_form(per_page: 30)
25
+ def fetch_releases(repo, current_version = nil)
26
+ current = current_version ? safe_gem_version(current_version) : nil
27
+ paginate_releases(repo, current)
28
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
29
+ Errno::ETIMEDOUT, Errno::ECONNRESET,
30
+ Net::OpenTimeout, Net::ReadTimeout,
31
+ OpenSSL::SSL::SSLError => e
32
+ raise NetworkError, "GitHub API request failed: #{e.message}"
33
+ end
34
+
35
+ def paginate_releases(repo, current)
36
+ all_releases = []
37
+
38
+ (1..MAX_PAGES).each do |page|
39
+ response, page_releases = fetch_release_page(repo, page)
40
+ break if page_releases.empty?
26
41
 
42
+ all_releases.concat(page_releases)
43
+ break if current && oldest_before_current?(page_releases, current)
44
+ break unless next_page?(response)
45
+ end
46
+
47
+ all_releases
48
+ end
49
+
50
+ def fetch_release_page(repo, page)
51
+ uri = build_releases_uri(repo, page)
27
52
  response = execute_request(uri)
28
53
  check_rate_limit(response)
29
- handle_response(response)
30
- rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
31
- Net::OpenTimeout, Net::ReadTimeout => e
32
- raise NetworkError, "GitHub API request failed: #{e.message}"
54
+ [response, handle_response(response)]
55
+ end
56
+
57
+ def build_releases_uri(repo, page)
58
+ uri = URI(format(RELEASES_URL, repo: repo))
59
+ uri.query = URI.encode_www_form(per_page: 100, page: page)
60
+ uri
61
+ end
62
+
63
+ def next_page?(response)
64
+ link = response["Link"]
65
+ return false unless link
66
+
67
+ link.include?('rel="next"')
68
+ end
69
+
70
+ def oldest_before_current?(releases, current)
71
+ releases.any? do |r|
72
+ version = @active_matcher.extract_version(r["tag_name"])
73
+ next false unless version
74
+
75
+ gem_version = safe_gem_version(version)
76
+ gem_version && gem_version < current
77
+ end
33
78
  end
34
79
 
35
80
  def execute_request(uri)
36
81
  headers = request_headers
37
82
  return @cache.get(uri, headers: headers) if @cache
38
83
 
84
+ timeout = GemChangelogDiff.configuration.request_timeout
39
85
  request = Net::HTTP::Get.new(uri)
40
86
  headers.each { |k, v| request[k] = v }
41
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
87
+ Net::HTTP.start(uri.hostname, uri.port,
88
+ use_ssl: true, open_timeout: timeout, read_timeout: timeout) do |http|
89
+ http.request(request)
90
+ end
42
91
  end
43
92
 
44
93
  def request_headers
@@ -52,7 +101,7 @@ module GemChangelogDiff
52
101
  end
53
102
 
54
103
  def handle_response(response)
55
- return [] if response.code == "404"
104
+ return [] if %w[301 404].include?(response.code)
56
105
 
57
106
  if response.code == "403" && response["X-RateLimit-Remaining"] == "0"
58
107
  raise RateLimitError, "GitHub API rate limit exceeded. Use --token to authenticate."
@@ -71,31 +120,35 @@ module GemChangelogDiff
71
120
  end
72
121
 
73
122
  def filter_releases(releases, current_version, newest_version)
74
- current = Gem::Version.new(current_version)
75
- newest = Gem::Version.new(newest_version)
123
+ current = safe_gem_version(current_version)
124
+ newest = safe_gem_version(newest_version)
125
+ return [] unless current && newest
76
126
 
77
127
  matched = releases.filter_map { |r| build_release(r, current, newest) }
78
128
  sort_releases(matched)
79
129
  end
80
130
 
81
131
  def build_release(release, current, newest)
82
- version = extract_version(release["tag_name"])
83
- return unless version
132
+ version_str = @active_matcher.extract_version(release["tag_name"])
133
+ return unless version_str
84
134
 
85
- gem_version = Gem::Version.new(version)
86
- return unless gem_version > current && gem_version <= newest
135
+ gem_version = safe_gem_version(version_str)
136
+ return unless gem_version && gem_version > current && gem_version <= newest
87
137
 
88
138
  { tag_name: release["tag_name"], name: release["name"],
89
139
  published_at: release["published_at"], body: release["body"] }
90
140
  end
91
141
 
92
142
  def sort_releases(releases)
93
- releases.sort_by { |r| Gem::Version.new(extract_version(r[:tag_name])) }.reverse
143
+ releases.sort_by do |r|
144
+ safe_gem_version(@active_matcher.extract_version(r[:tag_name])) || Gem::Version.new("0")
145
+ end.reverse
94
146
  end
95
147
 
96
- def extract_version(tag)
97
- match = tag&.match(TAG_VERSION_REGEX)
98
- match ? match[1] : nil
148
+ def safe_gem_version(version_str)
149
+ Gem::Version.new(version_str)
150
+ rescue ArgumentError
151
+ nil
99
152
  end
100
153
  end
101
154
  end
@@ -10,7 +10,8 @@ module GemChangelogDiff
10
10
 
11
11
  def select
12
12
  prompt = TTY::Prompt.new
13
- prompt.multi_select("Select gems to check:", per_page: 15) do |menu|
13
+ prompt.multi_select("Select gems to check:",
14
+ per_page: 15, help: "(Space to select, Enter to confirm)") do |menu|
14
15
  @gems.each do |gem|
15
16
  menu.choice "#{gem.name} (#{gem.current_version} → #{gem.newest_version})", gem
16
17
  end
@@ -6,17 +6,17 @@ require "json"
6
6
  module GemChangelogDiff
7
7
  class RubygemsClient
8
8
  RUBYGEMS_API = "https://rubygems.org/api/v1/gems/%<name>s.json"
9
- GITHUB_REPO_REGEX = %r{github\.com/([^/]+)/([^/]+)}
10
9
 
11
- def initialize(cache: nil)
10
+ def initialize(cache: nil, uri_resolver: UriResolver.new)
12
11
  @cache = cache
12
+ @uri_resolver = uri_resolver
13
13
  end
14
14
 
15
15
  def repo_url(gem_name)
16
16
  data = fetch_gem_data(gem_name)
17
17
  return nil unless data
18
18
 
19
- extract_github_repo(data)
19
+ @uri_resolver.resolve(data)
20
20
  end
21
21
 
22
22
  def latest_version(gem_name)
@@ -28,29 +28,23 @@ module GemChangelogDiff
28
28
 
29
29
  def fetch_gem_data(gem_name)
30
30
  uri = URI(format(RUBYGEMS_API, name: gem_name))
31
- response = @cache ? @cache.get(uri) : Net::HTTP.get_response(uri)
31
+ response = @cache ? @cache.get(uri) : fetch_from_api(uri)
32
32
  return nil unless response.is_a?(Net::HTTPSuccess)
33
33
 
34
34
  JSON.parse(response.body)
35
35
  rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
36
- Net::OpenTimeout, Net::ReadTimeout => e
36
+ Errno::ETIMEDOUT, Errno::ECONNRESET,
37
+ Net::OpenTimeout, Net::ReadTimeout,
38
+ OpenSSL::SSL::SSLError, JSON::ParserError => e
37
39
  raise NetworkError, "RubyGems API request failed: #{e.message}"
38
40
  end
39
41
 
40
- def extract_github_repo(data)
41
- %w[source_code_uri homepage_uri bug_tracker_uri].each do |field|
42
- url = data[field]
43
- next if url.nil? || url.empty?
44
-
45
- match = url.match(GITHUB_REPO_REGEX)
46
- next unless match
47
-
48
- owner = match[1]
49
- repo = match[2].sub(/\.git\z/, "").sub(%r{/.*}, "")
50
- return "#{owner}/#{repo}"
42
+ def fetch_from_api(uri)
43
+ timeout = GemChangelogDiff.configuration.request_timeout
44
+ Net::HTTP.start(uri.hostname, uri.port,
45
+ use_ssl: true, open_timeout: timeout, read_timeout: timeout) do |http|
46
+ http.request(Net::HTTP::Get.new(uri))
51
47
  end
52
-
53
- nil
54
48
  end
55
49
  end
56
50
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemChangelogDiff
4
+ class TagMatcher
5
+ STANDARD_PATTERN = /\A(?:release-)?v?(\d+\..+)\z/
6
+
7
+ def initialize(gem_name: nil)
8
+ @gem_name = gem_name
9
+ end
10
+
11
+ def extract_version(tag)
12
+ return nil if tag.nil? || tag.strip.empty?
13
+
14
+ version = try_gem_prefixed(tag) || try_standard_pattern(tag)
15
+ return nil unless version
16
+
17
+ validate_version(version)
18
+ end
19
+
20
+ private
21
+
22
+ def try_gem_prefixed(tag)
23
+ return nil unless @gem_name
24
+
25
+ prefix = "#{@gem_name}-"
26
+ return nil unless tag.start_with?(prefix)
27
+
28
+ raw = tag.delete_prefix(prefix)
29
+ raw.start_with?("v") ? raw[1..] : raw
30
+ end
31
+
32
+ def try_standard_pattern(tag)
33
+ match = tag.match(STANDARD_PATTERN)
34
+ match ? match[1] : nil
35
+ end
36
+
37
+ def validate_version(version_str)
38
+ Gem::Version.new(version_str)
39
+ version_str
40
+ rescue ArgumentError
41
+ nil
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module GemChangelogDiff
7
+ class UriResolver
8
+ GITHUB_REGEX = %r{github\.com/([^/]+)/([^/]+)}
9
+ NON_GITHUB_HOSTS = {
10
+ "gitlab.com" => "GitLab",
11
+ "codeberg.org" => "Codeberg",
12
+ "bitbucket.org" => "Bitbucket",
13
+ "sr.ht" => "SourceHut"
14
+ }.freeze
15
+ URI_FIELDS = %w[source_code_uri homepage_uri bug_tracker_uri].freeze
16
+ MAX_REDIRECTS = 3
17
+
18
+ def resolve(gem_data)
19
+ uris = extract_uris(gem_data)
20
+ return nil if uris.empty?
21
+
22
+ non_github = detect_non_github(uris)
23
+ raise RepoNotFoundError, "hosted on #{non_github} (not supported)" if non_github
24
+
25
+ slug = extract_github_slug(uris)
26
+ return nil unless slug
27
+
28
+ follow_redirects(slug)
29
+ end
30
+
31
+ private
32
+
33
+ def extract_uris(data)
34
+ URI_FIELDS.filter_map do |field|
35
+ value = data[field]
36
+ value unless value.nil? || value.to_s.strip.empty?
37
+ end
38
+ end
39
+
40
+ def detect_non_github(uris)
41
+ uris.each do |url|
42
+ host = URI.parse(url).host&.downcase
43
+ NON_GITHUB_HOSTS.each do |domain, name|
44
+ return name if host&.end_with?(domain)
45
+ end
46
+ rescue URI::InvalidURIError
47
+ next
48
+ end
49
+ nil
50
+ end
51
+
52
+ def extract_github_slug(uris)
53
+ uris.each do |url|
54
+ match = url.match(GITHUB_REGEX)
55
+ next unless match
56
+
57
+ owner = match[1]
58
+ repo = match[2].sub(/\.git\z/, "").sub(%r{/.*}, "")
59
+ return "#{owner}/#{repo}"
60
+ end
61
+ nil
62
+ end
63
+
64
+ def follow_redirects(slug, remaining = MAX_REDIRECTS)
65
+ return slug if remaining <= 0
66
+
67
+ response = execute_head_request(URI("https://api.github.com/repos/#{slug}"))
68
+ return slug unless response.is_a?(Net::HTTPRedirection)
69
+
70
+ new_slug = extract_slug_from_location(response["Location"])
71
+ new_slug ? follow_redirects(new_slug, remaining - 1) : slug
72
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
73
+ Errno::ETIMEDOUT, Errno::ECONNRESET,
74
+ Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
75
+ slug
76
+ end
77
+
78
+ def extract_slug_from_location(location)
79
+ location&.match(%r{repos/([^/]+/[^/]+)})&.then { |m| m[1] }
80
+ end
81
+
82
+ def execute_head_request(uri)
83
+ request = Net::HTTP::Head.new(uri)
84
+ request_headers.each { |k, v| request[k] = v }
85
+ Net::HTTP.start(uri.hostname, uri.port,
86
+ use_ssl: true, open_timeout: 5, read_timeout: 5) do |http|
87
+ http.request(request)
88
+ end
89
+ end
90
+
91
+ def request_headers
92
+ headers = {
93
+ "Accept" => "application/vnd.github.v3+json",
94
+ "User-Agent" => "gem_changelog_diff/#{VERSION}"
95
+ }
96
+ token = GemChangelogDiff.configuration.github_token
97
+ headers["Authorization"] = "token #{token}" if token
98
+ headers
99
+ end
100
+ end
101
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GemChangelogDiff
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -12,6 +12,8 @@ require_relative "gem_changelog_diff/errors"
12
12
  require_relative "gem_changelog_diff/cache"
13
13
  require_relative "gem_changelog_diff/outdated_gem"
14
14
  require_relative "gem_changelog_diff/detector"
15
+ require_relative "gem_changelog_diff/uri_resolver"
16
+ require_relative "gem_changelog_diff/tag_matcher"
15
17
  require_relative "gem_changelog_diff/rubygems_client"
16
18
  require_relative "gem_changelog_diff/lockfile_parser"
17
19
  require_relative "gem_changelog_diff/github_client"
@@ -30,6 +30,8 @@ module GemChangelogDiff
30
30
  attr_accessor concurrency: Integer
31
31
  attr_accessor ignore_gems: Array[String]
32
32
  attr_accessor no_color: bool
33
+ attr_accessor request_timeout: Integer
34
+ attr_accessor total_timeout: Integer
33
35
 
34
36
  VALID_KEYS: Array[Symbol]
35
37
 
@@ -102,18 +104,48 @@ module GemChangelogDiff
102
104
  def parse: (String output) -> Array[OutdatedGem]
103
105
  end
104
106
 
107
+ class UriResolver
108
+ GITHUB_REGEX: Regexp
109
+ NON_GITHUB_HOSTS: Hash[String, String]
110
+ URI_FIELDS: Array[String]
111
+ MAX_REDIRECTS: Integer
112
+
113
+ def resolve: (Hash[String, untyped] gem_data) -> String?
114
+
115
+ private
116
+
117
+ def extract_uris: (Hash[String, untyped] data) -> Array[String]
118
+ def detect_non_github: (Array[String] uris) -> String?
119
+ def extract_github_slug: (Array[String] uris) -> String?
120
+ def follow_redirects: (String slug, ?Integer remaining) -> String
121
+ def execute_head_request: (URI::Generic uri) -> (Net::HTTPResponse)
122
+ def request_headers: () -> Hash[String, String]
123
+ end
124
+
125
+ class TagMatcher
126
+ STANDARD_PATTERN: Regexp
127
+
128
+ def initialize: (?gem_name: String?) -> void
129
+ def extract_version: (String? tag) -> String?
130
+
131
+ private
132
+
133
+ def try_gem_prefixed: (String tag) -> String?
134
+ def try_standard_pattern: (String tag) -> String?
135
+ def validate_version: (String version_str) -> String?
136
+ end
137
+
105
138
  class RubygemsClient
106
139
  RUBYGEMS_API: String
107
- GITHUB_REPO_REGEX: Regexp
108
140
 
109
- def initialize: (?cache: Cache?) -> void
141
+ def initialize: (?cache: Cache?, ?uri_resolver: UriResolver) -> void
110
142
  def repo_url: (String gem_name) -> String?
111
143
  def latest_version: (String gem_name) -> String?
112
144
 
113
145
  private
114
146
 
115
147
  def fetch_gem_data: (String gem_name) -> Hash[String, untyped]?
116
- def extract_github_repo: (Hash[String, untyped] data) -> String?
148
+ def fetch_from_api: (URI::Generic uri) -> Net::HTTPResponse
117
149
  end
118
150
 
119
151
  class LockfileParser
@@ -153,6 +185,7 @@ module GemChangelogDiff
153
185
  def process_line: (String line, Array[[String, String]] sections, String? current_version, Array[String] current_body) -> [String?, Array[String]]
154
186
  def flush_section: (Array[[String, String]] sections, String? version, Array[String] body) -> void
155
187
  def clean_version: (String raw) -> String
188
+ def safe_gem_version: (String version_str) -> Gem::Version?
156
189
  end
157
190
 
158
191
  class SourceResolver
@@ -162,15 +195,18 @@ module GemChangelogDiff
162
195
 
163
196
  class GithubClient
164
197
  RELEASES_URL: String
165
- TAG_VERSION_REGEX: Regexp
166
198
  RATE_LIMIT_WARNING_THRESHOLD: Integer
199
+ MAX_PAGES: Integer
167
200
 
168
201
  def initialize: (?cache: Cache?) -> void
169
202
  def releases_between: (String repo, String current_version, String newest_version) -> Array[release_hash]
170
203
 
171
204
  private
172
205
 
173
- def fetch_releases: (String repo) -> Array[Hash[String, untyped]]
206
+ def fetch_releases: (String repo, ?String? current_version) -> Array[Hash[String, untyped]]
207
+ def build_releases_uri: (String repo, Integer page) -> URI::Generic
208
+ def next_page?: ((Net::HTTPResponse | CachedResponse) response) -> bool
209
+ def oldest_before_current?: (Array[Hash[String, untyped]] releases, Gem::Version current) -> bool
174
210
  def execute_request: (URI::Generic uri) -> (Net::HTTPResponse | CachedResponse)
175
211
  def request_headers: () -> Hash[String, String]
176
212
  def handle_response: (Net::HTTPResponse | CachedResponse response) -> Array[Hash[String, untyped]]
@@ -178,7 +214,7 @@ module GemChangelogDiff
178
214
  def filter_releases: (Array[Hash[String, untyped]] releases, String current_version, String newest_version) -> Array[release_hash]
179
215
  def build_release: (Hash[String, untyped] release, Gem::Version current, Gem::Version newest) -> release_hash?
180
216
  def sort_releases: (Array[release_hash] releases) -> Array[release_hash]
181
- def extract_version: (String? tag) -> String?
217
+ def safe_gem_version: (String version_str) -> Gem::Version?
182
218
  end
183
219
 
184
220
  class ConcurrentFetcher
@@ -187,6 +223,7 @@ module GemChangelogDiff
187
223
 
188
224
  private
189
225
 
226
+ def run_workers: [T, U] (Array[T], &) -> Array[U]
190
227
  def spawn_workers: (Queue[untyped], Array[untyped], &) -> Array[Thread]
191
228
  def process_queue: (Queue[untyped], Array[untyped], &) -> void
192
229
  end
@@ -270,6 +307,7 @@ module GemChangelogDiff
270
307
  def load_config: () -> void
271
308
  def dry_run_output: (Array[OutdatedGem] gems) -> void
272
309
  def format_dry_run: (Array[OutdatedGem] gems) -> String
310
+ def configure_timeout: () -> void
273
311
  def rails_credentials_token: () -> String?
274
312
  def output_results: (Array[OutdatedGem] gems) -> void
275
313
  def resolved_format: () -> String
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gem_changelog_diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -104,6 +104,8 @@ files:
104
104
  - lib/gem_changelog_diff/outdated_gem.rb
105
105
  - lib/gem_changelog_diff/rubygems_client.rb
106
106
  - lib/gem_changelog_diff/source_resolver.rb
107
+ - lib/gem_changelog_diff/tag_matcher.rb
108
+ - lib/gem_changelog_diff/uri_resolver.rb
107
109
  - lib/gem_changelog_diff/version.rb
108
110
  - sig/gem_changelog_diff.rbs
109
111
  homepage: https://github.com/eclectic-coding/gem_changelog_diff