gemstar 1.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd80f7bf53281fd8f5b80138f9f564ff8e393ffaec44276168dc020765c6ae7a
4
- data.tar.gz: 3d6b9c2f9a2e0955d6ace8c34100c05390cd9579aa8c8993361799450e36c625
3
+ metadata.gz: aba5f74f93dc292f713a6f5d1a824cc04d7601fffac736f075c75f08ea769ff7
4
+ data.tar.gz: f13a06cba387d9ae4ec744ed6e9703bbe8c1aea95cd8f27ab2f4efd816af33d6
5
5
  SHA512:
6
- metadata.gz: 79d87285b961fdd88c6e898b2016d2bc4cc65b508a5f2c7480f865f0144b12ca5a53be3ff7833d48ed1a66cf4851134a6dbe41fdfc44d807f720e0c63fe52a05
7
- data.tar.gz: 4803b9f217fef1aae5b671fdc5a356229697aba7d5cec4b2f1a05588d63a9ca88c38f876ed4aa1d2579451d6196097818a3d9aa866f8071004c45a9966c66a95
6
+ metadata.gz: e45c7adfbfae8779801141084c0097d57be63415398c1a1b695a090042aa1c8fc6c94764275963aca7ef361de4e63f38f39a172b5158814282cd43ce865f5556
7
+ data.tar.gz: ad082b18db97c9f7a1a1c74de309fad18ca282cdd41c42918b27818b7bcc6da895fc9320660f35a0546028b75ed56726a5a545b7e709f4eb82d9a49237f938f5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Change Log
2
2
 
3
+ ## 1.1.1
4
+
5
+ - Server: Show release dates for changelog entries.
6
+ - Fix GitHub release discovery with direct tag-page fallback and paginated GitHub tags support.
7
+ - Add changelog_uri fallback for GitHub repos with no changelog_uri metadata.
8
+ - Fix change log display for aws, herb, and pagy gems.
9
+
3
10
  ## 1.1
4
11
 
5
12
  - Server: Add **JavaScript package support** to browse change logs for your project's packages. This currently
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  require "cgi"
3
+ require "date"
3
4
  require "json"
5
+ require "time"
4
6
 
5
7
  module Gemstar
6
8
  class ChangeLog
@@ -31,7 +33,7 @@ module Gemstar
31
33
  return @sections if !cache_only && defined?(@sections) && !force_refresh
32
34
 
33
35
  metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
34
- cache_key = "sections-v4-#{metadata_key}"
36
+ cache_key = "sections-v5-#{metadata_key}"
35
37
  serialized = if cache_only
36
38
  Cache.peek(cache_key)
37
39
  else
@@ -82,6 +84,31 @@ module Gemstar
82
84
  result
83
85
  end
84
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
+
85
112
  def compute_sections(force_refresh: false)
86
113
  changelog_sections = parse_changelog_sections(cache_only: false, force_refresh: force_refresh) || {}
87
114
  github_sections = parse_github_release_sections(cache_only: false, force_refresh: force_refresh) || {}
@@ -93,6 +120,23 @@ module Gemstar
93
120
  sections
94
121
  end
95
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
+
96
140
  def decode_sections(serialized)
97
141
  JSON.parse(serialized)
98
142
  rescue JSON::ParserError
@@ -101,9 +145,7 @@ module Gemstar
101
145
 
102
146
  def merge_section_sources(changelog_sections, github_sections)
103
147
  return github_sections if changelog_sections.nil? || changelog_sections.empty?
104
- return changelog_sections if github_sections.nil? || github_sections.empty?
105
-
106
- github_sections.merge(changelog_sections)
148
+ changelog_sections
107
149
  end
108
150
 
109
151
  def extract_relevant_sections(old_version, new_version)
@@ -155,12 +197,22 @@ module Gemstar
155
197
  nil
156
198
  end
157
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
+
158
207
  def changelog_uri_candidates(cache_only: false, force_refresh: false)
159
208
  candidates = []
160
209
 
161
210
  repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
162
211
  return [] if repo_uri.nil? || repo_uri.empty?
163
212
 
