gemstar 1.2 → 1.3
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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +4 -2
- data/lib/gemstar/cache_warmer.rb +36 -1
- data/lib/gemstar/cargo_lock_file.rb +138 -0
- data/lib/gemstar/change_log.rb +127 -46
- data/lib/gemstar/cli.rb +8 -2
- data/lib/gemstar/commands/diff.rb +13 -2
- data/lib/gemstar/commands/server.rb +32 -11
- data/lib/gemstar/crates_io_metadata.rb +140 -0
- data/lib/gemstar/data/ruby_gems_metadata.json +10 -0
- data/lib/gemstar/outputs/html.rb +2 -0
- data/lib/gemstar/project.rb +208 -15
- data/lib/gemstar/pypi_metadata.rb +182 -0
- data/lib/gemstar/ruby_gems_metadata.rb +26 -3
- data/lib/gemstar/uv_lock_file.rb +114 -0
- data/lib/gemstar/version.rb +1 -1
- data/lib/gemstar/web/app.rb +452 -49
- data/lib/gemstar/web/templates/app.css +131 -4
- data/lib/gemstar/web/templates/app.js.erb +178 -21
- data/lib/gemstar.rb +4 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6eab7d36aeffe59f5863dffb07696c7ed8aab64dfb8828ae962122d607ad0b28
|
|
4
|
+
data.tar.gz: 9120441dae108fb6ff9d9a009cb9e6988349b884f011c40037460a86c45ef42a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5857f33b3a6b36759b2978b768749d7240f8ce54c73e57c48bce10ca407db846a955b80ba7c93d5db2a03bccbc6cb964ee55759ed4d75deb6982e23da681f5c8
|
|
7
|
+
data.tar.gz: 61dc580cfbb69aca3535428107a299833343ad132bb5e437cc01380ae4d58d90d525498818aed462793b036348e9f3666d83d8c6a7d2b821b62313f52c289381
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 1.3
|
|
4
|
+
|
|
5
|
+
- **Python uv projects** are now supported with our new `uv.lock` file parser and pypi package support!
|
|
6
|
+
- **Rust Cargo projects** are now supported with a new `Cargo.lock` parser and crates.io package support!
|
|
7
|
+
- Changelogs in rst format are now supported.
|
|
8
|
+
- Changelogs: Fix parsing of documentation percentages, linked version headings, and version-first headings with dotted dates.
|
|
9
|
+
- Changelogs: Do not treat version-looking prose inside release notes as a new release heading.
|
|
10
|
+
- Changelogs: Prefer repository-branch changelogs when gem metadata points to a release-pinned file that may omit its own release.
|
|
11
|
+
- Changelogs: Discover extensionless changelog files and direct GitHub changelog links, including Roda and Selenium release histories.
|
|
12
|
+
- Changelogs: Correct known upstream release-heading typos through package metadata, including Thruster 0.1.23.
|
|
13
|
+
- Server: Sort changelog cards with semantic version comparison so Selenium's `4.x` releases appear ahead of `3.142.x` and `3.141.x`.
|
|
14
|
+
- Server: Render linked changelog version headings without Markdown brackets.
|
|
15
|
+
- Server: Keep the details focus ring visible around its sticky header.
|
|
16
|
+
- Server: Keep keyboard-selected sidebar packages visible below the sticky filter header.
|
|
17
|
+
- Documentation: List Python package support in the README and gem metadata.
|
|
18
|
+
- Server: Details heading is now sticky.
|
|
19
|
+
- Server: Changed display of available versions to be more clear and concise.
|
|
20
|
+
- CLI: Improve message shown when no supported project files are found.
|
|
21
|
+
- CLI: Add `--version` to print the Gemstar version.
|
|
22
|
+
|
|
3
23
|
## 1.2
|
|
4
24
|
|
|
5
25
|
- Server: Cache warmer now pre-fetches package detail pages so browsing warmed gems is faster.
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/FDj/gemstar/actions)
|
|
4
4
|
|
|
5
5
|
# Gemstar
|
|
6
|
-
Helps you keep track of your gems and
|
|
6
|
+
Helps you keep track of your Ruby gems, JavaScript packages, Python packages, and Rust crates.
|
|
7
7
|
|
|
8
8
|
## Installation
|
|
9
9
|
|
|
@@ -72,7 +72,7 @@ To examine a specific Gemfile.lock, pass it like this:
|
|
|
72
72
|
gemstar diff --lockfile=~/MyProject/Gemfile.lock
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
To diff a project from anywhere, pass the project directory or a supported project file. In project mode, gemstar includes Ruby gems
|
|
75
|
+
To diff a project from anywhere, pass the project directory or a supported project file. In project mode, gemstar includes Ruby gems, JS packages from `importmap.rb` and `package-lock.json`, Python packages from `uv.lock`, and Rust crates from `Cargo.lock` when present:
|
|
76
76
|
|
|
77
77
|
```shell
|
|
78
78
|
gemstar diff --project ~/Code/my-app
|
|
@@ -83,6 +83,8 @@ To filter a project diff down to one ecosystem:
|
|
|
83
83
|
```shell
|
|
84
84
|
gemstar diff --project ~/Code/my-app --ecosystem js
|
|
85
85
|
gemstar diff --project ~/Code/my-app --ecosystem gems
|
|
86
|
+
gemstar diff --project ~/Code/my-app --ecosystem python
|
|
87
|
+
gemstar diff --project ~/Code/my-app --ecosystem cargo
|
|
86
88
|
```
|
|
87
89
|
|
|
88
90
|
To write markdown instead of html:
|
data/lib/gemstar/cache_warmer.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "set"
|
|
2
2
|
require "thread"
|
|
3
|
+
require_relative "crates_io_metadata"
|
|
4
|
+
require_relative "pypi_metadata"
|
|
3
5
|
|
|
4
6
|
module Gemstar
|
|
5
7
|
class CacheWarmer
|
|
@@ -18,6 +20,7 @@ module Gemstar
|
|
|
18
20
|
@completed = Set.new
|
|
19
21
|
@workers = []
|
|
20
22
|
@started = false
|
|
23
|
+
@stopping = false
|
|
21
24
|
@total = 0
|
|
22
25
|
@completed_count = 0
|
|
23
26
|
end
|
|
@@ -28,6 +31,8 @@ module Gemstar
|
|
|
28
31
|
states = normalize_package_states(package_states)
|
|
29
32
|
|
|
30
33
|
@mutex.synchronize do
|
|
34
|
+
return self if @stopping
|
|
35
|
+
|
|
31
36
|
states.each do |package_state|
|
|
32
37
|
key = package_key(package_state)
|
|
33
38
|
next if @completed.include?(key) || @queued.include?(key) || @in_progress.include?(key)
|
|
@@ -74,6 +79,28 @@ module Gemstar
|
|
|
74
79
|
@condition.broadcast
|
|
75
80
|
end
|
|
76
81
|
|
|
82
|
+
def shutdown(timeout: 0.5)
|
|
83
|
+
workers = nil
|
|
84
|
+
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
@stopping = true
|
|
87
|
+
@queue.clear
|
|
88
|
+
@queued.clear
|
|
89
|
+
@condition.broadcast
|
|
90
|
+
workers = @workers.dup
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
workers.each do |worker|
|
|
94
|
+
worker.join(timeout)
|
|
95
|
+
worker.kill if worker.alive?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@workers.clear
|
|
100
|
+
@started = false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
77
104
|
def pending?(package_name)
|
|
78
105
|
@mutex.synchronize do
|
|
79
106
|
@queue.any? { |item| item[:name] == package_name || item.dig(:source, :package_name) == package_name } ||
|
|
@@ -97,9 +124,10 @@ module Gemstar
|
|
|
97
124
|
|
|
98
125
|
loop do
|
|
99
126
|
package_state = @mutex.synchronize do
|
|
100
|
-
while @queue.empty?
|
|
127
|
+
while @queue.empty? && !@stopping
|
|
101
128
|
@condition.wait(@mutex)
|
|
102
129
|
end
|
|
130
|
+
return if @stopping && @queue.empty?
|
|
103
131
|
|
|
104
132
|
next_package = @queue.shift
|
|
105
133
|
key = package_key(next_package)
|
|
@@ -180,6 +208,13 @@ module Gemstar
|
|
|
180
208
|
end
|
|
181
209
|
|
|
182
210
|
def metadata_adapter_for(package_state)
|
|
211
|
+
if package_state[:package_scope] == "cargo"
|
|
212
|
+
package_name = package_state[:metadata_package_name] || package_state.dig(:source, :package_name) || package_state[:name]
|
|
213
|
+
return Gemstar::CratesIOMetadata.new(package_name)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
return Gemstar::PyPIMetadata.new(package_state[:name]) if package_state[:package_scope] == "python"
|
|
217
|
+
|
|
183
218
|
if package_state[:package_scope] == "js"
|
|
184
219
|
provider_gem = package_state.dig(:source, :provider_gem)
|
|
185
220
|
return Gemstar::RubyGemsMetadata.new(provider_gem) unless provider_gem.to_s.empty?
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Gemstar
|
|
6
|
+
class CargoLockFile
|
|
7
|
+
attr_reader :specs
|
|
8
|
+
attr_reader :spec_sources
|
|
9
|
+
|
|
10
|
+
def initialize(path: nil, content: nil)
|
|
11
|
+
parsed = parse_content(content || File.read(path))
|
|
12
|
+
@specs = parsed[:specs]
|
|
13
|
+
@spec_sources = parsed[:spec_sources]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def source_for(name)
|
|
17
|
+
spec_sources[name]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def parse_content(content)
|
|
23
|
+
packages = package_blocks(content).filter_map do |block|
|
|
24
|
+
name = scalar_value(block, "name")
|
|
25
|
+
version = scalar_value(block, "version")
|
|
26
|
+
source = scalar_value(block, "source")
|
|
27
|
+
next if name.to_s.empty? || version.to_s.empty?
|
|
28
|
+
next if source.to_s.empty?
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
name: name,
|
|
32
|
+
version: version,
|
|
33
|
+
source: source_for_package(source, name, version)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
specs = {}
|
|
38
|
+
spec_sources = {}
|
|
39
|
+
packages.group_by { |package| package[:name] }.each_value do |named_packages|
|
|
40
|
+
sorted_packages(named_packages).each_with_index do |package, index|
|
|
41
|
+
key = package_key(package, index, named_packages.length)
|
|
42
|
+
specs[key] = package[:version]
|
|
43
|
+
spec_sources[key] = package[:source]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{specs: specs, spec_sources: spec_sources}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def package_blocks(content)
|
|
51
|
+
content.to_s.split(/^\[\[package\]\]\s*$/).drop(1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def scalar_value(block, key)
|
|
55
|
+
block[/^#{Regexp.escape(key)}\s*=\s*"((?:\\.|[^"])*)"/, 1]&.gsub('\\"', '"')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def source_for_package(raw_source, name, version)
|
|
59
|
+
if raw_source.start_with?("git+")
|
|
60
|
+
git_source(raw_source, name, version)
|
|
61
|
+
else
|
|
62
|
+
registry_source(raw_source, name, version)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def git_source(raw_source, name, version)
|
|
67
|
+
remote_with_query, revision = raw_source.delete_prefix("git+").split("#", 2)
|
|
68
|
+
remote, query = remote_with_query.split("?", 2)
|
|
69
|
+
params = URI.decode_www_form(query.to_s).to_h
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
type: :git,
|
|
73
|
+
remote: remote,
|
|
74
|
+
revision: revision,
|
|
75
|
+
branch: params["branch"],
|
|
76
|
+
tag: params["tag"],
|
|
77
|
+
package_name: name,
|
|
78
|
+
package_version: version
|
|
79
|
+
}.compact
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
{
|
|
82
|
+
type: :git,
|
|
83
|
+
remote: raw_source.delete_prefix("git+").split(/[?#]/, 2).first,
|
|
84
|
+
revision: raw_source.split("#", 2)[1],
|
|
85
|
+
package_name: name,
|
|
86
|
+
package_version: version
|
|
87
|
+
}.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def registry_source(raw_source, name, version)
|
|
91
|
+
registry = raw_source.sub(/\A(?:registry|sparse)\+/, "")
|
|
92
|
+
crates_io = crates_io_registry?(registry)
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
type: :cargo,
|
|
96
|
+
remote: registry,
|
|
97
|
+
package_name: name,
|
|
98
|
+
package_version: version,
|
|
99
|
+
registry_url: crates_io ? "https://crates.io/crates/#{name}" : registry,
|
|
100
|
+
crates_io: crates_io
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def crates_io_registry?(registry)
|
|
105
|
+
registry.include?("crates.io-index") || registry.include?("index.crates.io")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def sorted_packages(packages)
|
|
109
|
+
packages.sort do |left, right|
|
|
110
|
+
comparison = compare_versions(right[:version], left[:version])
|
|
111
|
+
comparison.zero? ? left[:source].to_s <=> right[:source].to_s : comparison
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def compare_versions(left, right)
|
|
116
|
+
Gem::Version.new(left) <=> Gem::Version.new(right)
|
|
117
|
+
rescue ArgumentError
|
|
118
|
+
left.to_s <=> right.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def package_key(package, index, package_count)
|
|
122
|
+
return package[:name] if package_count == 1
|
|
123
|
+
|
|
124
|
+
key = "#{package[:name]}@#{package[:version]}"
|
|
125
|
+
if index.zero? || !@used_package_keys&.include?(key)
|
|
126
|
+
remember_package_key(key)
|
|
127
|
+
else
|
|
128
|
+
remember_package_key("#{key}:#{index + 1}")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def remember_package_key(key)
|
|
133
|
+
@used_package_keys ||= []
|
|
134
|
+
@used_package_keys << key
|
|
135
|
+
key
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/gemstar/change_log.rb
CHANGED
|
@@ -12,12 +12,21 @@ module Gemstar
|
|
|
12
12
|
class ChangeLog
|
|
13
13
|
@@candidates_found = Hash.new(0)
|
|
14
14
|
GITHUB_CLI_TIMEOUT = 8
|
|
15
|
+
MAX_CHANGELOG_REDIRECTS = 3
|
|
16
|
+
CHANGELOG_REDIRECT_PATTERN = /\b(?:change\s*log|changelog|release\s+notes?)\b.{0,160}\b(?:moved|relocated|now\s+lives|lives\s+here)\b/im
|
|
15
17
|
DEFAULT_CHANGELOG_PATHS = %w[
|
|
16
|
-
CHANGELOG.md releases.md CHANGES.md
|
|
18
|
+
CHANGELOG.md CHANGELOG releases.md CHANGES.md
|
|
17
19
|
Changelog.md changelog.md ChangeLog.md
|
|
18
20
|
Changes.md changes.md
|
|
19
21
|
HISTORY.md History.md history.md
|
|
20
|
-
History
|
|
22
|
+
History
|
|
23
|
+
RELEASE_NOTES.md Release_Notes.md release_notes.md
|
|
24
|
+
RELEASE-NOTES.md Release-Notes.md release-notes.md
|
|
25
|
+
CHANGELOG.rst Changelog.rst changelog.rst ChangeLog.rst
|
|
26
|
+
CHANGES.rst Changes.rst changes.rst
|
|
27
|
+
HISTORY.rst History.rst history.rst
|
|
28
|
+
CHANGES Changes
|
|
29
|
+
CHANGELOG.rdoc
|
|
21
30
|
].freeze
|
|
22
31
|
|
|
23
32
|
def initialize(metadata)
|
|
@@ -38,7 +47,7 @@ module Gemstar
|
|
|
38
47
|
return @sections if !cache_only && defined?(@sections) && !force_refresh
|
|
39
48
|
|
|
40
49
|
metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
|
|
41
|
-
cache_key = "sections-
|
|
50
|
+
cache_key = "sections-v10-#{metadata_key}"
|
|
42
51
|
serialized = if cache_only
|
|
43
52
|
Cache.peek(cache_key)
|
|
44
53
|
else
|
|
@@ -182,25 +191,10 @@ module Gemstar
|
|
|
182
191
|
value = version.to_s.strip
|
|
183
192
|
return nil if value.empty?
|
|
184
193
|
|
|
185
|
-
value.sub(/\Av/i, "")
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
# and avoiding returning a date string when both are present.
|
|
190
|
-
def extract_version_from_heading(line)
|
|
191
|
-
return nil unless line
|
|
192
|
-
heading = line.to_s
|
|
193
|
-
version_token = /(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)/
|
|
194
|
-
# 1) Prefer version inside parentheses after a date: "### 2025-11-07 (2.16.0)"
|
|
195
|
-
# Ensure we ONLY treat it as a version if it actually looks like a version (has a dot),
|
|
196
|
-
# so we don't capture dates like (2025-11-21).
|
|
197
|
-
return $1 if heading[/\(\s*v?#{version_token}(?![A-Za-z0-9])\s*\)/]
|
|
198
|
-
# 2) Version-first with optional leading markers/labels: "## v1.2.6 - 2025-10-21"
|
|
199
|
-
# Require a dot in the numeric token to avoid capturing dates like 2025-11-21.
|
|
200
|
-
return $1 if heading[/^\s*(?:[-*]\s+)?(?:#+|=+)?\s*(?:Version\s+)?\[*v?#{version_token}(?![A-Za-z0-9])\]*/i]
|
|
201
|
-
# 3) Anywhere: first semver-like token with a dot
|
|
202
|
-
return $1 if heading[/\bv?#{version_token}(?![A-Za-z0-9])\b/]
|
|
203
|
-
nil
|
|
194
|
+
value = value.sub(/\Av/i, "")
|
|
195
|
+
Gem::Version.new(value.gsub(/-[\w\-]+$/, "")).canonical_segments.join(".")
|
|
196
|
+
rescue ArgumentError
|
|
197
|
+
value
|
|
204
198
|
end
|
|
205
199
|
|
|
206
200
|
def extract_release_date_from_heading(line)
|
|
@@ -211,23 +205,24 @@ module Gemstar
|
|
|
211
205
|
end
|
|
212
206
|
|
|
213
207
|
def changelog_uri_candidates(cache_only: false, force_refresh: false)
|
|
214
|
-
candidates = []
|
|
215
|
-
|
|
216
208
|
repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
|
|
217
209
|
return [] if repo_uri.nil? || repo_uri.empty?
|
|
218
210
|
|
|
219
211
|
meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
|
|
220
|
-
|
|
212
|
+
explicit_candidates = meta ? changelog_uri_markdown_candidates(meta["changelog_uri"]) : []
|
|
221
213
|
|
|
222
214
|
changelog_source = metadata_changelog_source(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
|
|
223
215
|
return [] unless changelog_source
|
|
224
216
|
|
|
225
|
-
|
|
217
|
+
repository_candidates = changelog_source[:paths].product(changelog_source[:branches]).map do |file, branch|
|
|
226
218
|
[changelog_source[:base], branch, file].reject { |segment| segment.to_s.empty? }.join("/")
|
|
227
219
|
end
|
|
228
220
|
|
|
229
|
-
|
|
230
|
-
|
|
221
|
+
candidates = if version_pinned_changelog_uri?(meta)
|
|
222
|
+
repository_candidates + explicit_candidates
|
|
223
|
+
else
|
|
224
|
+
explicit_candidates + repository_candidates
|
|
225
|
+
end
|
|
231
226
|
|
|
232
227
|
candidates.flatten!
|
|
233
228
|
candidates.uniq!
|
|
@@ -236,12 +231,24 @@ module Gemstar
|
|
|
236
231
|
candidates
|
|
237
232
|
end
|
|
238
233
|
|
|
234
|
+
def version_pinned_changelog_uri?(meta)
|
|
235
|
+
changelog_uri = meta&.fetch("changelog_uri", nil).to_s
|
|
236
|
+
version = meta&.fetch("version", nil).to_s.sub(/\Av/i, "")
|
|
237
|
+
return false if changelog_uri.empty? || version.empty?
|
|
238
|
+
|
|
239
|
+
raw_uri = Gemstar::GitHub::github_blob_to_raw(changelog_uri)
|
|
240
|
+
path_segments = URI(raw_uri).path.split("/")
|
|
241
|
+
path_segments.include?(version) || path_segments.include?("v#{version}")
|
|
242
|
+
rescue URI::InvalidURIError
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
|
|
239
246
|
def changelog_uri_markdown_candidates(changelog_uri)
|
|
240
247
|
raw_uri = Gemstar::GitHub::github_blob_to_raw(changelog_uri)
|
|
241
248
|
return [] if raw_uri.to_s.empty?
|
|
242
249
|
|
|
243
250
|
candidates = []
|
|
244
|
-
candidates << raw_uri if raw_uri.match?(/\.(?:md|markdown|rdoc|txt)\z/i)
|
|
251
|
+
candidates << raw_uri if raw_uri.match?(/\.(?:md|markdown|rdoc|rst|txt)\z/i)
|
|
245
252
|
|
|
246
253
|
begin
|
|
247
254
|
uri = URI(raw_uri)
|
|
@@ -249,9 +256,14 @@ module Gemstar
|
|
|
249
256
|
if path.end_with?("/")
|
|
250
257
|
uri.path = "#{path.chomp("/")}.md"
|
|
251
258
|
candidates << uri.to_s
|
|
259
|
+
uri.path = "#{path.chomp("/")}.rst"
|
|
260
|
+
candidates << uri.to_s
|
|
252
261
|
elsif File.extname(path).empty?
|
|
262
|
+
candidates << raw_uri if uri.host == "raw.githubusercontent.com"
|
|
253
263
|
uri.path = "#{path}.md"
|
|
254
264
|
candidates << uri.to_s
|
|
265
|
+
uri.path = "#{path}.rst"
|
|
266
|
+
candidates << uri.to_s
|
|
255
267
|
end
|
|
256
268
|
rescue URI::InvalidURIError
|
|
257
269
|
nil
|
|
@@ -277,15 +289,14 @@ module Gemstar
|
|
|
277
289
|
content = nil
|
|
278
290
|
|
|
279
291
|
changelog_uri_candidates(cache_only: cache_only, force_refresh: force_refresh).find do |candidate|
|
|
280
|
-
content =
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
end
|
|
292
|
+
content = fetch_changelog_uri(candidate, cache_only: cache_only, force_refresh: force_refresh)
|
|
293
|
+
if content
|
|
294
|
+
content = follow_changelog_redirects(
|
|
295
|
+
content,
|
|
296
|
+
source_uri: candidate,
|
|
297
|
+
cache_only: cache_only,
|
|
298
|
+
force_refresh: force_refresh
|
|
299
|
+
)
|
|
289
300
|
end
|
|
290
301
|
|
|
291
302
|
# puts "fetch_changelog_content #{candidate}:\n#{content}" if Gemstar.debug?
|
|
@@ -300,13 +311,74 @@ module Gemstar
|
|
|
300
311
|
content
|
|
301
312
|
end
|
|
302
313
|
|
|
314
|
+
def fetch_changelog_uri(uri, cache_only:, force_refresh:)
|
|
315
|
+
if cache_only
|
|
316
|
+
Cache.peek("changelog-#{uri}")
|
|
317
|
+
else
|
|
318
|
+
Cache.fetch("changelog-#{uri}", force: force_refresh) do
|
|
319
|
+
URI.open(uri, read_timeout: 8)&.read
|
|
320
|
+
rescue => e
|
|
321
|
+
puts "#{uri}: #{e}" if Gemstar.debug?
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def follow_changelog_redirects(content, source_uri:, cache_only:, force_refresh:)
|
|
328
|
+
current_content = content
|
|
329
|
+
current_uri = source_uri
|
|
330
|
+
visited = {source_uri => true}
|
|
331
|
+
|
|
332
|
+
MAX_CHANGELOG_REDIRECTS.times do
|
|
333
|
+
redirect_uri = changelog_redirect_uri(current_content, current_uri)
|
|
334
|
+
break if redirect_uri.nil? || visited[redirect_uri]
|
|
335
|
+
|
|
336
|
+
redirected_content = fetch_changelog_uri(
|
|
337
|
+
redirect_uri,
|
|
338
|
+
cache_only: cache_only,
|
|
339
|
+
force_refresh: force_refresh
|
|
340
|
+
)
|
|
341
|
+
break if redirected_content.nil? || redirected_content.strip.empty?
|
|
342
|
+
|
|
343
|
+
puts "Following changelog redirect: #{current_uri} -> #{redirect_uri}" if Gemstar.debug?
|
|
344
|
+
visited[redirect_uri] = true
|
|
345
|
+
current_uri = redirect_uri
|
|
346
|
+
current_content = redirected_content
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
current_content
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def changelog_redirect_uri(content, source_uri)
|
|
353
|
+
text = content.to_s
|
|
354
|
+
return nil if text.bytesize > 4096 || text.lines.count > 12
|
|
355
|
+
return nil unless text.match?(CHANGELOG_REDIRECT_PATTERN)
|
|
356
|
+
|
|
357
|
+
links = text.scan(/\[([^\]]+)\]\(\s*<?([^\s)>]+)>?(?:\s+["'][^"']*["'])?\s*\)/)
|
|
358
|
+
redirect_link = links.find { |link_label, _| link_label.match?(/\A(?:here|changelog|change\s*log|release\s+notes?)\z/i) }
|
|
359
|
+
redirect_link ||= links.find { |_, link_href| link_href.match?(%r{(?:change(?:log|s)|history|releases?)}i) }
|
|
360
|
+
href = redirect_link&.last
|
|
361
|
+
return nil if href.nil?
|
|
362
|
+
|
|
363
|
+
resolved = URI.join(source_uri, CGI.unescapeHTML(href)).to_s
|
|
364
|
+
uri = URI(Gemstar::GitHub::github_blob_to_raw(resolved))
|
|
365
|
+
return nil unless %w[http https].include?(uri.scheme)
|
|
366
|
+
|
|
367
|
+
uri.fragment = nil
|
|
368
|
+
uri.to_s
|
|
369
|
+
rescue URI::InvalidURIError
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
|
|
303
373
|
VERSION_PATTERNS = [
|
|
304
|
-
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d
|
|
305
|
-
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d
|
|
306
|
-
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d
|
|
307
|
-
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d
|
|
308
|
-
/^\s*(?:[
|
|
374
|
+
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\s*\)/, # prefer this
|
|
375
|
+
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\]*\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/,
|
|
376
|
+
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\]*(?:\s*[-(].*)?/i,
|
|
377
|
+
/^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)\]*(?:\s*[-(].*)?/i,
|
|
378
|
+
/^\s*(?:[-+*]\s+)?Starting with version\s+v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)/i,
|
|
379
|
+
/^\s*(?:[-+*]\s+)?(?:Version\s+)?v?(\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9]|\s*%)(?:\s*[-(].*)?\s*$/i
|
|
309
380
|
]
|
|
381
|
+
RST_ADORNMENT_PATTERN = /^\s*[=\-~`^"']{3,}\s*$/
|
|
310
382
|
|
|
311
383
|
def parse_changelog_sections(cache_only: false, force_refresh: false)
|
|
312
384
|
# If the fetched content looks like a GitHub Releases HTML page, return {}
|
|
@@ -336,11 +408,13 @@ module Gemstar
|
|
|
336
408
|
lines.each do |line|
|
|
337
409
|
# Convert rdoc to markdown:
|
|
338
410
|
line = line.gsub(/^=+/) { |m| "#" * m.length }
|
|
411
|
+
next if line.match?(/^\s*\.\.\s+_v?[\w.-]+:/)
|
|
412
|
+
next if line.match?(RST_ADORNMENT_PATTERN)
|
|
339
413
|
|
|
340
414
|
m = VERSION_PATTERNS.lazy.map { |re| line.match(re) }.find(&:itself)
|
|
341
415
|
|
|
342
416
|
if m
|
|
343
|
-
new_key =
|
|
417
|
+
new_key = corrected_changelog_version(m[1], line)
|
|
344
418
|
|
|
345
419
|
if current_key
|
|
346
420
|
sections[current_key] ||= []
|
|
@@ -375,14 +449,21 @@ module Gemstar
|
|
|
375
449
|
|
|
376
450
|
c.lines.each_with_object({}) do |line, dates|
|
|
377
451
|
line = line.gsub(/^=+/) { |m| "#" * m.length }
|
|
378
|
-
|
|
452
|
+
match = VERSION_PATTERNS.lazy.map { |pattern| line.match(pattern) }.find(&:itself)
|
|
453
|
+
next unless match
|
|
379
454
|
|
|
380
|
-
version =
|
|
455
|
+
version = corrected_changelog_version(match[1], line)
|
|
381
456
|
date = extract_release_date_from_heading(line)
|
|
382
457
|
dates[version] ||= date if version && date
|
|
383
458
|
end
|
|
384
459
|
end
|
|
385
460
|
|
|
461
|
+
def corrected_changelog_version(version, heading)
|
|
462
|
+
return version unless @metadata.respond_to?(:correct_changelog_version)
|
|
463
|
+
|
|
464
|
+
@metadata.correct_changelog_version(version, heading: heading) || version
|
|
465
|
+
end
|
|
466
|
+
|
|
386
467
|
def parse_github_release_sections(cache_only: false, force_refresh: false)
|
|
387
468
|
repo_uri = @metadata&.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
|
|
388
469
|
return {} unless repo_uri&.include?("github.com")
|
data/lib/gemstar/cli.rb
CHANGED
|
@@ -5,7 +5,8 @@ module Gemstar
|
|
|
5
5
|
class CLI < Thor
|
|
6
6
|
package_name "gemstar"
|
|
7
7
|
|
|
8
|
-
map "-D" => "diff"
|
|
8
|
+
map "-D" => "diff",
|
|
9
|
+
"--version" => "version"
|
|
9
10
|
|
|
10
11
|
class_option :verbose, type: :boolean, default: false, desc: "Enable verbose output"
|
|
11
12
|
class_option :lockfile, type: :string, default: "Gemfile.lock", desc: "Lockfile path"
|
|
@@ -15,7 +16,7 @@ module Gemstar
|
|
|
15
16
|
method_option :to, type: :string, desc: "Git ref or lockfile"
|
|
16
17
|
method_option :since, type: :string, desc: "Set --from to the latest commit before this relative time (for example: '3 weeks')"
|
|
17
18
|
method_option :project, type: :string, desc: "Project directory or supported project file path"
|
|
18
|
-
method_option :ecosystem, type: :string, desc: "Filter packages by ecosystem (all, gems, js)"
|
|
19
|
+
method_option :ecosystem, type: :string, desc: "Filter packages by ecosystem (all, gems, js, python, cargo)"
|
|
19
20
|
method_option :format, type: :string, desc: "Output format (html or markdown)"
|
|
20
21
|
method_option :output_file, type: :string, desc: "Output file path"
|
|
21
22
|
method_option :debug_gem_regex, type: :string, desc: "Debug matching gems", hide: true
|
|
@@ -36,6 +37,11 @@ module Gemstar
|
|
|
36
37
|
desc "cache SUBCOMMAND ...ARGS", "Manage gemstar caches"
|
|
37
38
|
subcommand "cache", Gemstar::CacheCLI
|
|
38
39
|
|
|
40
|
+
desc "version", "Print gemstar version"
|
|
41
|
+
def version
|
|
42
|
+
puts Gemstar::VERSION
|
|
43
|
+
end
|
|
44
|
+
|
|
39
45
|
# desc "pick", "Interactively cherry-pick and upgrade gems"
|
|
40
46
|
# option :gem, type: :string, desc: "Gem name to cherry-pick"
|
|
41
47
|
# def pick
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "command"
|
|
4
|
+
require_relative "../pypi_metadata"
|
|
5
|
+
require_relative "../crates_io_metadata"
|
|
4
6
|
require "concurrent-ruby"
|
|
5
7
|
require "tmpdir"
|
|
6
8
|
require "pathname"
|
|
@@ -70,9 +72,9 @@ module Gemstar
|
|
|
70
72
|
def normalize_ecosystem(value)
|
|
71
73
|
normalized = value.to_s.strip.downcase
|
|
72
74
|
return "all" if normalized.empty?
|
|
73
|
-
return normalized if %w[all gems js].include?(normalized)
|
|
75
|
+
return normalized if %w[all gems js python cargo].include?(normalized)
|
|
74
76
|
|
|
75
|
-
raise Thor::Error, "Unsupported ecosystem #{value.inspect}. Expected one of: all, gems, js"
|
|
77
|
+
raise Thor::Error, "Unsupported ecosystem #{value.inspect}. Expected one of: all, gems, js, python, cargo"
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
def normalize_since(value)
|
|
@@ -210,6 +212,11 @@ module Gemstar
|
|
|
210
212
|
def metadata_for(package_state)
|
|
211
213
|
if package_state[:package_scope] == "js"
|
|
212
214
|
Gemstar::NpmMetadata.new(package_state[:name])
|
|
215
|
+
elsif package_state[:package_scope] == "python"
|
|
216
|
+
Gemstar::PyPIMetadata.new(package_state[:name])
|
|
217
|
+
elsif package_state[:package_scope] == "cargo"
|
|
218
|
+
package_name = package_state[:metadata_package_name] || package_state.dig(:source, :package_name) || package_state[:name]
|
|
219
|
+
Gemstar::CratesIOMetadata.new(package_name)
|
|
213
220
|
else
|
|
214
221
|
Gemstar::RubyGemsMetadata.new(package_state[:name])
|
|
215
222
|
end
|
|
@@ -315,6 +322,10 @@ module Gemstar
|
|
|
315
322
|
"importmap"
|
|
316
323
|
when :package_lock
|
|
317
324
|
"package-lock"
|
|
325
|
+
when :uv_lock
|
|
326
|
+
"uv.lock"
|
|
327
|
+
when :cargo_lock
|
|
328
|
+
"Cargo.lock"
|
|
318
329
|
else
|
|
319
330
|
package_state[:package_scope]
|
|
320
331
|
end
|