gemstar 1.0.4 → 1.1.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.
@@ -1,9 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
  require "cgi"
3
+ require "date"
4
+ require "json"
5
+ require "time"
3
6
 
4
7
  module Gemstar
5
8
  class ChangeLog
6
9
  @@candidates_found = Hash.new(0)
10
+ DEFAULT_CHANGELOG_PATHS = %w[
11
+ CHANGELOG.md releases.md CHANGES.md
12
+ Changelog.md changelog.md ChangeLog.md
13
+ Changes.md changes.md
14
+ HISTORY.md History.md history.md
15
+ History CHANGELOG.rdoc
16
+ ].freeze
7
17
 
8
18
  def initialize(metadata)
9
19
  @metadata = metadata
@@ -20,28 +30,122 @@ module Gemstar
20
30
  end
21
31
 
22
32
  def sections(cache_only: false, force_refresh: false)
23
- return @sections if !cache_only && defined?(@sections)
33
+ return @sections if !cache_only && defined?(@sections) && !force_refresh
24
34
 
25
- result = begin
26
- changelog_sections = parse_changelog_sections(cache_only: cache_only, force_refresh: force_refresh) || {}
27
- github_sections = parse_github_release_sections(cache_only: cache_only, force_refresh: force_refresh) || {}
35
+ metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
36
+ cache_key = "sections-v5-#{metadata_key}"
37
+ serialized = if cache_only
38
+ Cache.peek(cache_key)
39
+ else
40
+ Cache.fetch(cache_key, force: force_refresh) do
41
+ JSON.generate(compute_sections(force_refresh: force_refresh))
42
+ end
43
+ end
28
44
 
29
- s = merge_section_sources(changelog_sections, github_sections)
45
+ result = if serialized
46
+ decode_sections(serialized)
47
+ elsif cache_only
48
+ nil
49
+ else
50
+ compute_sections(force_refresh: force_refresh)
51
+ end
30
52
 
31
- pp @@candidates_found if Gemstar.debug? && !cache_only
53
+ @sections = result unless cache_only
54
+ result
55
+ end
56
+
57
+ def sections_for_versions(versions, cache_only: false, force_refresh: false)
58
+ requested_versions = normalize_requested_versions(versions)
59
+ return {} if requested_versions.empty?
32
60
 
33
- s
61
+ cached_sections = sections(cache_only: true) || {}
62
+ result = cached_sections.select { |version, _| requested_versions.include?(normalize_version_key(version)) }
63
+ return result if cache_only
64
+
65
+ changelog_sections = parse_changelog_sections(cache_only: false, force_refresh: force_refresh) || {}
66
+ changelog_sections.each do |version, lines|
67
+ result[version] ||= lines if requested_versions.include?(normalize_version_key(version))
68
+ end
69
+
70
+ repo_uri = @metadata&.repo_uri(cache_only: false, force_refresh: force_refresh)
71
+ if repo_uri&.include?("github.com")
72
+ missing_versions = requested_versions - result.keys.map { |version| normalize_version_key(version) }
73
+ missing_versions.each do |version|
74
+ specific_release = parse_specific_github_release_pages(
75
+ repo_uri,
76
+ version,
77
+ cache_only: false,
78
+ force_refresh: force_refresh
79
+ )
80
+ result.merge!(specific_release) if specific_release
81
+ end
34
82
  end
35
83
 
36
- @sections = result unless cache_only
37
84
  result
38
85
  end
39
86
 
