gemstar 1.1 → 1.2

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: c6157dce9498afdbda3d014af6e1e4e20e7477d73bd3b12129c0d754df28f4f7
4
+ data.tar.gz: c7fcc69688c21fea79ead9b9f762d026a5b57e5c597206171fe6faaeb5555db1
5
5
  SHA512:
6
- metadata.gz: 79d87285b961fdd88c6e898b2016d2bc4cc65b508a5f2c7480f865f0144b12ca5a53be3ff7833d48ed1a66cf4851134a6dbe41fdfc44d807f720e0c63fe52a05
7
- data.tar.gz: 4803b9f217fef1aae5b671fdc5a356229697aba7d5cec4b2f1a05588d63a9ca88c38f876ed4aa1d2579451d6196097818a3d9aa866f8071004c45a9966c66a95
6
+ metadata.gz: 89b29cc2ff749b326e1e8dfd2cc98279b45d05a961b1d1aab8ea0cfcc9e4c2acab238fbaf40c4b947b0c9201a6254a80beb5102aa8c28c348d1afde67ced305a
7
+ data.tar.gz: 55182c82a720bd6a5baf9ab26e7369df6c834c24ae959b05301a40da16ac0e447a6ada98ac2461064ba137f9e944191026c48084d1ec2f95e923c0cda6b19265
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Change Log
2
2
 
3
+ ## 1.2
4
+
5
+ - Server: Cache warmer now pre-fetches package detail pages so browsing warmed gems is faster.
6
+ - Server: Manual browser reloads now refresh the selected package details, including metadata and release notes caches.
7
+ - Server: Fetch GitHub release bodies through the GitHub Releases API before falling back to HTML scraping.
8
+ - Server: Add explicit "Use GitHub CLI" fallback for GitHub-backed release notes when automatic release discovery misses a page.
9
+
10
+ ## 1.1.1
11
+
12
+ - Server: Show release dates for changelog entries.
13
+ - Fix GitHub release discovery with direct tag-page fallback and paginated GitHub tags support.
14
+ - Add changelog_uri fallback for GitHub repos with no changelog_uri metadata.
15
+ - Fix change log display for aws, herb, and pagy gems.
16
+
3
17
  ## 1.1
4
18
 
5
19
  - Server: Add **JavaScript package support** to browse change logs for your project's packages. This currently
@@ -5,10 +5,11 @@ module Gemstar
5
5
  class CacheWarmer
6
6
  DEFAULT_THREADS = 10
7
7
 
8
- def initialize(io: $stderr, debug: false, thread_count: DEFAULT_THREADS)
8
+ def initialize(io: $stderr, debug: false, thread_count: DEFAULT_THREADS, detail_cache_fetcher: nil)
9
9
  @io = io
10
10
  @debug = debug
11
11
  @thread_count = thread_count
12
+ @detail_cache_fetcher = detail_cache_fetcher
12
13
  @mutex = Mutex.new
13
14
  @condition = ConditionVariable.new
14
15
  @queue = []
@@ -21,6 +22,8 @@ module Gemstar
21
22
  @completed_count = 0
22
23
  end
23
24
 
25
+ attr_writer :detail_cache_fetcher
26
+
24
27
  def enqueue_many(package_states)
25
28
  states = normalize_package_states(package_states)
26
29
 
@@ -120,13 +123,22 @@ module Gemstar
120
123
 
121
124
  def warm_cache_for_package(package_state)
122
125
  metadata = metadata_adapter_for(package_state)
123
- return unless metadata
126
+ metadata&.warm_cache(versions: package_versions(package_state))
124
127
 
125
- metadata.warm_cache(versions: package_versions(package_state))
128
+ warm_detail_cache_for_package(package_state)
126
129
  rescue StandardError => e
127
130
  log "Cache refresh failed for #{package_label(package_state)}: #{e.class}: #{e.message}"
128
131
  end
129
132
 
133
+ def warm_detail_cache_for_package(package_state)
134
+ fetcher = @detail_cache_fetcher
135
+ return unless fetcher
136
+
137
+ Array(package_state[:detail_cache_contexts]).each do |context|
138
+ fetcher.call(package_state, context)
139
+ end
140
+ end
141
+
130
142
  def log_progress(package_name, current)
131
143
  return unless @debug
132
144
  return unless current <= 5 || (current % 25).zero?
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
  require "cgi"
3
+ require "date"
3
4
  require "json"
5
+ require "open3"
6
+ require "time"
7
+ require "timeout"
8
+ require "uri"
9
+ require_relative "cache"
4
10
 
5
11
  module Gemstar
6
12
  class ChangeLog
7
13
  @@candidates_found = Hash.new(0)
14
+ GITHUB_CLI_TIMEOUT = 8
8
15
  DEFAULT_CHANGELOG_PATHS = %w[
9
16
  CHANGELOG.md releases.md CHANGES.md
10
17
  Changelog.md changelog.md ChangeLog.md
@@ -31,7 +38,7 @@ module Gemstar
31
38
  return @sections if !cache_only && defined?(@sections) && !force_refresh
32
39
 
33
40
  metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
34
- cache_key = "sections-v4-#{metadata_key}"
41
+ cache_key = "sections-v5-#{metadata_key}"
35
42
  serialized = if cache_only
36
43
  Cache.peek(cache_key)
37
44
  else
@@ -52,7 +59,7 @@ module Gemstar
52
59
  result
53
60
  end
54
61
 
55
- def sections_for_versions(versions, cache_only: false, force_refresh: false)
62
+ def sections_for_versions(versions, cache_only: false, force_refresh: false, use_github_cli: false)
56
63
  requested_versions = normalize_requested_versions(versions)
57
64
  return {} if requested_versions.empty?
58
65
 
@@ -73,7 +80,8 @@ module Gemstar
73
80
  repo_uri,
74
81
  version,
75
82
  cache_only: false,
76
- force_refresh: force_refresh
83
+ force_refresh: force_refresh,
84
+ use_github_cli: use_github_cli
77
85
  )
78
86
  result.merge!(specific_release) if specific_release
79
87
  end
@@ -82,6 +90,31 @@ module Gemstar
82
90
  result
83
91
  end
84
92
 
