gemstar 0.0.1 → 1.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: 80dede87ff46bc8aefd962ccd46dffcb199357eca04173b9610f85e74c74111a
4
- data.tar.gz: dfd221d5ec0cdd3c3511fdd1e7980fb8bc00050afab9d69cdb81c43d1673fee6
3
+ metadata.gz: 65596efbc0ff5c10ad950ca3cc8bb19e9ca9030db58b73d046ebce447200aa07
4
+ data.tar.gz: e5ab0cbb7f3db4b3d9949011f3b6446aecb725ee1863fd61dd196660332606de
5
5
  SHA512:
6
- metadata.gz: 4d160d053c3e60ef38d7b129876159a2d696015857d917f1dceb82afcd3e648d8e5736232535a9d79ef84e11681591019245317f5f15c3d7b24d06292e6c15f7
7
- data.tar.gz: bcadfa71fac1e0be9c6777217cba5797e729ead523383a7f049984e47f9b9c24952cb9ae2d7a4eb7f03615cc8f4aeaafa13961cbedcfde9dd94684d601bcb0f2
6
+ metadata.gz: ef75cdf526345f289eff5f0a8bff44520eb1d0aea55eff617de71d585832721655fd70c680e0e27ef7584c15dd4f304d9ee3b079c70fbafc7e4ccc5c8360c4ad
7
+ data.tar.gz: 13e3d871e1772c7f8ec183a3801504123896e8bd5365bba2a3e6fae5668415dcf2a40ef29f3494a2e9bef47251a3b913c37ae122879c3e8367cba9cf4490af1e
data/CHANGELOG.md CHANGED
@@ -1,19 +1,19 @@
1
1
  # Change Log
2
2
 
3
- ## TODO
4
- - Diff:
5
- - Gems:
6
- - benchmark
7
- - brakeman
8
- - json (not using CHANGES.md?)
9
- - nio4r not using releases.md?
10
- - parser not using CHANGELOG.md?
11
- - actioncable-next uses release tag names?
12
- - paper_trail not using CHANGELOG.md?
13
- - playwright-ruby-client uses release tags?
14
- - support ```ruby ```
3
+ ## Unreleased
15
4
 
16
- ## 0.1
5
+ - Added `gemstar server`, your interactive Gemfile.lock explorer and more.
6
+ - Default location for `diff` is now a tmp file.
7
+ - Removed Railtie from this gem.
8
+ - Improve how git root dir is determined.
9
+
10
+
11
+ ## 0.0.2
12
+
13
+ - Diff: Fix regex warnings shown in terminal.
14
+ - Diff: Simplify and fix change log section parsing.
15
+
16
+ ## 0.0.1
17
17
 
18
18
  - Initial release
19
19
  - Add GEMSTAR_DEBUG_GEM_REGEX to debug specific gems.
data/README.md CHANGED
@@ -7,32 +7,41 @@ A very preliminary gem to help you keep track of your gems.
7
7
 
8
8
  ## Installation
9
9
 
10
- Until it's released on RubyGems, you can install it from GitHub:
10
+ The easiest way to install gemstar is to use Bundler:
11
11
 
12
12
  ```shell
13
13
  # Shell
14
- gem install specific_install
15
- gem specific_install -l https://github.com/FDj/gemstar.git
14
+ gem install gemstar
16
15
  ```
17
16
 
18
- Or adding to your project:
17
+ Alternatively, add it to the development group in your Gemfile:
19
18
 
20
- ```ruby
21
- # Gemfile
22
- group :development do
23
- gem "gemstar", github: "FDj/gemstar"
24
- end
19
+ ```
20
+ gem "gemstar", group: :development
25
21
  ```
26
22
 
27
23
  ## Usage
28
24
 
29
- ### `gemstar diff`
25
+ ### gemstar server
26
+
27
+ ![Gemstar diff command output](docs/server.png)
28
+
29
+ Start the interactive web UI:
30
+
31
+ ```shell
32
+ gemstar server
33
+ ```
34
+
35
+ By default, the server listens to http://127.0.0.1:2112/
36
+
37
+
38
+ ### gemstar diff
30
39
 
31
40
  Run this after you've updated your gems.
32
41
 
33
42
  ```shell
34
- # in your project directory:
35
- bundle exec gemstar diff
43
+ # in your project directory, after bundle update:
44
+ gemstar diff
36
45
  ```
37
46
 
38
47
  This will generate an html diff report with changelog entries for each gem that was updated:
@@ -42,7 +51,13 @@ This will generate an html diff report with changelog entries for each gem that
42
51
  You can also specify from and to hashes or tags to generate a diff report for a specific range of commits:
43
52
 
44
53
  ```shell