87
+ def release_dates(versions: nil, cache_only: false, force_refresh: false)
88
+ requested_versions = normalize_requested_versions(versions)
89
+ metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
90
+ cache_key = "release-dates-v2-#{metadata_key}"
91
+ serialized = if cache_only
92
+ Cache.peek(cache_key)
93
+ else
94
+ Cache.fetch(cache_key, force: force_refresh) do
95
+ JSON.generate(compute_release_dates(force_refresh: force_refresh))
96
+ end
97
+ end
98
+
99
+ dates = if serialized
100
+ decode_sections(serialized) || {}
101
+ elsif cache_only
102
+ {}
103
+ else
104
+ compute_release_dates(force_refresh: force_refresh)
105
+ end
106
+
107
+ return dates if requested_versions.empty?
108
+
109
+ dates.select { |version, _date| requested_versions.include?(normalize_version_key(version)) }
110
+ end
111
+
112
+ def compute_sections(force_refresh: false)
113
+ changelog_sections = parse_changelog_sections(cache_only: false, force_refresh: force_refresh) || {}
114
+ github_sections = parse_github_release_sections(cache_only: false, force_refresh: force_refresh) || {}
115
+
116
+ sections = merge_section_sources(changelog_sections, github_sections)
117
+
118
+ pp @@candidates_found if Gemstar.debug?
119
+
120
+ sections
121
+ end
122
+
123
+ def compute_release_dates(force_refresh: false)
124
+ registry_dates = if @metadata.respond_to?(:registry_release_dates)
125
+ @metadata.registry_release_dates(cache_only: false, force_refresh: force_refresh)
126
+ else
127
+ {}
128
+ end
129
+ return registry_dates unless registry_dates.nil? || registry_dates.empty?
130
+
131
+ changelog_dates = parse_changelog_release_dates(cache_only: false, force_refresh: force_refresh)
132
+ return changelog_dates unless changelog_dates.nil? || changelog_dates.empty?
133
+
134
+ repo_uri = @metadata&.repo_uri(cache_only: false, force_refresh: force_refresh)
135
+ return {} unless repo_uri&.include?("github.com")
136
+
137
+ parse_github_tag_dates(repo_uri, cache_only: false, force_refresh: force_refresh)
138
+ end
139
+
140
+ def decode_sections(serialized)
141
+ JSON.parse(serialized)
142
+ rescue JSON::ParserError
143
+ nil
144
+ end
145
+
40
146
  def merge_section_sources(changelog_sections, github_sections)
41
147
  return github_sections if changelog_sections.nil? || changelog_sections.empty?
42
- return changelog_sections if github_sections.nil? || github_sections.empty?
43
-
44
- github_sections.merge(changelog_sections)
148
+ changelog_sections
45
149
  end
46
150
 
47
151
  def extract_relevant_sections(old_version, new_version)
@@ -64,6 +168,17 @@ module Gemstar
64
168
 
65
169
  private
66
170
 
171
+ def normalize_requested_versions(versions)
172
+ Array(versions).filter_map { |version| normalize_version_key(version) }.uniq
173
+ end
174
+
175
+ def normalize_version_key(version)
176
+ value = version.to_s.strip
177
+ return nil if value.empty?
178
+
179
+ value.sub(/\Av/i, "")
180
+ end
181
+
67
182
  # Extract a version token from a heading line, preferring explicit version forms
68
183
  # and avoiding returning a date string when both are present.
69
184
  def extract_version_from_heading(line)
@@ -82,40 +197,30 @@ module Gemstar
82
197
  nil
83
198
  end
84
199
 
200
+ def extract_release_date_from_heading(line)
201
+ return nil unless line
202
+
203
+ raw_date = line.to_s[/\b(\d{4}-\d{2}-\d{2})\b/, 1]
204
+ format_release_date(raw_date)
205
+ end
206
+
85
207
  def changelog_uri_candidates(cache_only: false, force_refresh: false)
86
208
  candidates = []
87
209
 
88
210
  repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
89
211
  return [] if repo_uri.nil? || repo_uri.empty?
90
212
 
91
- if repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
92
- base = "https://raw.githubusercontent.com/aws/aws-sdk-ruby/refs/heads/version-3/gems/#{@metadata.gem_name}"
93
- aws_style = true
94
- else
95
- base = repo_uri.sub("https://github.com", "https://raw.githubusercontent.com")
96
- aws_style = false
97
- end
98
-
99
- base = base.chomp("/")
100
-
101
- paths = aws_style ? ["CHANGELOG.md"] : %w[
102
- CHANGELOG.md releases.md CHANGES.md
103
- Changelog.md changelog.md ChangeLog.md
104
- Changes.md changes.md
105
- HISTORY.md History.md history.md
106
- History CHANGELOG.rdoc
107
- ]
108
-
109
- remote_repository = RemoteRepository.new(base)
213
+ meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
214
+ candidates += changelog_uri_markdown_candidates(meta["changelog_uri"]) if meta
110
215
 