93
+ def release_dates(versions: nil, cache_only: false, force_refresh: false)
94
+ requested_versions = normalize_requested_versions(versions)
95
+ metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
96
+ cache_key = "release-dates-v2-#{metadata_key}"
97
+ serialized = if cache_only
98
+ Cache.peek(cache_key)
99
+ else
100
+ Cache.fetch(cache_key, force: force_refresh) do
101
+ JSON.generate(compute_release_dates(force_refresh: force_refresh))
102
+ end
103
+ end
104
+
105
+ dates = if serialized
106
+ decode_sections(serialized) || {}
107
+ elsif cache_only
108
+ {}
109
+ else
110
+ compute_release_dates(force_refresh: force_refresh)
111
+ end
112
+
113
+ return dates if requested_versions.empty?
114
+
115
+ dates.select { |version, _date| requested_versions.include?(normalize_version_key(version)) }
116
+ end
117
+
85
118
  def compute_sections(force_refresh: false)
86
119
  changelog_sections = parse_changelog_sections(cache_only: false, force_refresh: force_refresh) || {}
87
120
  github_sections = parse_github_release_sections(cache_only: false, force_refresh: force_refresh) || {}
@@ -93,6 +126,23 @@ module Gemstar
93
126
  sections
94
127
  end
95
128
 
129
+ def compute_release_dates(force_refresh: false)
130
+ registry_dates = if @metadata.respond_to?(:registry_release_dates)
131
+ @metadata.registry_release_dates(cache_only: false, force_refresh: force_refresh)
132
+ else
133
+ {}
134
+ end
135
+ return registry_dates unless registry_dates.nil? || registry_dates.empty?
136
+
137
+ changelog_dates = parse_changelog_release_dates(cache_only: false, force_refresh: force_refresh)
138
+ return changelog_dates unless changelog_dates.nil? || changelog_dates.empty?
139
+
140
+ repo_uri = @metadata&.repo_uri(cache_only: false, force_refresh: force_refresh)
141
+ return {} unless repo_uri&.include?("github.com")
142
+
143
+ parse_github_tag_dates(repo_uri, cache_only: false, force_refresh: force_refresh)
144
+ end
145
+
96
146
  def decode_sections(serialized)
97
147
  JSON.parse(serialized)
98
148
  rescue JSON::ParserError
@@ -101,9 +151,7 @@ module Gemstar
101
151
 
102
152
  def merge_section_sources(changelog_sections, github_sections)
103
153
  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)
154
+ changelog_sections
107
155
  end
108
156
 
109
157
  def extract_relevant_sections(old_version, new_version)
@@ -155,12 +203,22 @@ module Gemstar
155
203
  nil
156
204
  end
157
205
 
206
+ def extract_release_date_from_heading(line)
207
+ return nil unless line
208
+
209
+ raw_date = line.to_s[/\b(\d{4}-\d{2}-\d{2})\b/, 1]
210
+ format_release_date(raw_date)
211
+ end
212
+
158
213
  def changelog_uri_candidates(cache_only: false, force_refresh: false)
159
214
  candidates = []
160
215
 
161
216
  repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
162
217
  return [] if repo_uri.nil? || repo_uri.empty?
163
218
 