45
- bundle exec gemstar diff --from 8e3aa96b7027834cdbabc0d8cbd5f9455165e930 --to HEAD
54
+ gemstar diff --from 8e3aa96b7027834cdbabc0d8cbd5f9455165e930 --to HEAD
55
+ ```
56
+
57
+ To examine a specific Gemfile.lock, pass it like this:
58
+
59
+ ```shell
60
+ gemstar diff --lockfile=~/MyProject/Gemfile.lock
46
61
  ```
47
62
 
48
63
  ## Contributing
data/lib/gemstar/cache.rb CHANGED
@@ -1,10 +1,11 @@
1
+ require_relative "config"
1
2
  require "fileutils"
2
3
  require "digest"
3
4
 
4
5
  module Gemstar
5
6
  class Cache
6
7
  MAX_CACHE_AGE = 60 * 60 * 24 * 7 # 1 week
7
- CACHE_DIR = ".gem_changelog_cache"
8
+ CACHE_DIR = File.join(Gemstar::Config.home_directory, "cache")
8
9
 
9
10
  @@initialized = false
10
11
 
@@ -18,15 +19,12 @@ module Gemstar
18
19
  def self.fetch(key, &block)
19
20
  init
20
21
 
21
- path = File.join(CACHE_DIR, Digest::SHA256.hexdigest(key))
22
+ path = path_for(key)
22
23
 
23
- if File.exist?(path)
24
- age = Time.now - File.mtime(path)
25
- if age <= MAX_CACHE_AGE
26
- content = File.read(path)
27
- return nil if content == "__404__"
28
- return content
29
- end
24
+ if fresh?(path)
25
+ content = File.read(path)
26
+ return nil if content == "__404__"
27
+ return content
30
28
  end
31
29
 
32
30
  begin
@@ -39,11 +37,50 @@ module Gemstar
39
37
  end
40
38
  end
41
39
 
40
+ def self.peek(key)
41
+ init
42
+
43
+ path = path_for(key)
44
+ return nil unless fresh?(path)
45
+
46
+ content = File.read(path)
47
+ return nil if content == "__404__"
48
+
49
+ content
50
+ end
51
+
52
+ def self.path_for(key)
53
+ File.join(CACHE_DIR, Digest::SHA256.hexdigest(key))
54
+ end
55
+
56
+ def self.fresh?(path)
57
+ return false unless File.exist?(path)
58
+
59
+ (Time.now - File.mtime(path)) <= MAX_CACHE_AGE
60
+ end
61
+
62
+ def self.flush!
63
+ init
64
+
65
+ flush_directory(CACHE_DIR)
66
+ end
67
+
68
+ def self.flush_directory(directory)
69
+ return 0 unless Dir.exist?(directory)
70
+
71
+ entries = Dir.children(directory)
72
+ entries.each do |entry|
73
+ FileUtils.rm_rf(File.join(directory, entry))
74
+ end
75
+
76
+ entries.count
77
+ end
78
+
42
79
  end
43
80
 
44
81
  def edit_gitignore
45
82
  gitignore_path = ".gitignore"
46
- ignore_entries = %w[.gem_changelog_cache/ gem_update_changelog.html]
83
+ ignore_entries = %w[gem_update_changelog.html]
47
84
 
48
85
  existing_lines = File.exist?(gitignore_path) ? File.read(gitignore_path).lines.map(&:chomp) : []
49
86
 
@@ -0,0 +1,12 @@
1
+ require "thor"
2
+
3
+ module Gemstar
4
+ class CacheCLI < Thor
5
+ package_name "gemstar cache"
6
+
7
+ desc "flush", "Clear all gemstar cache entries"
8
+ def flush
9
+ Gemstar::Commands::Cache.new({}).flush
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,120 @@
1
+ require "set"
2
+ require "thread"
3
+
4
+ module Gemstar
5
+ class CacheWarmer
6
+ DEFAULT_THREADS = 10
7
+
8
+ def initialize(io: $stderr, debug: false, thread_count: DEFAULT_THREADS)
9
+ @io = io
10
+ @debug = debug
11
+ @thread_count = thread_count
12
+ @mutex = Mutex.new
13
+ @condition = ConditionVariable.new
14
+ @queue = []
15
+ @queued = Set.new
16
+ @in_progress = Set.new
17
+ @completed = Set.new
18
+ @workers = []
19
+ @started = false
20
+ @total = 0
21
+ @completed_count = 0
22
+ end
23
+
24
+ def enqueue_many(gem_names)
25
+ names = gem_names.uniq
26
+
27
+ @mutex.synchronize do
28
+ names.each do |gem_name|
29
+ next if @completed.include?(gem_name) || @queued.include?(gem_name) || @in_progress.include?(gem_name)
30
+
31
+ @queue << gem_name
32
+ @queued << gem_name
33
+ end
34
+ @total += names.count
35
+ start_workers_unlocked unless @started
36
+ end
37
+
38
+ log "Background cache refresh queued for #{names.count} gems."
39
+ @condition.broadcast
40
+ self
41
+ end
42
+
43
+ def prioritize(gem_name)
44
+ @mutex.synchronize do
45
+ return if @completed.include?(gem_name) || @in_progress.include?(gem_name)
46
+
47
+ if @queued.include?(gem_name)
48
+ @queue.delete(gem_name)
49
+ else
50
+ @queued << gem_name
51
+ @total += 1
52
+ end
53
+
54
+ @queue.unshift(gem_name)
55
+ start_workers_unlocked unless @started
56
+ end
57
+
58
+ log "Prioritized #{gem_name}"
59
+ @condition.broadcast
60
+ end
61
+
62
+ private
63
+
64
+ def start_workers_unlocked
65
+ return if @started
66
+
67
+ @started = true
68
+ @thread_count.times do
69
+ @workers << Thread.new { worker_loop }
70
+ end
71
+ end
72
+
73
+ def worker_loop
74
+ Thread.current.name = "gemstar-cache-worker" if Thread.current.respond_to?(:name=)
75
+
76
+ loop do
77
+ gem_name = @mutex.synchronize do
78
+ while @queue.empty?
79
+ @condition.wait(@mutex)
80
+ end
81
+
82
+ next_gem = @queue.shift
83
+ @queued.delete(next_gem)
84
+ @in_progress << next_gem
85
+ next_gem
86
+ end
87
+
88
+ warm_cache_for_gem(gem_name)
89
+
90
+ current = @mutex.synchronize do
91
+ @in_progress.delete(gem_name)
92
+ @completed << gem_name
93
+ @completed_count += 1
94
+ end
95
+
96
+ log_progress(gem_name, current)
97
+ end
98
+ end
99
+
100
+ def warm_cache_for_gem(gem_name)
101
+ metadata = Gemstar::RubyGemsMetadata.new(gem_name)
102
+ metadata.meta
103
+ metadata.repo_uri
104
+ Gemstar::ChangeLog.new(metadata).sections
105
+ rescue StandardError => e
106
+ log "Cache refresh failed for #{gem_name}: #{e.class}: #{e.message}"
107
+ end
108
+
109
+ def log_progress(gem_name, current)
110
+ return unless @debug
111
+ return unless current <= 5 || (current % 25).zero?
112
+
113
+ log "Background cache refresh #{current}/#{@total}: #{gem_name}"
114
+ end
115
+
116
+ def log(message)
117
+ @io.puts(message)
118
+ end
119
+ end
120
+ end
@@ -10,21 +10,30 @@ module Gemstar
10
10
 
11
11
  attr_reader :metadata
12
12
 
13
- def content
14
- @content ||= fetch_changelog_content
13
+ def content(cache_only: false)
14
+ return @content if !cache_only && defined?(@content)
15
+
16
+ result = fetch_changelog_content(cache_only: cache_only)
17
+ @content = result unless cache_only
18
+ result
15
19
  end
16
20
 
17
- def sections
18
- @sections ||= begin
19
- s = parse_changelog_sections
21
+ def sections(cache_only: false)
22
+ return @sections if !cache_only && defined?(@sections)
23
+
24
+ result = begin
25
+ s = parse_changelog_sections(cache_only: cache_only)
20
26
  if s.nil? || s.empty?
21
- s = parse_github_release_sections
27
+ s = parse_github_release_sections(cache_only: cache_only)
22
28
  end
23
29
 
24
- pp @@candidates_found if Gemstar.debug?
30
+ pp @@candidates_found if Gemstar.debug? && !cache_only
25
31
 
26
32
  s
27
33
  end
34
+
35
+ @sections = result unless cache_only
36
+ result
28
37
  end
29
38
 
30
39
  def extract_relevant_sections(old_version, new_version)
@@ -52,23 +61,30 @@ module Gemstar
52
61
  def extract_version_from_heading(line)
53
62
  return nil unless line
54
63
  heading = line.to_s
64
+ version_token = /(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)/
55
65
  # 1) Prefer version inside parentheses after a date: "### 2025-11-07 (2.16.0)"
56
- return $1 if heading[/\(\s*v?(\d[\w.\-]+)\s*\)/]
66
+ # Ensure we ONLY treat it as a version if it actually looks like a version (has a dot),
67
+ # so we don't capture dates like (2025-11-21).
68
+ return $1 if heading[/\(\s*v?#{version_token}(?![A-Za-z0-9])\s*\)/]
57
69
  # 2) Version-first with optional leading markers/labels: "## v1.2.6 - 2025-10-21"
58
- return $1 if heading[/^\s*(?:#+|=+)?\s*(?:Version\s+)?\[?v?(\d[\w.\-]+)\]?/i]
70
+ # Require a dot in the numeric token to avoid capturing dates like 2025-11-21.
71
+ return $1 if heading[/^\s*(?:[-*]\s+)?(?:#+|=+)?\s*(?:Version\s+)?\[*v?#{version_token}(?![A-Za-z0-9])\]*/i]
59
72
  # 3) Anywhere: first semver-like token with a dot
60
- return $1 if heading[/\bv?(\d+\.\d+(?:\.\d+)?(?:[A-Za-z0-9.\-]+)?)\b/]
73
+ return $1 if heading[/\bv?#{version_token}(?![A-Za-z0-9])\b/]
61
74
  nil
62
75
  end
63
76
 
64
- def changelog_uri_candidates
77
+ def changelog_uri_candidates(cache_only: false)
65
78
  candidates = []
66
79
 
67
- if @metadata.repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
80
+ repo_uri = @metadata.repo_uri(cache_only: cache_only)
81
+ return [] if repo_uri.nil? || repo_uri.empty?
82
+
83
+ if repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
68
84
  base = "https://raw.githubusercontent.com/aws/aws-sdk-ruby/refs/heads/version-3/gems/#{@metadata.gem_name}"
69
85
  aws_style = true
70
86
  else
71
- base = @metadata.repo_uri.sub("https://github.com", "https://raw.githubusercontent.com")
87
+ base = repo_uri.sub("https://github.com", "https://raw.githubusercontent.com")
72
88
  aws_style = false
73
89
  end
74
90
 
@@ -91,7 +107,8 @@ module Gemstar
91
107
  end
92
108
 
93
109
  # Add the gem's changelog_uri last as it's usually not the most parsable:
94
- candidates += [Gemstar::GitHub::github_blob_to_raw(@metadata.meta["changelog_uri"])]
110
+ meta = @metadata.meta(cache_only: cache_only)
111
+ candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
95
112
 
96
113
  candidates.flatten!
97
114
  candidates.uniq!
@@ -100,15 +117,19 @@ module Gemstar
100
117
  candidates
101
118
  end
102
119
 
103
- def fetch_changelog_content
120
+ def fetch_changelog_content(cache_only: false)
104
121
  content = nil
105
122
 
106
- changelog_uri_candidates.find do |candidate|
107
- content = Cache.fetch("changelog-#{candidate}") do
108
- URI.open(candidate, read_timeout: 8)&.read
109
- rescue => e
110
- puts "#{candidate}: #{e}" if Gemstar.debug?
111
- nil
123
+ changelog_uri_candidates(cache_only: cache_only).find do |candidate|
124
+ content = if cache_only
125
+ Cache.peek("changelog-#{candidate}")
126
+ else
127
+ Cache.fetch("changelog-#{candidate}") do
128
+ URI.open(candidate, read_timeout: 8)&.read
129
+ rescue => e
130
+ puts "#{candidate}: #{e}" if Gemstar.debug?
131
+ nil
132
+ end
112
133
  end
113
134
 
114
135
  # puts "fetch_changelog_content #{candidate}:\n#{content}" if Gemstar.debug?
@@ -123,12 +144,19 @@ module Gemstar
123
144
  content
124
145
  end
125
146
 
147
+ VERSION_PATTERNS = [
148
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\s*\)/, # prefer this
149
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/,
150
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
151
+ /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
152
+ /^\s*(?:[-*]\s+)?(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])(?:\s*[-(].*)?/i
153
+ ]
126
154
 
127
- def parse_changelog_sections
155
+ def parse_changelog_sections(cache_only: false)
128
156
  # If the fetched content looks like a GitHub Releases HTML page, return {}
129
157
  # so that the GitHub releases scraper can handle it. This avoids
130
158
  # accidentally parsing HTML from /releases pages as a markdown changelog.
131
- c = content
159
+ c = content(cache_only: cache_only)
132
160
  return {} if c.nil? || c.strip.empty?
133
161
  if (c.include?("<html") || c.include?("<!DOCTYPE html")) &&
134
162
  (c.include?('data-test-selector="body-content"') || c.include?("/releases/tag/"))
@@ -136,108 +164,44 @@ module Gemstar
136
164
  return {}
137
165
  end
138
166
 
167
+ lines = c.lines
168
+
169
+ if lines.count < 4
170
+ # Skip changelog files that are too short to be useful
171
+ # This is sometimes the case with changelogs just saying "please see GitHub releases"
172
+ puts "parse_changelog_sections #{@metadata.gem_name}: Changelog too short; skipping" if Gemstar.debug?
173
+ return {}
174
+ end
175
+
139
176
  sections = {}
140
177
  current_key = nil
141
178
  current_lines = []
142
179
 
143
- flush_current = lambda do
144
- return unless current_key && !current_lines.empty?
145
- key = current_key
146
- # If key looks like a date or non-version, try to extract a proper version
147
- if key =~ /\A\d{4}-\d{2}-\d{2}\z/ || key !~ /\A\d[\w.\-]*\z/
148
- v = extract_version_from_heading(current_lines.first)
149
- key = v if v
150
- end
151
- if sections.key?(key)
152
- # Collision: merge by appending with a separator to avoid losing data
153
- sections[key] += ["\n"] + current_lines
154
- else
155
- sections[key] = current_lines.dup
156
- end
157
- end
158
-
159
- c.each_line do |line|
180
+ lines.each do |line|
160
181
  # Convert rdoc to markdown:
161
182
  line = line.gsub(/^=+/) { |m| "#" * m.length }
162
183
 
163
- new_key = nil
164
- # Keep-a-Changelog style: version first with trailing date, e.g. "## v1.2.6 - 2025-10-21"
165
- if line =~ /^\s*(?:#+|=+)\s*\[?v?(\d[\w.\-]+)\]?\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/
166
- new_key = extract_version_from_heading(line) || $1
167
- elsif line =~ /^\s*(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[?v?(\d[\w.\-]+)\]?(?:\s*[-(].*)?/i
168
- new_key = extract_version_from_heading(line) || $1
169
- elsif line =~ /^\s*(?:#+|=+)\s*(?:Version\s+)?\[?v?(\d[\w.\-]+)\]?(?:\s*[-(].*)?/i
170
- # header without label words before the version
171
- new_key = extract_version_from_heading(line) || $1
172
- elsif line =~ /^\s*(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d[\w.\-]+)\s*\)/
173
- # headings like "### 2025-11-07 (2.16.0)" — prefer the version in parentheses over the leading date
174
- new_key = extract_version_from_heading(line) || $1
175
- elsif line =~ /^\s*(?:Version\s+)?v?(\d[\w.\-]+)(?:\s*[-(].*)?/i
176
- # fallback for lines like "1.4.0 (2025-06-02)"
184
+ m = VERSION_PATTERNS.lazy.map { |re| line.match(re) }.find(&:itself)
185
+
186
+ if m
177
187
  new_key = extract_version_from_heading(line) || $1
178
- end
179
188
 
180
- if new_key
181
- # Flush previous section before starting a new one
182
- flush_current.call
189
+ if current_key
190
+ sections[current_key] ||= []
191
+ sections[current_key] << current_lines
192
+ current_lines = []
193
+ end
194
+
183
195
  current_key = new_key
184
- current_lines = [line]
185
- elsif current_key
186
- current_lines << line
187
196
  end
188
- end
189
197
 
190
- # Flush the last captured section
191
- flush_current.call
198
+ current_lines << line if current_key
199
+ end
192
200
 
193
- # Normalize keys: ensure all keys are versions; fix any leftover date-like keys conservatively
194
- begin
195
- normalized = {}
196
- sections.each do |k, lines|
197
- if k =~ /\A\d{4}-\d{2}-\d{2}\z/ || k !~ /\A\d[\w.\-]*\z/
198
- heading = lines.first.to_s
199
- # 1) Prefer version inside parentheses, e.g., "### 2025-11-07 (2.16.0)"
200
- if heading[/\(\s*v?(\d[\w.\-]+)\s*\)/]
201
- key = $1
202
- normalized[key] = if normalized.key?(key)
203
- normalized[key] + ["\n"] + lines
204
- else
205
- lines
206
- end
207
- next
208
- end
209
- # 2) Headings like "## v1.2.5 - 2025-10-21" or "## 1.2.5 — 2025-10-21"
210
- if heading[/^\s*(?:#+|=+)\s*(?:Version\s+)?\[?v?(\d+\.\d+(?:\.\d+)?(?:[A-Za-z0-9.\-]+)?)\]?/]
211
- key = $1
212
- normalized[key] = if normalized.key?(key)
213
- normalized[key] + ["\n"] + lines
214
- else
215
- lines
216
- end
217
- next
218
- end
219
- # 3) Anywhere in the heading, pick the first semver-like token with a dot
220
- if heading[/\bv?(\d+\.\d+(?:\.\d+)?(?:[A-Za-z0-9.\-]+)?)\b/]
221
- key = $1
222
- normalized[key] = if normalized.key?(key)
223
- normalized[key] + ["\n"] + lines
224
- else
225
- lines
226
- end
227
- next
228
- end
229
- end
230
- # Default: carry over, merging on collision to avoid loss
231
- if normalized.key?(k)
232
- normalized[k] += ["\n"] + lines
233
- else
234
- normalized[k] = lines
235
- end
236
- end
237
- sections = normalized unless normalized.empty?
238
- rescue => e
239
- # Be conservative; if normalization fails for any reason, keep original sections
240
- puts "Normalization error in parse_changelog_sections: #{e}" if Gemstar.debug?
201
+ if current_key
202
+ # Flush last section
203
+ sections[current_key] ||= []
204
+ sections[current_key] << current_lines
241
205
  end
242
206
 
243
207
  if Gemstar.debug?
@@ -248,34 +212,47 @@ module Gemstar
248
212
  sections
249
213
  end
250
214
 
251
- def parse_github_release_sections
215
+ def parse_github_release_sections(cache_only: false)
252
216
  begin
253
217
  require "nokogiri"
254
218
  rescue LoadError
255
219
  return {}
256
220
  end
257
221
 
258
- return {} unless @metadata&.repo_uri&.include?("github.com")
222
+ repo_uri = @metadata&.repo_uri(cache_only: cache_only)
223
+ return {} unless repo_uri&.include?("github.com")
259
224
 
260
- url = github_releases_url
225
+ url = github_releases_url(repo_uri)
261
226
  return {} unless url
262
227
 
263
- html = Cache.fetch("releases-#{url}") do
264
- begin
265
- URI.open(url, read_timeout: 8)&.read
266
- rescue => e
267
- puts "#{url}: #{e}" if Gemstar.debug?
268
- nil
228
+ html = if cache_only
229
+ Cache.peek("releases-#{url}")
230
+ else
231
+ Cache.fetch("releases-#{url}") do
232
+ begin
233
+ URI.open(url, read_timeout: 8)&.read
234
+ rescue => e
235
+ puts "#{url}: #{e}" if Gemstar.debug?
236
+ nil
237
+ end
238
+ end
239
+ end
240
+
241
+ if (html.nil? || html.strip.empty?) && cache_only
242
+ cached_content = content(cache_only: true)
243
+ if cached_content&.include?("<html") &&
244
+ (cached_content.include?('data-test-selector="body-content"') || cached_content.include?("/releases/tag/"))
245
+ html = cached_content
269
246
  end
270
247
  end
271
248
 
272
249
  return {} if html.nil? || html.strip.empty?
273
250
 
274
251
  doc = begin
275
- Nokogiri::HTML5(html)
276
- rescue => _
277
- Nokogiri::HTML(html)
278
- end
252
+ Nokogiri::HTML5(html)
253
+ rescue => _
254
+ Nokogiri::HTML(html)
255
+ end
279
256
 
280
257
  sections = {}
281
258
 
@@ -325,9 +302,9 @@ module Gemstar
325
302
  sections
326
303
  end
327
304
 
328
- def github_releases_url
329
- return nil unless @metadata&.repo_uri
330
- repo = @metadata.repo_uri.chomp("/")
305
+ def github_releases_url(repo_uri = @metadata&.repo_uri)
306
+ return nil unless repo_uri
307
+ repo = repo_uri.chomp("/")
331
308
  return nil if repo.empty?
332
309
  "#{repo}/releases"
333
310
  end