111
- branches = aws_style ? [""] : remote_repository.find_main_branch(cache_only: cache_only, force_refresh: force_refresh)
216
+ changelog_source = metadata_changelog_source(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
217
+ return [] unless changelog_source
112
218
 
113
- candidates += paths.product(branches).map do |file, branch|
114
- uri = aws_style ? "#{base}/#{file}" : "#{base}/#{branch}/#{file}"
219
+ candidates += changelog_source[:paths].product(changelog_source[:branches]).map do |file, branch|
220
+ [changelog_source[:base], branch, file].reject { |segment| segment.to_s.empty? }.join("/")
115
221
  end
116
222
 
117
223
  # Add the gem's changelog_uri last as it's usually not the most parsable:
118
- meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
119
224
  candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
120
225
 
121
226
  candidates.flatten!
@@ -125,6 +230,43 @@ module Gemstar
125
230
  candidates
126
231
  end
127
232
 
233
+ def changelog_uri_markdown_candidates(changelog_uri)
234
+ raw_uri = Gemstar::GitHub::github_blob_to_raw(changelog_uri)
235
+ return [] if raw_uri.to_s.empty?
236
+
237
+ candidates = []
238
+ candidates << raw_uri if raw_uri.match?(/\.(?:md|markdown|rdoc|txt)\z/i)
239
+
240
+ begin
241
+ uri = URI(raw_uri)
242
+ path = uri.path.to_s
243
+ if path.end_with?("/")
244
+ uri.path = "#{path.chomp("/")}.md"
245
+ candidates << uri.to_s
246
+ elsif File.extname(path).empty?
247
+ uri.path = "#{path}.md"
248
+ candidates << uri.to_s
249
+ end
250
+ rescue URI::InvalidURIError
251
+ nil
252
+ end
253
+
254
+ candidates
255
+ end
256
+
257
+ def metadata_changelog_source(repo_uri, cache_only:, force_refresh:)
258
+ if @metadata.respond_to?(:changelog_source)
259
+ return @metadata.changelog_source(repo_uri: repo_uri, cache_only: cache_only, force_refresh: force_refresh)
260
+ end
261
+
262
+ base = repo_uri.sub("https://github.com", "https://raw.githubusercontent.com").chomp("/")
263
+ {
264
+ base: base,
265
+ paths: DEFAULT_CHANGELOG_PATHS,
266
+ branches: RemoteRepository.new(base).find_main_branch(cache_only: cache_only, force_refresh: force_refresh)
267
+ }
268
+ end
269
+
128
270
  def fetch_changelog_content(cache_only: false, force_refresh: false)
129
271
  content = nil
130
272
 
@@ -220,6 +362,21 @@ module Gemstar
220
362
  sections
221
363
  end
222
364
 
365
+ def parse_changelog_release_dates(cache_only: false, force_refresh: false)
366
+ c = content(cache_only: cache_only, force_refresh: force_refresh)
367
+ return {} if c.nil? || c.strip.empty?
368
+ return {} if c.include?("<html") || c.include?("<!DOCTYPE html")
369
+
370
+ c.lines.each_with_object({}) do |line, dates|
371
+ line = line.gsub(/^=+/) { |m| "#" * m.length }
372
+ next unless VERSION_PATTERNS.any? { |re| line.match?(re) }
373
+
374
+ version = extract_version_from_heading(line)
375
+ date = extract_release_date_from_heading(line)
376
+ dates[version] ||= date if version && date
377
+ end
378
+ end
379
+
223
380
  def parse_github_release_sections(cache_only: false, force_refresh: false)
224
381
  begin
225
382
  require "nokogiri"
@@ -269,6 +426,7 @@ module Gemstar
269
426
  heading = sec.at_css('h2.sr-only')
270
427
  next unless heading
271
428
  text = heading.text.to_s.strip
429
+ next unless github_tag_matches_metadata?(text)
272
430
  next unless text[/v?(\d[\w.\-]+)/i]
273
431
  version = $1
274
432
 
@@ -292,6 +450,7 @@ module Gemstar
292
450
  link = container.at_xpath('ancestor::*[self::section or self::div][.//a[contains(@href, "/releases/tag/")]][1]//a[contains(@href, "/releases/tag/")]')
293
451
  text = link&.text.to_s
294
452
  text = File.basename(URI(link["href"]).path) if (text.nil? || text.empty?) && link
453
+ next unless github_tag_matches_metadata?(text)
295
454
  next unless text && text[/v?(\d[\w.\-]+)/i]
296
455
  version = $1
297
456
 
@@ -316,6 +475,13 @@ module Gemstar
316
475
  force_refresh: force_refresh
317
476
  )