219
+ meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
220
+ candidates += changelog_uri_markdown_candidates(meta["changelog_uri"]) if meta
221
+
164
222
  changelog_source = metadata_changelog_source(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
165
223
  return [] unless changelog_source
166
224
 
@@ -169,7 +227,6 @@ module Gemstar
169
227
  end
170
228
 
171
229
  # 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
230
  candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
174
231
 
175
232
  candidates.flatten!
@@ -179,6 +236,30 @@ module Gemstar
179
236
  candidates
180
237
  end
181
238
 
239
+ def changelog_uri_markdown_candidates(changelog_uri)
240
+ raw_uri = Gemstar::GitHub::github_blob_to_raw(changelog_uri)
241
+ return [] if raw_uri.to_s.empty?
242
+
243
+ candidates = []
244
+ candidates << raw_uri if raw_uri.match?(/\.(?:md|markdown|rdoc|txt)\z/i)
245
+
246
+ begin
247
+ uri = URI(raw_uri)
248
+ path = uri.path.to_s
249
+ if path.end_with?("/")
250
+ uri.path = "#{path.chomp("/")}.md"
251
+ candidates << uri.to_s
252
+ elsif File.extname(path).empty?
253
+ uri.path = "#{path}.md"
254
+ candidates << uri.to_s
255
+ end
256
+ rescue URI::InvalidURIError
257
+ nil
258
+ end
259
+
260
+ candidates
261
+ end
262
+
182
263
  def metadata_changelog_source(repo_uri, cache_only:, force_refresh:)
183
264
  if @metadata.respond_to?(:changelog_source)
184
265
  return @metadata.changelog_source(repo_uri: repo_uri, cache_only: cache_only, force_refresh: force_refresh)
@@ -287,19 +368,37 @@ module Gemstar
287
368
  sections
288
369
  end
289
370
 
290
- def parse_github_release_sections(cache_only: false, force_refresh: false)
291
- begin
292
- require "nokogiri"
293
- rescue LoadError
294
- return {}
371
+ def parse_changelog_release_dates(cache_only: false, force_refresh: false)
372
+ c = content(cache_only: cache_only, force_refresh: force_refresh)
373
+ return {} if c.nil? || c.strip.empty?
374
+ return {} if c.include?("<html") || c.include?("<!DOCTYPE html")
375
+
376
+ c.lines.each_with_object({}) do |line, dates|
377
+ line = line.gsub(/^=+/) { |m| "#" * m.length }
378
+ next unless VERSION_PATTERNS.any? { |re| line.match?(re) }
379
+
380
+ version = extract_version_from_heading(line)
381
+ date = extract_release_date_from_heading(line)
382
+ dates[version] ||= date if version && date
295
383
  end
384
+ end
296
385
 
386
+ def parse_github_release_sections(cache_only: false, force_refresh: false)
297
387
  repo_uri = @metadata&.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
298
388
  return {} unless repo_uri&.include?("github.com")
299
389
 
300
390
  url = github_releases_url(repo_uri)
301
391
  return {} unless url
302
392
 
393
+ api_sections = parse_github_api_release_sections(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
394
+ return api_sections unless api_sections.empty?
395
+
396
+ begin
397
+ require "nokogiri"
398
+ rescue LoadError
399
+ return {}
400
+ end
401
+
303
402
  html = if cache_only
304
403
  Cache.peek("releases-#{url}")
305
404
  else
@@ -416,11 +515,90 @@ module Gemstar
416
515
  "#{repo}/tags"
417
516
  end
418
517
 
419
- def parse_specific_github_release_pages(repo_uri, version, cache_only:, force_refresh:)
518
+ def github_releases_api_url(repo_uri = @metadata&.repo_uri)
519
+ repo_path = github_repo_path(repo_uri)
520
+ return nil unless repo_path
521
+
522
+ "https://api.github.com/repos/#{repo_path}/releases"
523
+ end
524
+
525
+ def github_release_api_url(repo_uri, tag)
526
+ repo_path = github_repo_path(repo_uri)
527
+ return nil unless repo_path
528
+
529
+ "https://api.github.com/repos/#{repo_path}/releases/tags/#{URI.encode_www_form_component(tag)}"
530
+ end
531
+
532
+ def github_repo_path(repo_uri)
533
+ uri = URI(repo_uri.to_s)
534
+ return nil unless uri.host.to_s.end_with?("github.com")
535
+
536
+ segments = uri.path.to_s.split("/").reject(&:empty?)
537
+ return nil if segments.length < 2
538
+
539
+ segments.take(2).join("/")
540
+ rescue URI::InvalidURIError
541
+ nil
542
+ end
543
+
544
+ def parse_github_api_release_sections(repo_uri, cache_only:, force_refresh:)
545
+ url = github_releases_api_url(repo_uri)
546
+ return {} unless url
547
+
548
+ json = if cache_only
549
+ Cache.peek("releases-api-#{url}")
550
+ else
551
+ Cache.fetch("releases-api-#{url}", force: force_refresh) do
552
+ begin
553
+ URI.open(url, "Accept" => "application/vnd.github+json", read_timeout: 8)&.read
554
+ rescue => e
555
+ puts "#{url}: #{e}" if Gemstar.debug?
556
+ nil
557
+ end
558
+ end
559
+ end
560
+
561
+ parse_github_api_releases(json)
562
+ end
563
+
564
+ def parse_github_api_releases(json)
565
+ releases = JSON.parse(json.to_s)
566
+ releases = [releases] if releases.is_a?(Hash)
567
+ return {} unless releases.is_a?(Array)
568
+
569
+ releases.each_with_object({}) do |release, sections|
570
+ next unless release.is_a?(Hash)
571
+
572
+ tag_name = (release["tag_name"] || release["tagName"]).to_s
573
+ next if tag_name.empty?
574
+ next unless github_tag_matches_metadata?(tag_name)
575
+
576
+ version = normalize_github_tag_version(tag_name)
577
+ next if version.to_s.empty?
578
+
579
+ body = release["body"].to_s.strip
580
+ title = release["name"].to_s.strip
581
+ content = body.empty? ? title : body
582
+ next if content.empty?
583
+
584
+ sections[version] ||= ["## #{version}\n", content]
585
+ end
586
+ rescue JSON::ParserError
587
+ {}
588
+ end
589
+
590
+ def parse_specific_github_release_pages(repo_uri, version, cache_only:, force_refresh:, use_github_cli: false)
420
591
  return {} unless repo_uri&.include?("github.com")
421
592
  return {} if version.to_s.empty?
422
593
 
423
- github_release_tag_urls(repo_uri, version).each do |url|
594
+ github_tag_candidates(version).each do |tag|
595
+ cli_section = parse_specific_github_cli_release(repo_uri, tag, cache_only: cache_only, force_refresh: force_refresh) if use_github_cli
596
+ return cli_section if cli_section && !cli_section.empty?
597
+
598
+ api_section = parse_specific_github_api_release(repo_uri, tag, cache_only: cache_only, force_refresh: force_refresh)
599
+ return api_section unless api_section.empty?
600
+
601
+ url = github_release_tag_url(repo_uri, tag)
424
602
  html = if cache_only
425
603
  Cache.peek("releases-#{url}")
426
604
  else
@@ -443,6 +621,62 @@ module Gemstar
443
621
  {}
444
622
  end
445
623
 
624
+ def parse_specific_github_cli_release(repo_uri, tag_name, cache_only:, force_refresh:)
625
+ repo_path = github_repo_path(repo_uri)
626
+ return {} unless repo_path
627
+
628
+ cache_key = "releases-gh-#{repo_path}-#{tag_name}"
629
+ json = if cache_only
630
+ Cache.peek(cache_key)
631
+ else
632
+ Cache.fetch(cache_key, force: force_refresh) do
633
+ fetch_github_cli_release_json(repo_path, tag_name)
634
+ end
635
+ end
636
+
637
+ parse_github_api_releases(json)
638
+ end
639
+
640
+ def fetch_github_cli_release_json(repo_path, tag_name)
641
+ stdout, _stderr, status = nil
642
+ Timeout.timeout(GITHUB_CLI_TIMEOUT) do
643
+ stdout, _stderr, status = Open3.capture3(
644
+ "gh",
645
+ "release",
646
+ "view",
647
+ tag_name,
648
+ "--repo",
649
+ repo_path,
650
+ "--json",
651
+ "body,name,tagName"
652
+ )
653
+ end
654
+
655
+ status.success? ? stdout : nil
656
+ rescue Errno::ENOENT, Timeout::Error
657
+ nil
658
+ end
659
+
660
+ def parse_specific_github_api_release(repo_uri, tag_name, cache_only:, force_refresh:)
661
+ url = github_release_api_url(repo_uri, URI.decode_www_form_component(tag_name.to_s))
662
+ return {} unless url
663
+
664
+ json = if cache_only
665
+ Cache.peek("releases-api-#{url}")
666
+ else
667
+ Cache.fetch("releases-api-#{url}", force: force_refresh) do
668
+ begin
669
+ URI.open(url, "Accept" => "application/vnd.github+json", read_timeout: 8)&.read
670
+ rescue => e
671
+ puts "#{url}: #{e}" if Gemstar.debug?
672
+ nil
673
+ end
674
+ end
675
+ end
676
+
677
+ parse_github_api_releases(json)
678
+ end
679
+
446
680
  def parse_github_tag_sections(repo_uri, cache_only:, force_refresh:)
447
681
  return {} unless repo_uri&.include?("github.com")
448
682
 
@@ -489,6 +723,40 @@ module Gemstar
489
723
  sections
490
724
  end
491
725
 
726
+ def parse_github_tag_dates(repo_uri, cache_only:, force_refresh:)
727
+ return {} unless repo_uri&.include?("github.com")
728
+
729
+ url = github_tags_url(repo_uri)
730
+ return {} unless url
731
+
732
+ dates = {}
733
+ seen_urls = {}
734
+
735
+ while url && !seen_urls[url]
736
+ seen_urls[url] = true
737
+ html = if cache_only
738
+ Cache.peek("tags-#{url}")
739
+ else
740
+ Cache.fetch("tags-#{url}", force: force_refresh) do
741
+ begin
742
+ URI.open(url, read_timeout: 8)&.read
743
+ rescue => e
744
+ puts "#{url}: #{e}" if Gemstar.debug?
745
+ nil
746
+ end
747
+ end
748
+ end
749
+
750
+ break if html.nil? || html.strip.empty?
751
+
752
+ page_dates, next_url = parse_single_github_tag_dates_page(html, repo_uri)
753
+ dates.merge!(page_dates) { |_version, existing, _new| existing }
754
+ url = next_url
755
+ end
756
+
757
+ dates
758
+ end
759
+
492
760
  def parse_single_github_tags_page(html, repo_uri)
493
761
  require "nokogiri"
494
762
 
@@ -539,6 +807,75 @@ module Gemstar
539
807
  [{}, nil]
540
808
  end
541
809
 
810
+ def parse_single_github_tag_dates_page(html, repo_uri)
811
+ require "nokogiri"
812
+
813
+ doc = begin
814
+ Nokogiri::HTML5(html)
815
+ rescue => _
816
+ Nokogiri::HTML(html)
817
+ end
818
+
819
+ dates = {}
820
+ repo_path = URI(repo_uri).path
821
+ release_prefix = "#{repo_path}/releases/tag/"
822
+ tree_prefix = "#{repo_path}/tree/"
823
+
824
+ doc.css("a[href]").each do |link|
825
+ href = link["href"].to_s
826
+ tag_name =
827
+ if href.start_with?(release_prefix)
828
+ href.delete_prefix(release_prefix)
829
+ elsif href.start_with?(tree_prefix)
830
+ href.delete_prefix(tree_prefix)
831
+ end
832
+ next if tag_name.to_s.empty?
833
+ next unless github_tag_matches_metadata?(tag_name)
834
+
835
+ version = normalize_github_tag_version(tag_name)
836
+ next if version.to_s.empty?
837
+
838
+ datetime = github_tag_datetime_for(link)
839
+ next if datetime.to_s.empty?
840
+
841
+ dates[version] ||= format_release_date(datetime)
842
+ end
843
+
844
+ next_href =
845
+ doc.at_css('a[rel="next"], a.next_page')&.[]("href") ||
846
+ doc.css("a[href]").find do |link|
847
+ href = link["href"].to_s
848
+ text = link.text.to_s.gsub(/\s+/, " ").strip
849
+ href.include?("/tags?after=") && text == "Next"
850
+ end&.[]("href")
851
+ next_url = if next_href && !next_href.empty?
852
+ URI.join(repo_uri, next_href).to_s
853
+ end
854
+
855
+ [dates.compact, next_url]
856
+ rescue LoadError
857
+ [{}, nil]
858
+ end
859
+
860
+ def github_tag_datetime_for(link)
861
+ container = link.at_xpath('ancestor::*[self::li or self::div][.//relative-time or .//time-ago][1]')
862
+ time_node = container&.at_css("relative-time[datetime], time-ago[datetime]") ||
863
+ link.xpath('following::relative-time[@datetime] | following::time-ago[@datetime]').first
864
+ time_node&.[]("datetime")
865
+ end
866
+
867
+ def format_release_date(datetime)
868
+ if datetime.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
869
+ date = Date.strptime(datetime.to_s, "%Y-%m-%d")
870
+ return date.strftime("%b #{date.day}, %Y")
871
+ end
872
+
873
+ time = Time.parse(datetime.to_s).utc
874
+ time.strftime("%b #{time.day}, %Y")
875
+ rescue ArgumentError
876
+ nil
877
+ end
878
+
542
879
  def parse_single_github_release_page(html, version)
543
880
  require "nokogiri"
544
881
 
@@ -567,11 +904,15 @@ module Gemstar
567
904
 
568
905
  def github_release_tag_urls(repo_url, version)
569
906
  github_tag_candidates(version).map do |tag|
570
- encoded_tag = URI.encode_www_form_component(tag)
571
- "#{repo_url}/releases/tag/#{encoded_tag}"
907
+ github_release_tag_url(repo_url, tag)
572
908
  end.uniq
573
909
  end
574
910
 
911
+ def github_release_tag_url(repo_url, tag)
912
+ encoded_tag = URI.encode_www_form_component(tag)
913
+ "#{repo_url}/releases/tag/#{encoded_tag}"
914
+ end
915
+
575
916
  def github_tag_candidates(version)
576
917
  return @metadata.github_tag_candidates(version) if @metadata.respond_to?(:github_tag_candidates)
577
918
 
@@ -1,6 +1,8 @@
1
1
  require_relative "command"
2
+ require "rack/mock"
2
3
  require "socket"
3
4
  require "shellwords"
5
+ require "uri"
4
6
  require "rbconfig"
5
7
 
6
8
  module Gemstar
@@ -45,7 +47,9 @@ module Gemstar
45
47
  projects = load_projects
46
48
  log_loaded_projects(projects)
47
49
  cache_warmer = build_cache_warmer
48
- app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory, cache_warmer: cache_warmer)
50
+ web_app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory, cache_warmer: cache_warmer)
51
+ cache_warmer.detail_cache_fetcher = build_detail_cache_fetcher(web_app)
52
+ app = web_app
49
53
  app = Gemstar::RequestLogger.new(app, io: $stderr) if debug_request_logging?
