gemstar 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 80dede87ff46bc8aefd962ccd46dffcb199357eca04173b9610f85e74c74111a
4
+ data.tar.gz: dfd221d5ec0cdd3c3511fdd1e7980fb8bc00050afab9d69cdb81c43d1673fee6
5
+ SHA512:
6
+ metadata.gz: 4d160d053c3e60ef38d7b129876159a2d696015857d917f1dceb82afcd3e648d8e5736232535a9d79ef84e11681591019245317f5f15c3d7b24d06292e6c15f7
7
+ data.tar.gz: bcadfa71fac1e0be9c6777217cba5797e729ead523383a7f049984e47f9b9c24952cb9ae2d7a4eb7f03615cc8f4aeaafa13961cbedcfde9dd94684d601bcb0f2
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Change Log
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 ```
15
+
16
+ ## 0.1
17
+
18
+ - Initial release
19
+ - Add GEMSTAR_DEBUG_GEM_REGEX to debug specific gems.
20
+ - Refactor to work correctly with more gems.
21
+ - Diff: More flexible changelog parsing.
22
+ - Diff: Fetch raw GitHub changelogs, not html.
23
+ - Diff: Support GitHub releases.
24
+ - Diff: Improved Markup rendering (with code samples)
25
+ - Diff: Try release notes in order of match frequency.
data/LICENSE.txt ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2025 Florian Dejako
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
+
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ [![Gem Version](https://badge.fury.io/rb/gemstar.svg)](https://rubygems.org/gems/gemstar)
2
+ [![Build](https://github.com/FDj/gemstar/workflows/Build/badge.svg)](https://github.com/palkan/gemstar/actions)
3
+ [![JRuby Build](https://github.com/FDj/gemstar/workflows/JRuby%20Build/badge.svg)](https://github.com/FDj/gemstar/actions)
4
+
5
+ # Gemstar
6
+ A very preliminary gem to help you keep track of your gems.
7
+
8
+ ## Installation
9
+
10
+ Until it's released on RubyGems, you can install it from GitHub:
11
+
12
+ ```shell
13
+ # Shell
14
+ gem install specific_install
15
+ gem specific_install -l https://github.com/FDj/gemstar.git
16
+ ```
17
+
18
+ Or adding to your project:
19
+
20
+ ```ruby
21
+ # Gemfile
22
+ group :development do
23
+ gem "gemstar", github: "FDj/gemstar"
24
+ end
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### `gemstar diff`
30
+
31
+ Run this after you've updated your gems.
32
+
33
+ ```shell
34
+ # in your project directory:
35
+ bundle exec gemstar diff
36
+ ```
37
+
38
+ This will generate an html diff report with changelog entries for each gem that was updated:
39
+
40
+ ![Gemstar diff command output](docs/diff.png)
41
+
42
+ You can also specify from and to hashes or tags to generate a diff report for a specific range of commits:
43
+
44
+ ```shell
45
+ bundle exec gemstar diff --from 8e3aa96b7027834cdbabc0d8cbd5f9455165e930 --to HEAD
46
+ ```
47
+
48
+ ## Contributing
49
+
50
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/FDj/gemstar](https://github.com/FDj/gemstar).
51
+
52
+ ## Credits
53
+
54
+ This gem is generated via [`newgem` template](https://github.com/palkan/newgem) by [@palkan](https://github.com/palkan).
55
+
56
+ ## License
57
+
58
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/bin/gemstar ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- mode: ruby -*-
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require_relative "../lib/gemstar"
6
+
7
+ Gemstar::CLI.start(ARGV)
@@ -0,0 +1,60 @@
1
+ require "fileutils"
2
+ require "digest"
3
+
4
+ module Gemstar
5
+ class Cache
6
+ MAX_CACHE_AGE = 60 * 60 * 24 * 7 # 1 week
7
+ CACHE_DIR = ".gem_changelog_cache"
8
+
9
+ @@initialized = false
10
+
11
+ def self.init
12
+ return if @@initialized
13
+
14
+ FileUtils.mkdir_p(CACHE_DIR)
15
+ @@initialized = true
16
+ end
17
+
18
+ def self.fetch(key, &block)
19
+ init
20
+
21
+ path = File.join(CACHE_DIR, Digest::SHA256.hexdigest(key))
22
+
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
30
+ end
31
+
32
+ begin
33
+ data = block.call
34
+ File.write(path, data || "__404__")
35
+ data
36
+ rescue
37
+ File.write(path, "__404__")
38
+ nil
39
+ end
40
+ end
41
+
42
+ end
43
+
44
+ def edit_gitignore
45
+ gitignore_path = ".gitignore"
46
+ ignore_entries = %w[.gem_changelog_cache/ gem_update_changelog.html]
47
+
48
+ existing_lines = File.exist?(gitignore_path) ? File.read(gitignore_path).lines.map(&:chomp) : []
49
+
50
+ new_lines = ignore_entries.reject { |entry| existing_lines.include?(entry) }
51
+
52
+ unless new_lines.empty?
53
+ File.open(gitignore_path, "a") do |f|
54
+ f.puts "\n# Cache/output from gem changelog tool" if (existing_lines & ignore_entries).empty?
55
+ new_lines.each { |entry| f.puts entry }
56
+ end
57
+ end
58
+ end
59
+
60
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemstar
4
+ class ChangeLog
5
+ @@candidates_found = Hash.new(0)
6
+
7
+ def initialize(metadata)
8
+ @metadata = metadata
9
+ end
10
+
11
+ attr_reader :metadata
12
+
13
+ def content
14
+ @content ||= fetch_changelog_content
15
+ end
16
+
17
+ def sections
18
+ @sections ||= begin
19
+ s = parse_changelog_sections
20
+ if s.nil? || s.empty?
21
+ s = parse_github_release_sections
22
+ end
23
+
24
+ pp @@candidates_found if Gemstar.debug?
25
+
26
+ s
27
+ end
28
+ end
29
+
30
+ def extract_relevant_sections(old_version, new_version)
31
+ from = Gem::Version.new(old_version.gsub(/-[\w\-]+$/, "")) rescue nil if old_version
32
+ from ||= Gem::Version.new("0.0.0")
33
+ to = Gem::Version.new(new_version.gsub(/-[\w\-]+$/, "")) rescue nil if new_version
34
+ to ||= Gem::Version.new("9999.9999.9999")
35
+
36
+ sections.select do |version, _|
37
+ v = Gem::Version.new(version.gsub(/-[\w\-]+$/, ""))
38
+ v > from && v <= to
39
+ rescue => e
40
+ false
41
+ end.sort_by do |k, _|
42
+ Gem::Version.new(k.gsub(/-[\w\-]+$/, ""))
43
+ rescue => e
44
+ Gem::Version.new("0.0.0")
45
+ end.reverse.to_h
46
+ end
47
+
48
+ private
49
+
50
+ # Extract a version token from a heading line, preferring explicit version forms
51
+ # and avoiding returning a date string when both are present.
52
+ def extract_version_from_heading(line)
53
+ return nil unless line
54
+ heading = line.to_s
55
+ # 1) Prefer version inside parentheses after a date: "### 2025-11-07 (2.16.0)"
56
+ return $1 if heading[/\(\s*v?(\d[\w.\-]+)\s*\)/]
57
+ # 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]
59
+ # 3) Anywhere: first semver-like token with a dot
60
+ return $1 if heading[/\bv?(\d+\.\d+(?:\.\d+)?(?:[A-Za-z0-9.\-]+)?)\b/]
61
+ nil
62
+ end
63
+
64
+ def changelog_uri_candidates
65
+ candidates = []
66
+
67
+ if @metadata.repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
68
+ base = "https://raw.githubusercontent.com/aws/aws-sdk-ruby/refs/heads/version-3/gems/#{@metadata.gem_name}"
69
+ aws_style = true
70
+ else
71
+ base = @metadata.repo_uri.sub("https://github.com", "https://raw.githubusercontent.com")
72
+ aws_style = false
73
+ end
74
+
75
+ base = base.chomp("/")
76
+
77
+ paths = aws_style ? ["CHANGELOG.md"] : %w[
78
+ CHANGELOG.md releases.md CHANGES.md
79
+ Changelog.md changelog.md ChangeLog.md
80
+ Changes.md changes.md
81
+ HISTORY.md History.md history.md
82
+ History CHANGELOG.rdoc
83
+ ]
84
+
85
+ remote_repository = RemoteRepository.new(base)
86
+
87
+ branches = aws_style ? [""] : remote_repository.find_main_branch
88
+
89
+ candidates += paths.product(branches).map do |file, branch|
90
+ uri = aws_style ? "#{base}/#{file}" : "#{base}/#{branch}/#{file}"
91
+ end
92
+
93
+ # 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"])]
95
+
96
+ candidates.flatten!
97
+ candidates.uniq!
98
+ candidates.compact!
99
+
100
+ candidates
101
+ end
102
+
103
+ def fetch_changelog_content
104
+ content = nil
105
+
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
112
+ end
113
+
114
+ # puts "fetch_changelog_content #{candidate}:\n#{content}" if Gemstar.debug?
115
+
116
+ if content
117
+ @@candidates_found[candidate.split("/").last] += 1
118
+ end
119
+
120
+ !content.nil?
121
+ end
122
+
123
+ content
124
+ end
125
+
126
+
127
+ def parse_changelog_sections
128
+ # If the fetched content looks like a GitHub Releases HTML page, return {}
129
+ # so that the GitHub releases scraper can handle it. This avoids
130
+ # accidentally parsing HTML from /releases pages as a markdown changelog.
131
+ c = content
132
+ return {} if c.nil? || c.strip.empty?
133
+ if (c.include?("<html") || c.include?("<!DOCTYPE html")) &&
134
+ (c.include?('data-test-selector="body-content"') || c.include?("/releases/tag/"))
135
+ puts "parse_changelog_sections #{@metadata.gem_name}: Detected GitHub Releases HTML; skipping to fallback" if Gemstar.debug?
136
+ return {}
137
+ end
138
+
139
+ sections = {}
140
+ current_key = nil
141
+ current_lines = []
142
+
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|
160
+ # Convert rdoc to markdown:
161
+ line = line.gsub(/^=+/) { |m| "#" * m.length }
162
+
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)"
177
+ new_key = extract_version_from_heading(line) || $1
178
+ end
179
+
180
+ if new_key
181
+ # Flush previous section before starting a new one
182
+ flush_current.call
183
+ current_key = new_key
184
+ current_lines = [line]
185
+ elsif current_key
186
+ current_lines << line
187
+ end
188
+ end
189
+
190
+ # Flush the last captured section
191
+ flush_current.call
192
+
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?
241
+ end
242
+
243
+ if Gemstar.debug?
244
+ puts "parse_changelog_sections #{@metadata.gem_name}:"
245
+ pp sections
246
+ end
247
+
248
+ sections
249
+ end
250
+
251
+ def parse_github_release_sections
252
+ begin
253
+ require "nokogiri"
254
+ rescue LoadError
255
+ return {}
256
+ end
257
+
258
+ return {} unless @metadata&.repo_uri&.include?("github.com")
259
+
260
+ url = github_releases_url
261
+ return {} unless url
262
+
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
269
+ end
270
+ end
271
+
272
+ return {} if html.nil? || html.strip.empty?
273
+
274
+ doc = begin
275
+ Nokogiri::HTML5(html)
276
+ rescue => _
277
+ Nokogiri::HTML(html)
278
+ end
279
+
280
+ sections = {}
281
+
282
+ # Preferred: iterate release sections that have an accessible h2 with the version (sr-only)
283
+ doc.css('section[aria-labelledby]').each do |sec|
284
+ heading = sec.at_css('h2.sr-only')
285
+ next unless heading
286
+ text = heading.text.to_s.strip
287
+ next unless text[/v?(\d[\w.\-]+)/i]
288
+ version = $1
289
+
290
+ body = sec.at_css('[data-test-selector="body-content"] .markdown-body') ||
291
+ sec.at_css('[data-test-selector="body-content"]') ||
292
+ sec.at_css('.markdown-body')
293
+ next unless body
294
+
295
+ html_chunk = body.inner_html.to_s.strip
296
+ next if html_chunk.empty?
297
+
298
+ lines = ["## #{version}\n", html_chunk]
299
+ sections[version] = lines
300
+ end
301
+
302
+ # Fallback: look for any body-content blocks across the page and try to infer nearby tag links
303
+ if sections.empty?
304
+ doc.css('[data-test-selector="body-content"]').each do |container|
305
+ body = container.at_css('.markdown-body') || container
306
+ # find a tag link near this container
307
+ link = container.at_xpath('ancestor::*[self::section or self::div][.//a[contains(@href, "/releases/tag/")]][1]//a[contains(@href, "/releases/tag/")]')
308
+ text = link&.text.to_s
309
+ text = File.basename(URI(link["href"]).path) if (text.nil? || text.empty?) && link
310
+ next unless text && text[/v?(\d[\w.\-]+)/i]
311
+ version = $1
312
+
313
+ html_chunk = body.inner_html.to_s.strip
314
+ next if html_chunk.empty?
315
+ lines = ["## #{version}\n", html_chunk]
316
+ sections[version] = lines
317
+ end
318
+ end
319
+
320
+ if Gemstar.debug?
321
+ puts "parse_github_release_sections #{@metadata.gem_name}:"
322
+ pp sections.keys
323
+ end
324
+
325
+ sections
326
+ end
327
+
328
+ def github_releases_url
329
+ return nil unless @metadata&.repo_uri
330
+ repo = @metadata.repo_uri.chomp("/")
331
+ return nil if repo.empty?
332
+ "#{repo}/releases"
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,50 @@
1
+ # lib/gemstar/cli.rb
2
+ require "thor"
3
+
4
+ module Gemstar
5
+ class CLI < Thor
6
+ package_name "gemstar"
7
+
8
+ map "-D" => "diff"
9
+
10
+ class_option :verbose, type: :boolean, default: false, desc: "Enable verbose output"
11
+ class_option :lockfile, type: :string, default: "Gemfile.lock", desc: "Lockfile path"
12
+
13
+ desc "diff", "Show changelogs for updated gems"
14
+ method_option :from, type: :string, desc: "Git ref or lockfile"
15
+ method_option :to, type: :string, desc: "Git ref or lockfile"
16
+ method_option :output_file, type: :string, desc: "Output file path"
17
+ method_option :debug_gem_regex, type: :string, desc: "Debug matching gems", hide: true
18
+ def diff
19
+ Gemstar::Commands::Diff.new(options).run
20
+ end
21
+
22
+ # desc "pick", "Interactively cherry-pick and upgrade gems"
23
+ # option :gem, type: :string, desc: "Gem name to cherry-pick"
24
+ # def pick
25
+ # Gemstar::Commands::Pick.new(options).run
26
+ # end
27
+ #
28
+ # desc "audit", "Run security and vulnerability checks"
29
+ # def audit
30
+ # Gemstar::Commands::Audit.new.run
31
+ # end
32
+ #
33
+ # desc "diff", "Show lockfile diff or GitHub comparison"
34
+ # option :from, type: :string
35
+ # option :to, type: :string
36
+ # def diff
37
+ # Gemstar::Commands::Diff.new(options).run
38
+ # end
39
+
40
+ # desc "init", "Setup gem hygiene for a project"
41
+ # def init
42
+ # Gemstar::Commands::Init.new.run
43
+ # end
44
+
45
+ def self.exit_on_failure?
46
+ true
47
+ end
48
+ end
49
+
50
+ end
@@ -0,0 +1,13 @@
1
+ module Gemstar
2
+ module Commands
3
+ class Command
4
+ def initialize(options)
5
+ @options = options
6
+ end
7
+
8
+ def run
9
+ pp @options
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+ require "concurrent-ruby"
5
+ require "tempfile"
6
+
7
+ module Gemstar
8
+ module Commands
9
+ class Diff < Command
10
+ attr_reader :updates
11
+ attr_reader :failed
12
+ attr_reader :from
13
+ attr_reader :to
14
+ attr_reader :lockfile
15
+ attr_reader :git_repo
16
+ attr_reader :lockfile_full_path
17
+ attr_reader :output_file
18
+
19
+ def initialize(options)
20
+ super
21
+
22
+ @debug_gem_regex = Regexp.new(options[:debug_gem_regex] || ENV["GEMSTAR_DEBUG_GEM_REGEX"] || ".*")
23
+
24
+ @from = options[:from] || "HEAD"
25
+ @to = options[:to]
26
+ @lockfile = options[:lockfile] || "Gemfile.lock"
27
+ @output_file = options[:output_file] || "gem_update_changelog.html"
28
+
29
+ @git_repo = Gemstar::GitRepo.new(File.dirname(@lockfile))
30
+ end
31
+
32
+ def run
33
+ # logic to diff from/to, find updated gems, fetch changelogs
34
+
35
+ #+++ edit_gitignore?
36
+
37
+ @lockfile_full_path = git_repo.get_full_path(File.basename(lockfile))
38
+ puts "Lockfile path: #{lockfile_full_path}"
39
+
40
+ old = LockFile.new(content: git_repo.show_blob_at(@from, lockfile_full_path))
41
+ new = @to ?
42
+ LockFile.new(content: git_repo.show_blob_at(@to, lockfile_full_path)) :
43
+ LockFile.new(path: lockfile)
44
+
45
+ collect_updates(new_lockfile: new, old_lockfile: old)
46
+
47
+ html = Outputs::HTML.new.render_diff(self)
48
+ File.write(output_file, html)
49
+ puts "✅ gem_update_changelog.html created."
50
+
51
+ if failed.any?
52
+ puts "\n⚠️ The following gems failed to process:"
53
+ failed.each { |gem, msg| puts " - #{gem}: #{msg}" }
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def build_entry(gem_name:, old_version:, new_version:)
60
+ metadata = Gemstar::RubyGemsMetadata.new(gem_name)
61
+ repo_url = metadata.repo_uri
62
+ changelog = Gemstar::ChangeLog.new(metadata)
63
+ sections = changelog.extract_relevant_sections(old_version, new_version)
64
+
65
+ compare_url = if repo_url && old_version
66
+ tag_from_v = "v#{old_version}"
67
+ tag_to_v = "v#{new_version}"
68
+ tag_from_raw = old_version
69
+ tag_to_raw = new_version
70
+
71
+ url_v = "#{repo_url}/compare/#{tag_from_v}...#{tag_to_v}"
72
+ url_raw = "#{repo_url}/compare/#{tag_from_raw}...#{tag_to_raw}"
73
+
74
+ begin
75
+ URI.open(url_v, read_timeout: 4) # TODO use a real HTTP client
76
+ url_v
77
+ rescue
78
+ url_raw
79
+ end
80
+ end
81
+
82
+ homepage_url = metadata.meta["homepage_uri"] || metadata.meta["source_code_uri"] || "https://rubygems.org/gems/#{gem_name}"
83
+ description = metadata.meta["info"]
84
+
85
+ entry = {
86
+ old: old_version,
87
+ new: new_version,
88
+ homepage_url: homepage_url,
89
+ description: description
90
+ }
91
+ entry[:sections] = sections unless sections.nil? || sections.empty?
92
+ entry[:compare_url] = compare_url if compare_url
93
+
94
+ if entry[:sections].nil? && repo_url && new_version
95
+ entry[:release_url] = "#{repo_url}/releases/tag/#{new_version}"
96
+ end
97
+ entry[:release_page] = "#{repo_url}/releases" if repo_url && (!sections || sections.empty?)
98
+
99
+ if repo_url && new_version
100
+ version_list = sections ? sections.keys : []
101
+ if version_list.empty?
102
+ version_list = [new_version]
103
+ end
104
+
105
+ entry[:release_urls] = version_list.map do |ver|
106
+ "#{repo_url}/releases/tag/#{ver}"
107
+ end
108
+ end
109
+
110
+ entry
111
+ end
112
+
113
+ def collect_updates(new_lockfile:, old_lockfile:)
114
+ @updates = {}
115
+ @failed = []
116
+ mutex = Mutex.new
117
+ pool = Concurrent::FixedThreadPool.new(10)
118
+
119
+ new_lockfile.specs.keys.sort.each do |gem_name|
120
+ pool.post do
121
+ next unless @debug_gem_regex.match?(gem_name)
122
+
123
+ old_version = old_lockfile.specs[gem_name]
124
+ new_version = new_lockfile.specs[gem_name]
125
+ next if old_version == new_version
126
+
127
+ puts "#{gem_name} (#{old_version || "new"} → #{new_version})..."
128
+
129
+ begin
130
+ entry = build_entry(gem_name: gem_name, old_version: old_version, new_version: new_version)
131
+
132
+ mutex.synchronize { updates[gem_name] = entry }
133
+ rescue => e
134
+ mutex.synchronize { failed << [gem_name, e.message] }
135
+ puts "⚠️ Failed to process #{gem_name}: #{e.message}"
136
+ end
137
+ end
138
+ end
139
+
140
+ pool.shutdown
141
+ pool.wait_for_termination
142
+
143
+ @updates = updates
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemstar
4
+ class GitHub
5
+ def self.github_blob_to_raw(url, ref_is_tag: false)
6
+ return nil unless url
7
+
8
+ uri = URI(url)
9
+ return url unless uri.host == "github.com"
10
+
11
+ owner, repo, blob, *rest = uri.path.split("/")[1..]
12
+ return url unless blob == "blob"
13
+
14
+ ref = rest.shift
15
+ path = rest.join("/")
16
+
17
+ ref_prefix = ref_is_tag ? "refs/tags/" : ""
18
+
19
+ uri.scheme = "https"
20
+ uri.host = "raw.githubusercontent.com"
21
+ uri.path = "/#{owner}/#{repo}/#{ref_prefix}#{ref}/#{path}"
22
+ uri.to_s
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ module Gemstar
2
+ class GitRepo
3
+ def initialize(specified_directory)
4
+ @specified_directory = specified_directory || Dir.pwd
5
+ @tree_root_directory = find_git_root(File.dirname(@specified_directory))
6
+ end
7
+
8
+ def find_git_root(directory)
9
+ # return directory if File.directory?(File.join(directory, ".git"))
10
+ # find_git_root(File.dirname(directory))
11
+
12
+ run_git_command(%W[rev-parse --show-toplevel])
13
+ end
14
+
15
+ def git_client
16
+ "git"
17
+ end
18
+
19
+ def run_git_command(command, in_directory: @specified_directory, strip: true)
20
+ git_command = [git_client]
21
+ git_command += ["-C", in_directory] if in_directory
22
+ git_command += command
23
+
24
+ puts %[run_git_command (joined): #{git_command.join(" ")}] if Gemstar.debug?
25
+
26
+ output = IO.popen(git_command, err: [:child, :out],
27
+ &:read)
28
+ strip ? output.strip : output
29
+ end
30
+
31
+ def resolve_commit(revish, default_branch: "HEAD")
32
+ # If it looks like a pure date (or you want to support "date only"),
33
+ # map it to "latest commit before date on default_branch".
34
+ if revish =~ /\d{4}-\d{2}-\d{2}/ || revish =~ /\d{1,2}:\d{2}/i
35
+ sha = run_git_command(["rev-list", "-1", "--before", revish, default_branch])
36
+ raise "No commit before #{revish} on #{default_branch}" if sha.empty?
37
+ return sha
38
+ end
39
+
40
+ # Otherwise let Git parse whatever the user typed.
41
+ sha = run_git_command(%W[rev-parse --verify #{revish}^{commit}])
42
+ raise "Unknown revision: #{revish}" if sha.empty?
43
+ sha
44
+ end
45
+
46
+ def show_blob_at(revish, path)
47
+ commit = resolve_commit(revish)
48
+ run_git_command(["show", "#{commit}:#{path}"])
49
+ end
50
+
51
+ def get_full_path(path)
52
+ run_git_command(["ls-files", "--full-name", "--", path])
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,30 @@
1
+ module Gemstar
2
+ class LockFile
3
+ def initialize(path: nil, content: nil)
4
+ @path = path
5
+ @specs = content ? parse_content(content) : parse_lockfile(path)
6
+ end
7
+
8
+ attr_reader :specs
9
+
10
+ private
11
+
12
+ def parse_lockfile(path)
13
+ parse_content(File.read(path))
14
+ end
15
+
16
+ def parse_content(content)
17
+ specs = {}
18
+ in_specs = false
19
+ content.each_line do |line|
20
+ in_specs = true if line.strip == "GEM"
21
+ next unless in_specs
22
+ if line =~ /^\s{4}(\S+) \((.+)\)/
23
+ name, version = $1, $2
24
+ specs[name] = version
25
+ end
26
+ end
27
+ specs
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,7 @@
1
+ module Gemstar
2
+ module Outputs
3
+ class Basic
4
+ def render_diff(updates) end
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "basic"
4
+ require "pathname"
5
+ require "kramdown"
6
+ begin
7
+ require "kramdown-parser-gfm"
8
+ rescue LoadError
9
+ # Optional dependency: if not available, we'll gracefully fall back to the default parser
10
+ end
11
+
12
+ module Gemstar
13
+ module Outputs
14
+ class HTML < Basic
15
+ def render_diff(diff_command)
16
+ body = diff_command.updates.sort.map do |gem_name, info|
17
+ icon = info[:homepage_url]&.include?("github.com") ? "🐙" : "💎"
18
+ tooltip = info[:description] ? "title=\"#{info[:description].gsub('"', "&quot;")}\"" : ""
19
+ link = "<a href=\"#{info[:homepage_url]}\" #{tooltip} target=\"_blank\">#{gem_name}</a>"
20
+ html = if info[:sections]
21
+ info[:sections].map do |_version, lines|
22
+ html_chunk = begin
23
+ opts = { hard_wrap: false }
24
+ opts[:input] = "GFM" if defined?(Kramdown::Parser::GFM)
25
+ Kramdown::Document.new(lines.join, opts).to_html
26
+ rescue Kramdown::Error
27
+ Kramdown::Document.new(lines.join, { hard_wrap: false }).to_html
28
+ end
29
+ <<~HTML
30
+ #{html_chunk}
31
+ HTML
32
+ end.join("\n")
33
+ elsif info[:release_urls]
34
+ "" # the changelog wasn't found, but we have release links — skip the message
35
+ else
36
+ "<p><strong>#{gem_name}:</strong> No changelog entries found</p>"
37
+ end
38
+
39
+ <<~HTML
40
+ <section>
41
+ <h2>#{icon} #{link}: #{info[:old] || "new"} → #{info[:new]}</h2>
42
+ #{"<p><a href='#{info[:release_page]}' target='_blank'>View all GitHub release notes</a></p>" if info[:release_page]}
43
+ #{html}
44
+ </section>
45
+ HTML
46
+ end.join("\n")
47
+
48
+ project_name = Pathname.getwd.basename.to_s
49
+
50
+ <<~HTML
51
+ <!DOCTYPE html>
52
+ <html>
53
+ <head>
54
+ <meta charset="UTF-8">
55
+ <title>#{project_name}: Gem Updates with Changelogs</title>
56
+ <style>
57
+ body { font-family: sans-serif; padding: 2em; background: #fdfdfd; }
58
+ section { margin-bottom: 3em; border-bottom: 1px solid #ccc; padding-bottom: 1em; }
59
+ h2 { color: #333; }
60
+ h3 { margin-top: 1em; color: #444; }
61
+ pre { background: #eee; padding: 1em; overflow-x: auto; }
62
+ a { color: #0645ad; text-decoration: none; }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <h1>#{project_name}: Gem Updates</h1>
67
+ <p><i>Showing changes from #{diff_command.from} to #{diff_command.to || "now"}, generated on #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}.</i></p>
68
+ #{body}
69
+ </body>
70
+ </html>
71
+ HTML
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemstar # :nodoc:
4
+ class Railtie < ::Rails::Railtie # :nodoc:
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemstar
4
+ class RemoteRepository
5
+ def initialize(repository_uri)
6
+ @repository_uri = repository_uri
7
+ end
8
+
9
+ def find_main_branch
10
+ # Attempt loading .gitignore (assumed to be present in all repos) from either
11
+ # main or master branch:
12
+ %w[main master].each do |branch|
13
+ Cache.fetch("gitignore-#{branch}") do
14
+ content = begin
15
+ URI.open("#{@repository_uri}/#{branch}/.gitignore", read_timeout: 8)&.read
16
+ rescue
17
+ nil
18
+ end
19
+ return [branch] unless content.nil?
20
+ end
21
+ end
22
+
23
+ # No .gitignore found, have to search for changelogs in both branches:
24
+ %w[main master]
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,59 @@
1
+ require "open-uri"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Gemstar
6
+ class RubyGemsMetadata
7
+ def initialize(gem_name)
8
+ @gem_name = gem_name
9
+ end
10
+
11
+ attr_reader :gem_name
12
+
13
+ def meta
14
+ @meta ||=
15
+ begin
16
+ url = "https://rubygems.org/api/v1/gems/#{URI.encode_www_form_component(gem_name)}.json"
17
+ Cache.fetch("rubygems-#{gem_name}") do
18
+ URI.open(url).read
19
+ end.then { |json|
20
+ begin
21
+ JSON.parse(json) if json
22
+ rescue
23
+ nil
24
+ end }
25
+ end
26
+ end
27
+
28
+ def repo_uri
29
+ return nil unless meta
30
+
31
+ @repo_uri ||= begin
32
+ uri = meta["source_code_uri"]
33
+
34
+ if uri.nil?
35
+ uri = meta["homepage_uri"]
36
+ if uri.include?("github.com")
37
+ uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}]
38
+ end
39
+ end
40
+
41
+ uri ||= ""
42
+
43
+ uri = uri.sub("http://", "https://")
44
+
45
+ uri = uri.gsub(/\.git$/, "")
46
+
47
+ if uri.include?("github.io")
48
+ # Convert e.g. https://socketry.github.io/console/ to https://github.com/socketry/console/
49
+ uri = uri.sub(%r{\Ahttps?://([\w-]+)\.github\.io/([^/]+)}) do
50
+ "https://github.com/#{$1}/#{$2}"
51
+ end
52
+ end
53
+
54
+ uri
55
+ end
56
+ end
57
+
58
+ end
59
+ end
@@ -0,0 +1,28 @@
1
+ module Gemstar
2
+ module Utils
3
+ def generate_version_range(from_str, to_str)
4
+ from = Gem::Version.new(from_str.gsub(/-[\w\-]+$/, ''))
5
+ to = Gem::Version.new(to_str.gsub(/-[\w\-]+$/, ''))
6
+ result = Set.new
7
+
8
+ # Generate known version steps up to 2000 iterations max (safety limit)
9
+ queue = [from]
10
+ 2000.times do
11
+ v = queue.pop
12
+ break if v.nil? || v >= to
13
+
14
+ patch = Gem::Version.new("#{v.segments[0]}.#{v.segments[1]}.#{v.segments[2] + 1}")
15
+ minor = Gem::Version.new("#{v.segments[0]}.#{v.segments[1] + 1}.0")
16
+ major = Gem::Version.new("#{v.segments[0] + 1}.0.0")
17
+
18
+ [patch, minor, major].each do |next_v|
19
+ next if next_v > to || result&.include?(next_v)
20
+ result << next_v
21
+ queue << next_v
22
+ end
23
+ end
24
+
25
+ result.select { |v| v > from && v <= to }.sort.map(&:to_s)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemstar # :nodoc:
4
+ VERSION = "0.0.1"
5
+
6
+ def self.debug?
7
+ return @debug if defined?(@debug)
8
+ @debug = ENV["GEMSTAR_DEBUG"] == "true"
9
+ end
10
+ end
data/lib/gemstar.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstar/version"
4
+ require "gemstar/railtie" if defined?(Rails::Railtie)
5
+ require "gemstar/cli"
6
+ require "gemstar/commands/command"
7
+ require "gemstar/commands/diff"
8
+ require "gemstar/outputs/basic"
9
+ require "gemstar/outputs/html"
10
+ require "gemstar/cache"
11
+ require "gemstar/change_log"
12
+ require "gemstar/git_hub"
13
+ require "gemstar/lock_file"
14
+ require "gemstar/remote_repository"
15
+ require "gemstar/utils"
16
+ require "gemstar/ruby_gems_metadata"
17
+ require "gemstar/git_repo"
metadata ADDED
@@ -0,0 +1,205 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemstar
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Florian Dejako
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 2.6.8
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 2.6.8
26
+ - !ruby/object:Gem::Dependency
27
+ name: combustion
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.5'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: kramdown
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: kramdown-parser-gfm
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rouge
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '4'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '4'
110
+ - !ruby/object:Gem::Dependency
111
+ name: concurrent-ruby
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '1.0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: thor
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '1.4'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '1.4'
138
+ - !ruby/object:Gem::Dependency
139
+ name: nokogiri
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '1.18'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '1.18'
152
+ description: Gem changelog viewer and more, but starting as a changelog viewer for
153
+ bundled gems.
154
+ email:
155
+ - fdejako@gmail.com
156
+ executables:
157
+ - gemstar
158
+ extensions: []
159
+ extra_rdoc_files: []
160
+ files:
161
+ - CHANGELOG.md
162
+ - LICENSE.txt
163
+ - README.md
164
+ - bin/gemstar
165
+ - lib/gemstar.rb
166
+ - lib/gemstar/cache.rb
167
+ - lib/gemstar/change_log.rb
168
+ - lib/gemstar/cli.rb
169
+ - lib/gemstar/commands/command.rb
170
+ - lib/gemstar/commands/diff.rb
171
+ - lib/gemstar/git_hub.rb
172
+ - lib/gemstar/git_repo.rb
173
+ - lib/gemstar/lock_file.rb
174
+ - lib/gemstar/outputs/basic.rb
175
+ - lib/gemstar/outputs/html.rb
176
+ - lib/gemstar/railtie.rb
177
+ - lib/gemstar/remote_repository.rb
178
+ - lib/gemstar/ruby_gems_metadata.rb
179
+ - lib/gemstar/utils.rb
180
+ - lib/gemstar/version.rb
181
+ homepage: https://github.com/FDj/gemstar
182
+ licenses:
183
+ - MIT
184
+ metadata:
185
+ bug_tracker_uri: https://github.com/FDj/gemstar/issues
186
+ changelog_uri: https://github.com/FDj/gemstar/blob/master/CHANGELOG.md
187
+ source_code_uri: https://github.com/FDj/gemstar
188
+ rdoc_options: []
189
+ require_paths:
190
+ - lib
191
+ required_ruby_version: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: '3.3'
196
+ required_rubygems_version: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - ">="
199
+ - !ruby/object:Gem::Version
200
+ version: '0'
201
+ requirements: []
202
+ rubygems_version: 3.7.0
203
+ specification_version: 4
204
+ summary: Making sense of gems.
205
+ test_files: []