318
477
  sections = tag_sections.merge(current_release_sections)
478
+ elsif discover_github_tag_sections?
479
+ tag_sections = parse_github_tag_sections(
480
+ repo_uri,
481
+ cache_only: cache_only,
482
+ force_refresh: force_refresh
483
+ )
484
+ sections = tag_sections.merge(sections)
319
485
  end
320
486
 
321
487
  if Gemstar.debug?
@@ -413,6 +579,40 @@ module Gemstar
413
579
  sections
414
580
  end
415
581
 
582
+ def parse_github_tag_dates(repo_uri, cache_only:, force_refresh:)
583
+ return {} unless repo_uri&.include?("github.com")
584
+
585
+ url = github_tags_url(repo_uri)
586
+ return {} unless url
587
+
588
+ dates = {}
589
+ seen_urls = {}
590
+
591
+ while url && !seen_urls[url]
592
+ seen_urls[url] = true
593
+ html = if cache_only
594
+ Cache.peek("tags-#{url}")
595
+ else
596
+ Cache.fetch("tags-#{url}", force: force_refresh) do
597
+ begin
598
+ URI.open(url, read_timeout: 8)&.read
599
+ rescue => e
600
+ puts "#{url}: #{e}" if Gemstar.debug?
601
+ nil
602
+ end
603
+ end
604
+ end
605
+
606
+ break if html.nil? || html.strip.empty?
607
+
608
+ page_dates, next_url = parse_single_github_tag_dates_page(html, repo_uri)
609
+ dates.merge!(page_dates) { |_version, existing, _new| existing }
610
+ url = next_url
611
+ end
612
+
613
+ dates
614
+ end
615
+
416
616
  def parse_single_github_tags_page(html, repo_uri)
417
617
  require "nokogiri"
418
618
 
@@ -436,6 +636,7 @@ module Gemstar
436
636
  href.delete_prefix(tree_prefix)
437
637
  end
438
638
  next if tag_name.to_s.empty?
639
+ next unless github_tag_matches_metadata?(tag_name)
439
640
 
440
641
  version = normalize_github_tag_version(tag_name)
441
642
  next if version.to_s.empty?
@@ -462,6 +663,75 @@ module Gemstar
462
663
  [{}, nil]
463
664
  end
464
665
 
666
+ def parse_single_github_tag_dates_page(html, repo_uri)
667
+ require "nokogiri"
668
+
669
+ doc = begin
670
+ Nokogiri::HTML5(html)
671
+ rescue => _
672
+ Nokogiri::HTML(html)
673
+ end
674
+
675
+ dates = {}
676
+ repo_path = URI(repo_uri).path
677
+ release_prefix = "#{repo_path}/releases/tag/"
678
+ tree_prefix = "#{repo_path}/tree/"
679
+
680
+ doc.css("a[href]").each do |link|
681
+ href = link["href"].to_s
682
+ tag_name =
683
+ if href.start_with?(release_prefix)
684
+ href.delete_prefix(release_prefix)
685
+ elsif href.start_with?(tree_prefix)
686
+ href.delete_prefix(tree_prefix)
687
+ end
688
+ next if tag_name.to_s.empty?
689
+ next unless github_tag_matches_metadata?(tag_name)
690
+
691
+ version = normalize_github_tag_version(tag_name)
692
+ next if version.to_s.empty?
693
+
694
+ datetime = github_tag_datetime_for(link)
695
+ next if datetime.to_s.empty?
696
+
697
+ dates[version] ||= format_release_date(datetime)
698
+ end
699
+
700
+ next_href =
701
+ doc.at_css('a[rel="next"], a.next_page')&.[]("href") ||
702
+ doc.css("a[href]").find do |link|
703
+ href = link["href"].to_s
704
+ text = link.text.to_s.gsub(/\s+/, " ").strip
705
+ href.include?("/tags?after=") && text == "Next"
706
+ end&.[]("href")
707
+ next_url = if next_href && !next_href.empty?
708
+ URI.join(repo_uri, next_href).to_s
709
+ end
710
+
711
+ [dates.compact, next_url]
712
+ rescue LoadError
713
+ [{}, nil]
714
+ end
715
+
716
+ def github_tag_datetime_for(link)
717
+ container = link.at_xpath('ancestor::*[self::li or self::div][.//relative-time or .//time-ago][1]')
718
+ time_node = container&.at_css("relative-time[datetime], time-ago[datetime]") ||
719
+ link.xpath('following::relative-time[@datetime] | following::time-ago[@datetime]').first
720
+ time_node&.[]("datetime")
721
+ end
722
+
723
+ def format_release_date(datetime)
724
+ if datetime.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
725
+ date = Date.strptime(datetime.to_s, "%Y-%m-%d")
726
+ return date.strftime("%b #{date.day}, %Y")
727
+ end
728
+
729
+ time = Time.parse(datetime.to_s).utc
730
+ time.strftime("%b #{time.day}, %Y")
731
+ rescue ArgumentError
732
+ nil
733
+ end
734
+
465
735
  def parse_single_github_release_page(html, version)