50
54
 
51
55
  puts "Gemstar server listening on http://#{bind}:#{port}"
@@ -183,8 +187,21 @@ module Gemstar
183
187
  end
184
188
 
185
189
  def start_background_cache_refresh(projects, cache_warmer)
186
- package_states = projects.flat_map do |project|
187
- project.gem_states(from_revision_id: "worktree", to_revision_id: "worktree")
190
+ package_states = projects.flat_map.with_index do |project, project_index|
191
+ from_revision_id = project.default_from_revision_id
192
+ to_revision_id = "worktree"
193
+ states = project.gem_states(from_revision_id: from_revision_id, to_revision_id: to_revision_id)
194
+ detail_cache_contexts = detail_cache_contexts_for(
195
+ project: project,
196
+ project_index: project_index,
197
+ from_revision_id: from_revision_id,
198
+ to_revision_id: to_revision_id,
199
+ package_states: states
200
+ )
201
+
202
+ states.map do |state|
203
+ state.merge(detail_cache_contexts: detail_cache_contexts_for_package(state, detail_cache_contexts))
204
+ end
188
205
  end
189
206
 
190
207
  return nil if package_states.empty?
@@ -192,6 +209,53 @@ module Gemstar
192
209
  cache_warmer.enqueue_many(package_states)
