gemstar 1.1.1 → 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.
@@ -2,17 +2,31 @@
2
2
  require "cgi"
3
3
  require "date"
4
4
  require "json"
5
+ require "open3"
5
6
  require "time"
7
+ require "timeout"
8
+ require "uri"
9
+ require_relative "cache"
6
10
 
7
11
  module Gemstar
8
12
  class ChangeLog
9
13
  @@candidates_found = Hash.new(0)
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
10
17
  DEFAULT_CHANGELOG_PATHS = %w[
11
- CHANGELOG.md releases.md CHANGES.md
18
+ CHANGELOG.md CHANGELOG releases.md CHANGES.md
12
19
  Changelog.md changelog.md ChangeLog.md
13
20
  Changes.md changes.md
14
21
  HISTORY.md History.md history.md
15
- History CHANGELOG.rdoc
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
16
30
  ].freeze
17
31
 
18
32
  def initialize(metadata)
@@ -33,7 +47,7 @@ module Gemstar
33
47
  return @sections if !cache_only && defined?(@sections) && !force_refresh
34
48
 
35
49
  metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
36
- cache_key = "sections-v5-#{metadata_key}"
50
+ cache_key = "sections-v10-#{metadata_key}"
37
51
  serialized = if cache_only
38
52
  Cache.peek(cache_key)
39
53
  else
@@ -54,7 +68,7 @@ module Gemstar
54
68
  result
55
69
  end
56
70
 
57
- def sections_for_versions(versions, cache_only: false, force_refresh: false)
71
+ def sections_for_versions(versions, cache_only: false, force_refresh: false, use_github_cli: false)
58
72
  requested_versions = normalize_requested_versions(versions)
59
73
  return {} if requested_versions.empty?
60
74
 
@@ -75,7 +89,8 @@ module Gemstar
75
89
  repo_uri,
76
90
  version,
77
91
  cache_only: false,
78
- force_refresh: force_refresh
92
+ force_refresh: force_refresh,
93
+ use_github_cli: use_github_cli
79
94
  )
80
95
  result.merge!(specific_release) if specific_release
81
96
  end
@@ -176,25 +191,10 @@ module Gemstar
176
191
  value = version.to_s.strip
177
192
  return nil if value.empty?
178
193
 