466
736
  require "nokogiri"
467
737
 
@@ -490,21 +760,34 @@ module Gemstar
490
760
 
491
761
  def github_release_tag_urls(repo_url, version)
492
762
  github_tag_candidates(version).map do |tag|
493
- "#{repo_url}/releases/tag/#{tag}"
763
+ encoded_tag = URI.encode_www_form_component(tag)
764
+ "#{repo_url}/releases/tag/#{encoded_tag}"
494
765
  end.uniq
495
766
  end
496
767
 
497
768
  def github_tag_candidates(version)
769
+ return @metadata.github_tag_candidates(version) if @metadata.respond_to?(:github_tag_candidates)
770
+
498
771
  raw = version.to_s
499
772
  [raw, (raw.start_with?("v") ? raw : "v#{raw}")].uniq
500
773
  end
501
774
 
502
775
  def normalize_github_tag_version(tag_name)
503
776
  decoded = URI.decode_www_form_component(tag_name.to_s.split("?").first.to_s)
504
- match = decoded.match(/\Av?(\d[\w.\-]*)\z/i)
777
+ match = decoded.match(/\A(?:.+@)?v?(\d[\w.\-]*)\z/i)
505
778
  match && match[1]
506
779
  end
507
780
 
781
+ def github_tag_matches_metadata?(tag_name)
782
+ return @metadata.github_tag_matches?(tag_name) if @metadata.respond_to?(:github_tag_matches?)
783
+
784
+ true
785
+ end
786
+
787
+ def discover_github_tag_sections?
788
+ @metadata.respond_to?(:discover_github_tag_sections?) && @metadata.discover_github_tag_sections?
789
+ end
790
+
508
791
  def prefer_github_releases_first?(cache_only:, force_refresh:)
509
792
  meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
510
793
  repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
data/lib/gemstar/cli.rb CHANGED
@@ -13,6 +13,9 @@ module Gemstar
13
13
  desc "diff", "Show changelogs for updated gems"
14
14
  method_option :from, type: :string, desc: "Git ref or lockfile"
15
15
  method_option :to, type: :string, desc: "Git ref or lockfile"
16
+ method_option :since, type: :string, desc: "Set --from to the latest commit before this relative time (for example: '3 weeks')"
17
+ 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)"
16
19
  method_option :format, type: :string, desc: "Output format (html or markdown)"
17
20
  method_option :output_file, type: :string, desc: "Output file path"
18
21
  method_option :debug_gem_regex, type: :string, desc: "Debug matching gems", hide: true
@@ -22,9 +25,10 @@ module Gemstar
22
25
 
23
26
  desc "server", "Start the interactive web server"
24
27
  method_option :bind, type: :string, default: "127.0.0.1", desc: "Bind address"
25
- method_option :port, type: :numeric, default: 2112, desc: "Port"
28
+ method_option :port, type: :numeric, desc: "Port (default: 2112; auto-increments when omitted)"
26
29
  method_option :project, type: :string, repeatable: true, desc: "Project directories or Gemfile paths"
27
30
  method_option :reload, type: :boolean, default: false, desc: "Restart automatically when files change"
31
+ method_option :open, type: :boolean, default: false, desc: "Open the server root in a browser after startup"
28
32
  def server
29
33
  Gemstar::Commands::Server.new(options).run
30
34
  end