193
210
  end
194
211
 
212
+ def detail_cache_contexts_for(project:, project_index:, from_revision_id:, to_revision_id:, package_states:)
213
+ default_filter = package_states.any? { |state| state[:status] != :unchanged } ? "updated" : "all"
214
+ default_scope = project.package_scope_options.map { |option| option[:id] } == ["gems"] ? "gems" : "all"
215
+
216
+ [
217
+ {
218
+ project: project_index,
219
+ from: from_revision_id,
220
+ to: to_revision_id,
221
+ filter: default_filter,
222
+ scope: default_scope
223
+ },
224
+ {
225
+ project: project_index,
226
+ from: from_revision_id,
227
+ to: to_revision_id,
228
+ filter: "all",
229
+ scope: default_scope
230
+ }
231
+ ].uniq
232
+ end
233
+
234
+ def detail_cache_contexts_for_package(package_state, base_contexts)
235
+ package_scope = package_state[:package_scope]
236
+
237
+ base_contexts.flat_map do |context|
238
+ [
239
+ context,
240
+ context.merge(scope: package_scope)
241
+ ]
242
+ end.uniq
243
+ end
244
+
245
+ def build_detail_cache_fetcher(app)
246
+ lambda do |package_state, context|
247
+ params = context.merge(package: package_state[:name])
248
+ env = Rack::MockRequest.env_for(
249
+ "/detail?#{URI.encode_www_form(params)}",
250
+ "REQUEST_METHOD" => "GET",
251
+ "gemstar.detail_cache_warm" => true
252
+ )
253
+ response = app.call(env)
254
+ body = response[2]
255
+ body.close if body.respond_to?(:close)
256
+ end
257
+ end
258
+
195
259
  def server_start_callback(projects, cache_warmer)
196
260
  proc do
197
261
  Thread.new do
@@ -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
 
@@ -64,13 +64,13 @@ module Gemstar
64
64
  repo
65
65
  end
66
66
 
67
- def changelog_sections(versions: nil, cache_only: false, force_refresh: false)
67
+ def changelog_sections(versions: nil, cache_only: false, force_refresh: false, use_github_cli: false)
68
68
  requested_versions = Array(versions).compact
69
69
  changelog = Gemstar::ChangeLog.new(self)
70
70
  if requested_versions.empty?
71
71
  changelog.sections(cache_only: cache_only, force_refresh: force_refresh)
72
72
  else
73
- changelog.sections_for_versions(requested_versions, cache_only: cache_only, force_refresh: force_refresh)
73
+ changelog.sections_for_versions(requested_versions, cache_only: cache_only, force_refresh: force_refresh, use_github_cli: use_github_cli)
74
74
  end
75
75
  end
76
76
 
@@ -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
@@ -86,8 +88,35 @@ module Gemstar
86
88
  repo
87
89
  end
88
90
 
89
- def changelog_sections(versions: nil, cache_only: false, force_refresh: false)
90
- Gemstar::ChangeLog.new(self).sections(cache_only: cache_only, force_refresh: force_refresh)
91
+ def changelog_sections(versions: nil, cache_only: false, force_refresh: false, use_github_cli: false)
92
+ changelog = Gemstar::ChangeLog.new(self)
93
+ if use_github_cli && Array(versions).compact.any?
94
+ changelog.sections_for_versions(versions, cache_only: cache_only, force_refresh: force_refresh, use_github_cli: true)
95
+ else
96
+ changelog.sections(cache_only: cache_only, force_refresh: force_refresh)
97
+ end
98
+ end
99
+
100
+ def registry_release_dates(cache_only: false, force_refresh: false)
101
+ cache_key = "rubygems-versions-#{gem_name}"
102
+ json = if cache_only
103
+ Cache.peek(cache_key)
104
+ else
105
+ url = "https://rubygems.org/api/v1/versions/#{URI.encode_www_form_component(gem_name)}.json"
106
+ Cache.fetch(cache_key, force: force_refresh) do
107
+ URI.open(url, read_timeout: 8).read
108
+ end
109
+ end
110
+
111
+ Array(JSON.parse(json)).each_with_object({}) do |version, dates|
112
+ number = version["number"].to_s
113
+ created_at = version["created_at"].to_s
114
+ next if number.empty? || created_at.empty?
115
+
116
+ dates[number] = format_registry_release_date(created_at)
117
+ end.compact
118
+ rescue JSON::ParserError
119
+ {}
91
120
  end
92
121
 
93
122
  def warm_cache(versions: nil)
@@ -146,5 +175,17 @@ module Gemstar
146
175
  value.to_s.gsub("{gem_name}", gem_name)
147
176
  end
148
177
 
178
+ def format_registry_release_date(datetime)
179
+ if datetime.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
180
+ date = Date.strptime(datetime.to_s, "%Y-%m-%d")
181
+ return date.strftime("%b #{date.day}, %Y")
182
+ end
183
+
184
+ time = Time.parse(datetime.to_s).utc
185
+ time.strftime("%b #{time.day}, %Y")
186
+ rescue ArgumentError
187
+ nil
188
+ end
189
+
149
190
  end
150
191
  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.2"
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)
@@ -58,13 +59,15 @@ module Gemstar
58
59
  r.get "detail" do
59
60
  request_cache_key = detail_request_cache_key(r.params)
60
61
  request_cache = self.class.opts[:detail_request_cache]
61
- if request_cache_key && request_cache.key?(request_cache_key)
62
+ refresh_detail = detail_refresh_requested?(r.params)
63
+ if request_cache_key && !refresh_detail && request_cache.key?(request_cache_key)
62
64
  next request_cache[request_cache_key]
63
65
  end
66
+ request_cache.delete(request_cache_key) if request_cache_key && refresh_detail
64
67
 
65
68
  load_state(r.params)