179
- value.sub(/\Av/i, "")
180
- end
181
-
182
- # Extract a version token from a heading line, preferring explicit version forms
183
- # and avoiding returning a date string when both are present.
184
- def extract_version_from_heading(line)
185
- return nil unless line
186
- heading = line.to_s
187
- version_token = /(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)/
188
- # 1) Prefer version inside parentheses after a date: "### 2025-11-07 (2.16.0)"
189
- # Ensure we ONLY treat it as a version if it actually looks like a version (has a dot),
190
- # so we don't capture dates like (2025-11-21).
191
- return $1 if heading[/\(\s*v?#{version_token}(?![A-Za-z0-9])\s*\)/]
192
- # 2) Version-first with optional leading markers/labels: "## v1.2.6 - 2025-10-21"
193
- # Require a dot in the numeric token to avoid capturing dates like 2025-11-21.
194
- return $1 if heading[/^\s*(?:[-*]\s+)?(?:#+|=+)?\s*(?:Version\s+)?\[*v?#{version_token}(?![A-Za-z0-9])\]*/i]
195
- # 3) Anywhere: first semver-like token with a dot
196
- return $1 if heading[/\bv?#{version_token}(?![A-Za-z0-9])\b/]
197
- nil
194
+ value = value.sub(/\Av/i, "")
195
+ Gem::Version.new(value.gsub(/-[\w\-]+$/, "")).canonical_segments.join(".")
196
+ rescue ArgumentError
197
+ value
198
198
  end
199
199
 
200
200
  def extract_release_date_from_heading(line)
@@ -205,23 +205,24 @@ module Gemstar
205
205
  end
206
206
 
207
207
  def changelog_uri_candidates(cache_only: false, force_refresh: false)
208
- candidates = []
209
-
210
208
  repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
211
209
  return [] if repo_uri.nil? || repo_uri.empty?
212
210
 
213
211
  meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
214
- candidates += changelog_uri_markdown_candidates(meta["changelog_uri"]) if meta
212
+ explicit_candidates = meta ? changelog_uri_markdown_candidates(meta["changelog_uri"]) : []
215
213
 
216
214
  changelog_source = metadata_changelog_source(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
217
215
  return [] unless changelog_source
218
216
 
219
- candidates += changelog_source[:paths].product(changelog_source[:branches]).map do |file, branch|
217
+ repository_candidates = changelog_source[:paths].product(changelog_source[:branches]).map do |file, branch|
220
218
  [changelog_source[:base], branch, file].reject { |segment| segment.to_s.empty? }.join("/")
221
219
  end
222
220
 
223
- # Add the gem's changelog_uri last as it's usually not the most parsable:
224
- candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
221
+ candidates = if version_pinned_changelog_uri?(meta)
222
+ repository_candidates + explicit_candidates
223
+ else
224
+ explicit_candidates + repository_candidates
225
+ end
225
226
 
226
227
  candidates.flatten!
227
228
  candidates.uniq!
@@ -230,12 +231,24 @@ module Gemstar
230
231
  candidates
231
232
  end
232
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
+
233
246
  def changelog_uri_markdown_candidates(changelog_uri)
234
247
  raw_uri = Gemstar::GitHub::github_blob_to_raw(changelog_uri)
235
248
  return [] if raw_uri.to_s.empty?
236
249
 
237
250
  candidates = []
238
- 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)
239
252
 
240
253
  begin
241
254
  uri = URI(raw_uri)
@@ -243,9 +256,14 @@ module Gemstar
243
256
  if path.end_with?("/")
244
257
  uri.path = "#{path.chomp("/")}.md"
245
258
  candidates << uri.to_s
259
+ uri.path = "#{path.chomp("/")}.rst"
260
+ candidates << uri.to_s
246
261
  elsif File.extname(path).empty?
262
+ candidates << raw_uri if uri.host == "raw.githubusercontent.com"
247
263
  uri.path = "#{path}.md"
248
264
  candidates << uri.to_s
265
+ uri.path = "#{path}.rst"
266
+ candidates << uri.to_s
249
267
  end
250
268
  rescue URI::InvalidURIError
251
269
  nil
@@ -271,15 +289,14 @@ module Gemstar
271
289
  content = nil
272
290
 
273
291
  changelog_uri_candidates(cache_only: cache_only, force_refresh: force_refresh).find do |candidate|
274
- content = if cache_only
275
- Cache.peek("changelog-#{candidate}")
276
- else
277
- Cache.fetch("changelog-#{candidate}", force: force_refresh) do
278
- URI.open(candidate, read_timeout: 8)&.read
279
- rescue => e
280
- puts "#{candidate}: #{e}" if Gemstar.debug?
281
- nil
282
- 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
+ )
283
300
  end
284
301
 
285
302
  # puts "fetch_changelog_content #{candidate}:\n#{content}" if Gemstar.debug?
@@ -294,13 +311,74 @@ module Gemstar
294
311
  content
295
312
  end
296
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
+
297
373
  VERSION_PATTERNS = [
298
- /^\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
299
- /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/,
300
- /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
301
- /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i,
302
- /^\s*(?:[-*]\s+)?(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])(?:\s*[-(].*)?/i
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
303
380
  ]
381
+ RST_ADORNMENT_PATTERN = /^\s*[=\-~`^"']{3,}\s*$/
304
382
 
305
383
  def parse_changelog_sections(cache_only: false, force_refresh: false)
306
384
  # If the fetched content looks like a GitHub Releases HTML page, return {}
@@ -330,11 +408,13 @@ module Gemstar
330
408
  lines.each do |line|
331
409
  # Convert rdoc to markdown:
332
410
  line = line.gsub(/^=+/) { |m| "#" * m.length }
411
+ next if line.match?(/^\s*\.\.\s+_v?[\w.-]+:/)
412
+ next if line.match?(RST_ADORNMENT_PATTERN)
333
413
 
334
414
  m = VERSION_PATTERNS.lazy.map { |re| line.match(re) }.find(&:itself)
335
415
 
336
416
  if m
337
- new_key = extract_version_from_heading(line) || $1
417
+ new_key = corrected_changelog_version(m[1], line)
338
418
 
339
419
  if current_key
340
420
  sections[current_key] ||= []
@@ -369,27 +449,37 @@ module Gemstar
369
449
 
370
450
  c.lines.each_with_object({}) do |line, dates|
371
451
  line = line.gsub(/^=+/) { |m| "#" * m.length }
372
- next unless VERSION_PATTERNS.any? { |re| line.match?(re) }
452
+ match = VERSION_PATTERNS.lazy.map { |pattern| line.match(pattern) }.find(&:itself)
453
+ next unless match
373
454
 
374
- version = extract_version_from_heading(line)
455
+ version = corrected_changelog_version(match[1], line)
375
456
  date = extract_release_date_from_heading(line)
376
457
  dates[version] ||= date if version && date
377
458
  end
378
459
  end
379
460
 
380
- def parse_github_release_sections(cache_only: false, force_refresh: false)
381
- begin
382
- require "nokogiri"
383
- rescue LoadError
384
- return {}
385
- end
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
386
466
 
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")
389
470
 
390
471
  url = github_releases_url(repo_uri)
391
472
  return {} unless url
392
473
 
474
+ api_sections = parse_github_api_release_sections(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
475
+ return api_sections unless api_sections.empty?
476
+
477
+ begin
478
+ require "nokogiri"
479
+ rescue LoadError
480
+ return {}
481
+ end
482
+
393
483
  html = if cache_only
394
484
  Cache.peek("releases-#{url}")
395
485
  else
@@ -506,11 +596,90 @@ module Gemstar
506
596
  "#{repo}/tags"
507
597
  end
508
598
 
509
- def parse_specific_github_release_pages(repo_uri, version, cache_only:, force_refresh:)
599
+ def github_releases_api_url(repo_uri = @metadata&.repo_uri)
600
+ repo_path = github_repo_path(repo_uri)
601
+ return nil unless repo_path
602
+
603
+ "https://api.github.com/repos/#{repo_path}/releases"
604
+ end
605
+
606
+ def github_release_api_url(repo_uri, tag)
607
+ repo_path = github_repo_path(repo_uri)
608
+ return nil unless repo_path
609
+
610
+ "https://api.github.com/repos/#{repo_path}/releases/tags/#{URI.encode_www_form_component(tag)}"
611
+ end
612
+
613
+ def github_repo_path(repo_uri)
614
+ uri = URI(repo_uri.to_s)
615
+ return nil unless uri.host.to_s.end_with?("github.com")
616
+
617
+ segments = uri.path.to_s.split("/").reject(&:empty?)
618
+ return nil if segments.length < 2
619
+
620
+ segments.take(2).join("/")
621
+ rescue URI::InvalidURIError
622
+ nil
623
+ end
624
+
625
+ def parse_github_api_release_sections(repo_uri, cache_only:, force_refresh:)
626
+ url = github_releases_api_url(repo_uri)
627
+ return {} unless url
628
+
629
+ json = if cache_only
630
+ Cache.peek("releases-api-#{url}")
631
+ else
632
+ Cache.fetch("releases-api-#{url}", force: force_refresh) do
633
+ begin
634
+ URI.open(url, "Accept" => "application/vnd.github+json", read_timeout: 8)&.read
635
+ rescue => e
636
+ puts "#{url}: #{e}" if Gemstar.debug?
637
+ nil
638
+ end
639
+ end
640
+ end
641
+
642
+ parse_github_api_releases(json)
643
+ end
644
+
645
+ def parse_github_api_releases(json)
646
+ releases = JSON.parse(json.to_s)
647
+ releases = [releases] if releases.is_a?(Hash)
648
+ return {} unless releases.is_a?(Array)
649
+
650
+ releases.each_with_object({}) do |release, sections|
651
+ next unless release.is_a?(Hash)
652
+
653
+ tag_name = (release["tag_name"] || release["tagName"]).to_s
654
+ next if tag_name.empty?
655
+ next unless github_tag_matches_metadata?(tag_name)
656
+
657
+ version = normalize_github_tag_version(tag_name)
658
+ next if version.to_s.empty?
659
+
660
+ body = release["body"].to_s.strip
661
+ title = release["name"].to_s.strip
662
+ content = body.empty? ? title : body
663
+ next if content.empty?
664
+
665
+ sections[version] ||= ["## #{version}\n", content]
666
+ end
667
+ rescue JSON::ParserError
668
+ {}
669
+ end
670
+
671
+ def parse_specific_github_release_pages(repo_uri, version, cache_only:, force_refresh:, use_github_cli: false)
510
672
  return {} unless repo_uri&.include?("github.com")
511
673
  return {} if version.to_s.empty?
512
674
 
513
- github_release_tag_urls(repo_uri, version).each do |url|
675
+ github_tag_candidates(version).each do |tag|
676
+ cli_section = parse_specific_github_cli_release(repo_uri, tag, cache_only: cache_only, force_refresh: force_refresh) if use_github_cli
677
+ return cli_section if cli_section && !cli_section.empty?
678
+
679
+ api_section = parse_specific_github_api_release(repo_uri, tag, cache_only: cache_only, force_refresh: force_refresh)
680
+ return api_section unless api_section.empty?
681
+
682
+ url = github_release_tag_url(repo_uri, tag)
514
683
  html = if cache_only
515
684
  Cache.peek("releases-#{url}")
516
685
  else
@@ -533,6 +702,62 @@ module Gemstar
533
702
  {}
534
703
  end
535
704
 
705
+ def parse_specific_github_cli_release(repo_uri, tag_name, cache_only:, force_refresh:)
706
+ repo_path = github_repo_path(repo_uri)
707
+ return {} unless repo_path
708
+
709
+ cache_key = "releases-gh-#{repo_path}-#{tag_name}"
710
+ json = if cache_only
711
+ Cache.peek(cache_key)
712
+ else
713
+ Cache.fetch(cache_key, force: force_refresh) do
714
+ fetch_github_cli_release_json(repo_path, tag_name)
715
+ end
716
+ end
717
+
718
+ parse_github_api_releases(json)
719
+ end
720
+
721
+ def fetch_github_cli_release_json(repo_path, tag_name)
722
+ stdout, _stderr, status = nil
723
+ Timeout.timeout(GITHUB_CLI_TIMEOUT) do
724
+ stdout, _stderr, status = Open3.capture3(
725
+ "gh",
726
+ "release",
727
+ "view",
728
+ tag_name,
729
+ "--repo",
730
+ repo_path,
731
+ "--json",
732
+ "body,name,tagName"
733
+ )
734
+ end
735
+
736
+ status.success? ? stdout : nil
737
+ rescue Errno::ENOENT, Timeout::Error
738
+ nil
739
+ end
740
+
741
+ def parse_specific_github_api_release(repo_uri, tag_name, cache_only:, force_refresh:)
742
+ url = github_release_api_url(repo_uri, URI.decode_www_form_component(tag_name.to_s))
743
+ return {} unless url
744
+
745
+ json = if cache_only
746
+ Cache.peek("releases-api-#{url}")
747
+ else
748
+ Cache.fetch("releases-api-#{url}", force: force_refresh) do
749
+ begin
750
+ URI.open(url, "Accept" => "application/vnd.github+json", read_timeout: 8)&.read
751
+ rescue => e
752
+ puts "#{url}: #{e}" if Gemstar.debug?
753
+ nil
754
+ end
755
+ end
756
+ end
757
+
758
+ parse_github_api_releases(json)
759
+ end
760
+
536
761
  def parse_github_tag_sections(repo_uri, cache_only:, force_refresh:)
537
762
  return {} unless repo_uri&.include?("github.com")
538
763
 
@@ -760,11 +985,15 @@ module Gemstar
760
985
 
761
986
  def github_release_tag_urls(repo_url, version)
762
987
  github_tag_candidates(version).map do |tag|
763
- encoded_tag = URI.encode_www_form_component(tag)
764
- "#{repo_url}/releases/tag/#{encoded_tag}"
988
+ github_release_tag_url(repo_url, tag)
765
989
  end.uniq
766
990
  end
767
991
 
992
+ def github_release_tag_url(repo_url, tag)
993
+ encoded_tag = URI.encode_www_form_component(tag)
994
+ "#{repo_url}/releases/tag/#{encoded_tag}"
995
+ end
996
+
768
997
  def github_tag_candidates(version)
769
998
  return @metadata.github_tag_candidates(version) if @metadata.respond_to?(:github_tag_candidates)
770
999
 
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