213
+ meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
214
+ candidates += changelog_uri_markdown_candidates(meta["changelog_uri"]) if meta
215
+
164
216
  changelog_source = metadata_changelog_source(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
165
217
  return [] unless changelog_source
166
218
 
@@ -169,7 +221,6 @@ module Gemstar
169
221
  end
170
222
 
171
223
  # Add the gem's changelog_uri last as it's usually not the most parsable:
172
- meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
173
224
  candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
174
225
 
175
226
  candidates.flatten!
@@ -179,6 +230,30 @@ module Gemstar
179
230
  candidates
180
231
  end
181
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
+
182
257
  def metadata_changelog_source(repo_uri, cache_only:, force_refresh:)
183
258
  if @metadata.respond_to?(:changelog_source)
184
259
  return @metadata.changelog_source(repo_uri: repo_uri, cache_only: cache_only, force_refresh: force_refresh)
@@ -287,6 +362,21 @@ module Gemstar
287
362
  sections
288
363
  end
289
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
+
290
380
  def parse_github_release_sections(cache_only: false, force_refresh: false)
291
381
  begin
292
382
  require "nokogiri"
@@ -489,6 +579,40 @@ module Gemstar
489
579
  sections
490
580
  end
491
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
+
492
616
  def parse_single_github_tags_page(html, repo_uri)
493
617
  require "nokogiri"
494
618
 
@@ -539,6 +663,75 @@ module Gemstar
539
663
  [{}, nil]
540
664
  end
541
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
+
542
735
  def parse_single_github_release_page(html, version)
543
736
  require "nokogiri"
544
737
 
@@ -8,11 +8,12 @@ module Gemstar
8
8
  uri = URI(url)
9
9
  return url unless uri.host == "github.com"
10
10
 
11
- owner, repo, blob, *rest = uri.path.split("/")[1..]
12
- return url unless blob == "blob"
11
+ owner, repo, view, *rest = uri.path.split("/")[1..]
12
+ return url unless %w[blob tree].include?(view)
13
13
 
14
14
  ref = rest.shift
15
15
  path = rest.join("/")
16
+ return url if path.empty?
16
17
 
17
18
  ref_prefix = ref_is_tag ? "refs/tags/" : ""
18
19
 
@@ -1,6 +1,8 @@
1
1
  require "open-uri"
2
2
  require "uri"
3
3
  require "json"
4
+ require "date"
5
+ require "time"
4
6
  require_relative "remote_repository"
5
7
 
6
8
  module Gemstar
@@ -90,6 +92,28 @@ module Gemstar
90
92
  Gemstar::ChangeLog.new(self).sections(cache_only: cache_only, force_refresh: force_refresh)
91
93
  end
92
94
 
95
+ def registry_release_dates(cache_only: false, force_refresh: false)
96
+ cache_key = "rubygems-versions-#{gem_name}"
97
+ json = if cache_only
98
+ Cache.peek(cache_key)
99
+ else
100
+ url = "https://rubygems.org/api/v1/versions/#{URI.encode_www_form_component(gem_name)}.json"
101
+ Cache.fetch(cache_key, force: force_refresh) do
102
+ URI.open(url, read_timeout: 8).read
103
+ end
104
+ end
105
+
106
+ Array(JSON.parse(json)).each_with_object({}) do |version, dates|
107
+ number = version["number"].to_s
108
+ created_at = version["created_at"].to_s
109
+ next if number.empty? || created_at.empty?
110
+
111
+ dates[number] = format_registry_release_date(created_at)
112
+ end.compact
113
+ rescue JSON::ParserError
114
+ {}
115
+ end
116
+
93
117
  def warm_cache(versions: nil)
94
118
  meta
95
119
  repo_uri
@@ -146,5 +170,17 @@ module Gemstar
146
170
  value.to_s.gsub("{gem_name}", gem_name)
147
171
  end
148
172
 
173
+ def format_registry_release_date(datetime)
174
+ if datetime.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
175
+ date = Date.strptime(datetime.to_s, "%Y-%m-%d")
176
+ return date.strftime("%b #{date.day}, %Y")
177
+ end
178
+
179
+ time = Time.parse(datetime.to_s).utc
180
+ time.strftime("%b #{time.day}, %Y")
181
+ rescue ArgumentError
182
+ nil
183
+ end
184
+
149
185
  end
150
186
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemstar # :nodoc:
4
- VERSION = "1.1"
4
+ VERSION = "1.1.1"
5
5
  def self.debug?
6
6
  return @debug if defined?(@debug)
7
7
  @debug = ENV["GEMSTAR_DEBUG"] == "true"
@@ -2,6 +2,7 @@ require "cgi"
2
2
  require "erb"
3
3
  require "uri"
4
4
  require "kramdown"
5
+ require "nokogiri"
5
6
  require "roda"
6
7
 
7
8
  begin
@@ -13,7 +14,7 @@ module Gemstar
13
14
  module Web
14
15
  class App < Roda
15
16
  MISSING_METADATA = Object.new
16
- CACHE_VERSION = "v6"
17
+ CACHE_VERSION = "v7"
17
18
 
18
19
  class << self
19
20
  def build(projects:, config_home:, cache_warmer: nil)
@@ -837,7 +838,10 @@ module Gemstar
837
838
  <article class="revision-card revision-#{section[:kind]}#{status_class}">
838
839
  <header class="revision-card-header">
839
840
  <div class="revision-card-titlebar">
840
- <h5>#{h(section[:title] || section[:version])}</h5>
841
+ <div class="revision-card-heading">
842
+ <h5>#{h(section[:title] || section[:version])}</h5>
843
+ #{render_release_date(section[:release_date])}
844
+ </div>
841
845
  <div class="revision-card-actions">
842
846
  #{title_links.join}
843
847
  </div>
@@ -850,6 +854,12 @@ module Gemstar
850
854
  HTML
851
855
  end
852
856
 
857
+ def render_release_date(release_date)
858
+ return "" if release_date.to_s.empty?
859
+
860
+ %(<span class="revision-release-date" title="Estimated release date">#{h(release_date)}</span>)
861
+ end
862
+
853
863
  def grouped_change_sections(gem_state)
854
864
  sections = change_sections(gem_state)
855
865
  latest = sections.select { |section| section[:kind] == :future }
@@ -895,6 +905,7 @@ module Gemstar
895
905
  return change_sections_cache[cache_key] = [] if sections.nil? || sections.empty?
896
906
 
897
907
  previous_version = gem_state[:old_version]
908
+ release_dates = resolved_release_dates(metadata, sections.keys, gem_state)
898
909
 
899
910
  rendered_sections = sections.keys.filter_map do |version|
900
911
  kind = section_kind(version, previous_version, current_version, gem_state[:status])
@@ -906,6 +917,7 @@ module Gemstar
906
917
  title: content[:title],
907
918
  kind: kind,
908
919
  previous_version: previous_section_version(sections.keys, version),
920
+ release_date: release_date_for(release_dates, version),
909
921
  html: content[:html]
910
922
  }
911
923
  end
@@ -931,6 +943,38 @@ module Gemstar
931
943
  cached_sections.merge(refreshed_sections || {})
932
944
  end
933
945
 
946
+ def resolved_release_dates(metadata, versions, gem_state)
947
+ changelog = Gemstar::ChangeLog.new(metadata)
948
+ cached_dates = changelog.release_dates(versions: versions, cache_only: true) || {}
949
+ return cached_dates unless selected_gem_missing_release_dates?(gem_state, versions, cached_dates)
950
+
951
+ fetched_dates = changelog.release_dates(versions: versions, cache_only: false) || {}
952
+ cached_dates.merge(fetched_dates)
953
+ rescue StandardError
954
+ {}
955
+ end
956
+
957
+ def release_date_for(release_dates, version)
958
+ release_dates.find do |candidate_version, _date|
959
+ normalize_release_version_key(candidate_version) == normalize_release_version_key(version)
960
+ end&.last
961
+ end
962
+
963
+ def selected_gem_missing_release_dates?(gem_state, versions, release_dates)
964
+ return false unless @selected_gem && gem_state[:name] == @selected_gem[:name]
965
+
966
+ requested_versions = Array(versions).map { |version| normalize_release_version_key(version) }.compact
967
+ dated_versions = release_dates.keys.map { |version| normalize_release_version_key(version) }.compact
968
+ (requested_versions - dated_versions).any?
969
+ end
970
+
971
+ def normalize_release_version_key(version)
972
+ value = version.to_s.strip
973
+ return nil if value.empty?
974
+
975
+ value.sub(/\Av/i, "")
976
+ end
977
+
934
978
  def relevant_package_versions(gem_state, metadata)
935
979
  metadata_hash = metadata_for(gem_state) || {}
936
980
  [
@@ -1034,7 +1078,7 @@ module Gemstar
1034
1078
 
1035
1079
  def strip_leading_version_heading(text, heading_version)
1036
1080
  stripped = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1037
- return strip_leading_hash_separator(stripped) unless stripped == text
1081
+ return strip_leading_heading_separator(stripped) unless stripped == text
1038
1082
 
1039
1083
  lines = text.lines
1040
1084
  return text if lines.empty?
@@ -1048,11 +1092,11 @@ module Gemstar
1048
1092
 
1049
1093
  remaining = lines.drop(1)
1050
1094
  remaining.shift while remaining.first&.strip&.empty?
1051
- strip_leading_hash_separator(remaining.join)
1095
+ strip_leading_heading_separator(remaining.join)
1052
1096
  end
1053
1097
 
1054
- def strip_leading_hash_separator(text)
1055
- text.sub(/\A\s*#{Regexp.escape("#")}{4,}\s*\n+/, "")
1098
+ def strip_leading_heading_separator(text)
1099
+ text.sub(/\A\s*(?:#{Regexp.escape("#")}{4,}|[-=]{3,})\s*\n+/, "")
1056
1100
  end
1057
1101
 
1058
1102
  def compare_versions(left, right)
@@ -566,6 +566,13 @@
566
566
  flex: 0 0 auto;
567
567
  margin-left: auto;
568
568
  }
569
+ .revision-card-heading {
570
+ display: inline-flex;
571
+ align-items: baseline;
572
+ flex-wrap: wrap;
573
+ gap: 0.45rem;
574
+ min-width: 0;
575
+ }
569
576
  .revision-card h5 {
570
577
  margin: 0;
571
578
  font-size: 1.55rem;
@@ -573,6 +580,12 @@
573
580
  color: var(--ink);
574
581
  min-width: 0;
575
582
  }
583
+ .revision-release-date {
584
+ color: var(--grey);
585
+ font-size: 0.86rem;
586
+ font-weight: 600;
587
+ white-space: nowrap;
588
+ }
576
589
  .revision-markup {
577
590
  padding: 0.7rem;
578
591
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstar
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.1'
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Dejako