66
- prioritize_selected_gem
67
- detail_html = render_detail
69
+ prioritize_selected_gem unless r.env["gemstar.detail_cache_warm"]
70
+ detail_html = render_detail(force_refresh: refresh_detail, use_github_cli: detail_use_github_cli_requested?(r.params))
68
71
  request_cache[request_cache_key] = detail_html if request_cache_key
69
72
  detail_html
70
73
  end
@@ -130,6 +133,14 @@ module Gemstar
130
133
  0
131
134
  end
132
135
 
136
+ def detail_refresh_requested?(params)
137
+ %w[1 true yes].include?(params["refresh"].to_s.downcase)
138
+ end
139
+
140
+ def detail_use_github_cli_requested?(params)
141
+ %w[1 true yes].include?(params["use_gh"].to_s.downcase)
142
+ end
143
+
133
144
  def page_title
134
145
  return "Gemstar" unless @selected_project
135
146
 
@@ -479,10 +490,10 @@ module Gemstar
479
490
  HTML
480
491
  end
481
492
 
482
- def render_detail
493
+ def render_detail(force_refresh: false, use_github_cli: false)
483
494
  return empty_detail_html unless @selected_gem
484
495
 
485
- metadata = metadata_for(@selected_gem, refresh_if_missing: true)
496
+ metadata = metadata_for(@selected_gem, refresh_if_missing: true, force_refresh: force_refresh)
486
497
  cache_key = [
487
498
  CACHE_VERSION,
488
499
  @selected_project_index,
@@ -501,9 +512,9 @@ module Gemstar
501
512
  @selected_gem[:status]
502
513
  ]
503
514
  detail_cache = self.class.opts[:detail_html_cache]
504
- return detail_cache[cache_key] if detail_cache.key?(cache_key)
515
+ return detail_cache[cache_key] if !force_refresh && detail_cache.key?(cache_key)
505
516
 
506
- groups = grouped_change_sections(@selected_gem)
517
+ groups = grouped_change_sections(@selected_gem, force_refresh: force_refresh, use_github_cli: use_github_cli)
507
518
  detail_pending = detail_pending?(@selected_gem[:name], metadata, groups)
508
519
 
509
520
  detail_html = <<~HTML
@@ -837,7 +848,10 @@ module Gemstar
837
848
  <article class="revision-card revision-#{section[:kind]}#{status_class}">
838
849
  <header class="revision-card-header">
839
850
  <div class="revision-card-titlebar">
840
- <h5>#{h(section[:title] || section[:version])}</h5>
851
+ <div class="revision-card-heading">
852
+ <h5>#{h(section[:title] || section[:version])}</h5>
853
+ #{render_release_date(section[:release_date])}
854
+ </div>
841
855
  <div class="revision-card-actions">
842
856
  #{title_links.join}
843
857
  </div>
@@ -850,8 +864,14 @@ module Gemstar
850
864
  HTML
851
865
  end
852
866
 
853
- def grouped_change_sections(gem_state)
854
- sections = change_sections(gem_state)
867
+ def render_release_date(release_date)
868
+ return "" if release_date.to_s.empty?
869
+
870
+ %(<span class="revision-release-date" title="Estimated release date">#{h(release_date)}</span>)
871
+ end
872
+
873
+ def grouped_change_sections(gem_state, force_refresh: false, use_github_cli: false)
874
+ sections = change_sections(gem_state, force_refresh: force_refresh, use_github_cli: use_github_cli)
855
875
  latest = sections.select { |section| section[:kind] == :future }
856
876
  current = sections.select { |section| section[:kind] == :current }
857
877
  previous = sections.select { |section| section[:kind] == :previous }
@@ -868,8 +888,8 @@ module Gemstar
868
888
  }
869
889
  end
870
890
 
871
- def change_sections(gem_state)
872
- metadata_hash = metadata_for(gem_state) || {}
891
+ def change_sections(gem_state, force_refresh: false, use_github_cli: false)
892
+ metadata_hash = metadata_for(gem_state, refresh_if_missing: force_refresh, force_refresh: force_refresh) || {}
873
893
  current_version = effective_package_version(gem_state, metadata_hash)
874
894
  cache_key = [
875
895
  CACHE_VERSION,
@@ -884,17 +904,18 @@ module Gemstar
884
904
  gem_state[:status]
885
905
  ]
886
906
  change_sections_cache = self.class.opts[:change_sections_cache]
887
- return change_sections_cache[cache_key] if change_sections_cache.key?(cache_key)
907
+ return change_sections_cache[cache_key] if !force_refresh && change_sections_cache.key?(cache_key)
888
908
 
889
909
  return [] if gem_state[:new_version].nil? &&
890
910
  gem_state[:old_version].nil? &&
891
911
  (current_version.nil? || current_version.to_s.empty?)
892
912
  metadata = metadata_adapter_for(gem_state)
893
913
  return change_sections_cache[cache_key] = [] unless metadata
894
- sections = resolved_sections(metadata, gem_state)
914
+ sections = resolved_sections(metadata, gem_state, force_refresh: force_refresh, use_github_cli: use_github_cli)
895
915
  return change_sections_cache[cache_key] = [] if sections.nil? || sections.empty?
896
916
 
897
917
  previous_version = gem_state[:old_version]
918
+ release_dates = resolved_release_dates(metadata, sections.keys, gem_state, force_refresh: force_refresh)
898
919
 
899
920
  rendered_sections = sections.keys.filter_map do |version|
900
921
  kind = section_kind(version, previous_version, current_version, gem_state[:status])
@@ -906,6 +927,7 @@ module Gemstar
906
927
  title: content[:title],
907
928
  kind: kind,
908
929
  previous_version: previous_section_version(sections.keys, version),
930
+ release_date: release_date_for(release_dates, version),
909
931
  html: content[:html]
910
932
  }
911
933
  end
@@ -915,10 +937,10 @@ module Gemstar
915
937
  []
916
938
  end
917
939
 
918
- def resolved_sections(metadata, gem_state)
940
+ def resolved_sections(metadata, gem_state, force_refresh: false, use_github_cli: false)
919
941
  changelog = Gemstar::ChangeLog.new(metadata)
920
- cached_sections = changelog.sections(cache_only: true) || {}
921
- return cached_sections unless selected_gem_requires_refresh?(gem_state, cached_sections)
942
+ cached_sections = force_refresh ? {} : changelog.sections(cache_only: true) || {}
943
+ return cached_sections unless force_refresh || selected_gem_requires_refresh?(gem_state, cached_sections)
922
944
 
923
945
  @metadata_cache.delete([gem_state[:package_scope], gem_state[:name]])
924
946
  metadata.meta(cache_only: false, force_refresh: true)
@@ -926,11 +948,44 @@ module Gemstar
926
948
  refreshed_sections = metadata.changelog_sections(
927
949
  versions: relevant_package_versions(gem_state, metadata),
928
950
  cache_only: false,
929
- force_refresh: true
951
+ force_refresh: true,
952
+ use_github_cli: use_github_cli
930
953
  )
931
954
  cached_sections.merge(refreshed_sections || {})
932
955
  end
933
956
 
957
+ def resolved_release_dates(metadata, versions, gem_state, force_refresh: false)
958
+ changelog = Gemstar::ChangeLog.new(metadata)
959
+ cached_dates = force_refresh ? {} : changelog.release_dates(versions: versions, cache_only: true) || {}
960
+ return cached_dates unless selected_gem_missing_release_dates?(gem_state, versions, cached_dates)
961
+
962
+ fetched_dates = changelog.release_dates(versions: versions, cache_only: false, force_refresh: force_refresh) || {}
963
+ cached_dates.merge(fetched_dates)
964
+ rescue StandardError
965
+ {}
966
+ end
967
+
968
+ def release_date_for(release_dates, version)
969
+ release_dates.find do |candidate_version, _date|
970
+ normalize_release_version_key(candidate_version) == normalize_release_version_key(version)
971
+ end&.last
972
+ end
973
+
974
+ def selected_gem_missing_release_dates?(gem_state, versions, release_dates)
975
+ return false unless @selected_gem && gem_state[:name] == @selected_gem[:name]
976
+
977
+ requested_versions = Array(versions).map { |version| normalize_release_version_key(version) }.compact
978
+ dated_versions = release_dates.keys.map { |version| normalize_release_version_key(version) }.compact
979
+ (requested_versions - dated_versions).any?
980
+ end
981
+
982
+ def normalize_release_version_key(version)
983
+ value = version.to_s.strip
984
+ return nil if value.empty?
985
+
986
+ value.sub(/\Av/i, "")
987
+ end
988
+
934
989
  def relevant_package_versions(gem_state, metadata)
935
990
  metadata_hash = metadata_for(gem_state) || {}
936
991
  [
@@ -1033,26 +1088,26 @@ module Gemstar
1033
1088
  end
1034
1089
 
1035
1090
  def strip_leading_version_heading(text, heading_version)
1036
- stripped = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1037
- return strip_leading_hash_separator(stripped) unless stripped == text
1091
+ stripped = text.sub(/\A\s*#+\s*(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b[^\n]*\n+/i, "")
1092
+ return strip_leading_heading_separator(stripped) unless stripped == text
1038
1093
 
1039
1094
  lines = text.lines
1040
1095
  return text if lines.empty?
1041
1096
 
1042
1097
  first_line = lines.first.to_s
1043
1098
  heading_like =
1044
- first_line.match?(/\A\s*v?#{Regexp.escape(heading_version)}\b/i) ||
1045
- first_line.match?(/\A\s*[\[(]?v?#{Regexp.escape(heading_version)}\b/i)
1099
+ first_line.match?(/\A\s*(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b/i) ||
1100
+ first_line.match?(/\A\s*[\[(]?(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b/i)
1046
1101
 
1047
1102
  return text unless heading_like
1048
1103
 
1049
1104
  remaining = lines.drop(1)
1050
1105
  remaining.shift while remaining.first&.strip&.empty?
1051
- strip_leading_hash_separator(remaining.join)
1106
+ strip_leading_heading_separator(remaining.join)
1052
1107
  end
1053
1108
 
1054
- def strip_leading_hash_separator(text)
1055
- text.sub(/\A\s*#{Regexp.escape("#")}{4,}\s*\n+/, "")
1109
+ def strip_leading_heading_separator(text)
1110
+ text.sub(/\A\s*(?:#{Regexp.escape("#")}{4,}|[-=]{3,})\s*\n+/, "")
1056
1111
  end
1057
1112
 
1058
1113
  def compare_versions(left, right)
@@ -1061,26 +1116,26 @@ module Gemstar
1061
1116
  left.to_s <=> right.to_s
1062
1117
  end
1063
1118
 
1064
- def metadata_for(package_state_or_name, refresh_if_missing: false)
1119
+ def metadata_for(package_state_or_name, refresh_if_missing: false, force_refresh: false)
1065
1120
  package_state = package_state_or_name.is_a?(Hash) ? package_state_or_name : { name: package_state_or_name, package_scope: "gems" }
1066
1121
  gem_name = package_state[:name]
1067
1122
  cache_key = [package_state[:package_scope], gem_name]
1068
1123
  cached = @metadata_cache[cache_key]
1069
- return nil if cached.equal?(MISSING_METADATA)
1070
- return cached if cached
1124
+ return nil if !force_refresh && cached.equal?(MISSING_METADATA)
1125
+ return cached if !force_refresh && cached
1071
1126
 
1072
1127
  if package_state[:package_scope] != "gems"
1073
1128
  metadata = local_package_metadata(package_state)
1074
1129
  adapter = metadata_adapter_for(package_state)
1075
- remote_metadata = adapter&.meta(cache_only: true)
1130
+ remote_metadata = force_refresh ? nil : adapter&.meta(cache_only: true)
1076
1131
  remote_metadata = adapter&.meta(cache_only: false, force_refresh: true) if remote_metadata.nil? && refresh_if_missing
1077
1132
  metadata = metadata.compact.merge(remote_metadata || {})
1078
1133
  @metadata_cache[cache_key] = metadata || MISSING_METADATA
1079
1134
  return metadata
1080
1135
  end
1081
1136
 
1082
- metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
1083
- if metadata.nil? && refresh_if_missing
1137
+ metadata = force_refresh ? nil : Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
1138
+ if metadata.nil? && (refresh_if_missing || force_refresh)
1084
1139
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: false, force_refresh: true)
1085
1140
  end
1086
1141
 
@@ -1261,16 +1316,27 @@ module Gemstar
1261
1316
  else
1262
1317
  %(<a href="#{h(fallback_url)}" target="_blank" rel="noreferrer">#{h(fallback_label)}</a>)
1263
1318
  end
1319
+ github_cli_action = render_github_cli_release_button(version, repo_url)
1264
1320
 
1265
1321
  {
1266
1322
  version: version,
1267
1323
  title: version,
1268
1324
  kind: :current,
1269
1325
  previous_version: fallback_previous_version_for(gem_state, previous_sections),
1270
- html: "<p>No release information available. Check #{fallback_link} for more information.</p>"
1326
+ html: "<p>No release information available. Check #{fallback_link} for more information.</p>#{github_cli_action}"
1271
1327
  }
1272
1328
  end
1273
1329
 
1330
+ def render_github_cli_release_button(version, repo_url)
1331
+ return "" unless repo_url.to_s.include?("github.com")
1332
+
1333
+ <<~HTML
1334
+ <div class="detail-inline-actions">
1335
+ <button type="button" class="action" data-detail-use-gh data-release-version="#{h(version)}">Use GitHub CLI</button>
1336
+ </div>
1337
+ HTML
1338
+ end
1339
+
1274
1340
  def fallback_previous_version_for(gem_state, previous_sections)
1275
1341
  return gem_state[:old_version] if gem_state[:new_version]
1276
1342
  return previous_sections.first[:version] if previous_sections.any?
@@ -455,6 +455,12 @@
455
455
  background: #fff;
456
456
  padding: 0.5rem;
457
457
  }
458
+ .detail-inline-actions {
459
+ display: flex;
460
+ flex-wrap: wrap;
461
+ gap: 0.35rem;
462
+ margin-top: 0.55rem;
463
+ }
458
464
  .detail-loading-shell {
459
465
  min-height: 14rem;
460
466
  display: grid;
@@ -566,6 +572,13 @@
566
572
  flex: 0 0 auto;
567
573
  margin-left: auto;
568
574
  }
575
+ .revision-card-heading {
576
+ display: inline-flex;
577
+ align-items: baseline;
578
+ flex-wrap: wrap;
579
+ gap: 0.45rem;
580
+ min-width: 0;
581
+ }
569
582
  .revision-card h5 {
570
583
  margin: 0;
571
584
  font-size: 1.55rem;
@@ -573,6 +586,12 @@
573
586
  color: var(--ink);
574
587
  min-width: 0;
575
588
  }
589
+ .revision-release-date {
590
+ color: var(--grey);
591
+ font-size: 0.86rem;
592
+ font-weight: 600;
593
+ white-space: nowrap;
594
+ }
576
595
  .revision-markup {
577
596
  padding: 0.7rem;
578
597
  }
@@ -19,6 +19,12 @@
19
19
  const emptyDetailHtml = <%= empty_detail_html_json %>;
20
20
  const packageCollectionLabel = <%= (@selected_project&.package_collection_label || "Packages").downcase.dump %>;
21
21
  const detailDisclosureStorageKey = "gemstar.detailDisclosureOpen";
22
+ const pageWasReloaded = (() => {
23
+ const navigationEntry = performance.getEntriesByType && performance.getEntriesByType("navigation")[0];
24
+ if (navigationEntry) return navigationEntry.type === "reload";
25
+ return performance.navigation && performance.navigation.type === 1;
26
+ })();
27
+ let pendingManualDetailRefresh = pageWasReloaded;
22
28
 
23
29
  const visibleGemLinks = () => gemLinks.filter((link) => !link.hidden);
24
30
  const currentSelectedIndex = () => visibleGemLinks().findIndex((link) => link.classList.contains("is-selected"));
@@ -170,7 +176,15 @@
170
176
  </section>
171
177
  `;
172
178
 
173
- const fetchDetail = (url, historyMode = "push") => {
179
+ const fetchDetail = (url, historyMode = "push", options = {}) => {
180
+ const requestUrl = new URL(url, window.location.origin);
181
+ if (options.refresh) {
182
+ requestUrl.searchParams.set("refresh", "1");
183
+ }
184
+ if (options.useGh) {
185
+ requestUrl.searchParams.set("use_gh", "1");
186
+ requestUrl.searchParams.set("refresh", "1");
187
+ }
174
188
  const normalizedUrl = new URL(url, window.location.origin).toString();
175
189
  const requestToken = ++detailRequestToken;
176
190
  activeDetailUrl = normalizedUrl;
@@ -180,7 +194,7 @@
180
194
  replaceDetail(loadingDetailHtml(normalizedUrl));
181
195
  }, 1000);
182
196
 
183
- fetch(url, { headers: { "X-Requested-With": "gemstar-detail" } })
197
+ fetch(requestUrl.toString(), { headers: { "X-Requested-With": "gemstar-detail" } })
184
198
  .then((response) => response.text())
185
199
  .then((html) => {
186
200
  stopDetailLoading();
@@ -232,7 +246,8 @@
232
246
  window.history.replaceState({}, "", pageUrl);
233
247
  }
234
248
  if (detailNeedsInitialFetch()) {
235
- fetchDetail(detailLink.dataset.detailUrl || detailLink.href, requested ? "none" : "replace");
249
+ fetchDetail(detailLink.dataset.detailUrl || detailLink.href, requested ? "none" : "replace", { refresh: pendingManualDetailRefresh });
250
+ pendingManualDetailRefresh = false;
236
251
  }
237
252
  return;
238
253
  }
@@ -241,7 +256,11 @@
241
256
 
242
257
  const firstVisibleLink = visibleGemLinks()[0];
243
258
  if (firstVisibleLink) {
244
- activateGemLink(firstVisibleLink, "replace", true);
259
+ const refresh = pendingManualDetailRefresh;
260
+ pendingManualDetailRefresh = false;
261
+ syncSidebarSelection(firstVisibleLink.dataset.gemName, true);
262
+ fetchDetail(firstVisibleLink.dataset.detailUrl || firstVisibleLink.href, "replace", { refresh });
263
+ focusSidebar();
245
264
  }
246
265
  };
247
266
 
@@ -309,6 +328,16 @@
309
328
  });
310
329
  }
311
330
 
331
+ document.addEventListener("click", (event) => {
332
+ const button = event.target.closest("[data-detail-use-gh]");
333
+ if (!button || !detailPanel || !detailPanel.dataset.detailUrl) return;
334
+
335
+ event.preventDefault();
336
+ button.disabled = true;
337
+ button.textContent = "Checking...";
338
+ fetchDetail(detailPanel.dataset.detailUrl, "none", { refresh: true, useGh: true });
339
+ });
340
+
312
341
  const navigate = (params) => {
313
342
  const url = new URL(window.location.href);
314
343
  Object.entries(params).forEach(([key, value]) => {
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.2'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Dejako