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.
@@ -1,9 +1,14 @@
1
1
  require "cgi"
2
2
  require "erb"
3
+ require "json"
4
+ require "open3"
5
+ require "shellwords"
3
6
  require "uri"
4
7
  require "kramdown"
5
8
  require "nokogiri"
6
9
  require "roda"
10
+ require_relative "../crates_io_metadata"
11
+ require_relative "../pypi_metadata"
7
12
 
8
13
  begin
9
14
  require "kramdown-parser-gfm"
@@ -14,7 +19,7 @@ module Gemstar
14
19
  module Web
15
20
  class App < Roda
16
21
  MISSING_METADATA = Object.new
17
- CACHE_VERSION = "v7"
22
+ CACHE_VERSION = "v11"
18
23
 
19
24
  class << self
20
25
  def build(projects:, config_home:, cache_warmer: nil)
@@ -59,13 +64,15 @@ module Gemstar
59
64
  r.get "detail" do
60
65
  request_cache_key = detail_request_cache_key(r.params)
61
66
  request_cache = self.class.opts[:detail_request_cache]
62
- if request_cache_key && request_cache.key?(request_cache_key)
67
+ refresh_detail = detail_refresh_requested?(r.params)
68
+ if request_cache_key && !refresh_detail && request_cache.key?(request_cache_key)
63
69
  next request_cache[request_cache_key]
64
70
  end
71
+ request_cache.delete(request_cache_key) if request_cache_key && refresh_detail
65
72
 
66
73
  load_state(r.params)
67
- prioritize_selected_gem
68
- detail_html = render_detail
74
+ prioritize_selected_gem unless r.env["gemstar.detail_cache_warm"]
75
+ detail_html = render_detail(force_refresh: refresh_detail, use_github_cli: detail_use_github_cli_requested?(r.params))
69
76
  request_cache[request_cache_key] = detail_html if request_cache_key
70
77
  detail_html
71
78
  end
@@ -80,6 +87,23 @@ module Gemstar
80
87
  File.read(project.gemfile_path)
81
88
  end
82
89
 
90
+ r.post "actions", String do |action_id|
91
+ response["Content-Type"] = "application/json; charset=utf-8"
92
+
93
+ project_index = selected_project_index(r.params["project"])
94
+ project = @projects[project_index]
95
+ action = project_action(action_id, project)
96
+ unless project && action
97
+ response.status = 404
98
+ next JSON.generate({ ok: false, error: "Unknown project action." })
99
+ end
100
+
101
+ result = run_project_action(project, action)
102
+ clear_runtime_state!(project)
103
+ response.status = result[:ok] ? 200 : 500
104
+ JSON.generate(result)
105
+ end
106
+
83
107
  r.on "projects", String do |project_id|
84
108
  response.redirect "/?project=#{project_id}"
85
109
  end
@@ -102,6 +126,7 @@ module Gemstar
102
126
  importmap_stamp = File.file?(project.importmap_path) ? File.mtime(project.importmap_path).to_i : 0
103
127
  importmap_vendor_stamp = importmap_vendor_mtime(project)
104
128
  package_lock_stamp = File.file?(project.package_lock_path) ? File.mtime(project.package_lock_path).to_i : 0
129
+ cargo_lock_stamp = File.file?(project.cargo_lock_path) ? File.mtime(project.cargo_lock_path).to_i : 0
105
130
 
106
131
  [
107
132
  CACHE_VERSION,
@@ -111,10 +136,12 @@ module Gemstar
111
136
  params["filter"],
112
137
  params["scope"],
113
138
  package_param(params),
139
+ params["package_id"],
114
140
  lockfile_stamp,
115
141
  importmap_stamp,
116
142
  importmap_vendor_stamp,
117
- package_lock_stamp
143
+ package_lock_stamp,
144
+ cargo_lock_stamp
118
145
  ]
119
146
  end
120
147
 
@@ -131,6 +158,14 @@ module Gemstar
131
158
  0
132
159
  end
133
160
 
161
+ def detail_refresh_requested?(params)
162
+ %w[1 true yes].include?(params["refresh"].to_s.downcase)
163
+ end
164
+
165
+ def detail_use_github_cli_requested?(params)
166
+ %w[1 true yes].include?(params["use_gh"].to_s.downcase)
167
+ end
168
+
134
169
  def page_title
135
170
  return "Gemstar" unless @selected_project
136
171
 
@@ -146,10 +181,12 @@ module Gemstar
146
181
  @selected_to_revision_id = selected_to_revision_id(@selected_to_revision_id)
147
182
  @gem_states = @selected_project ? @selected_project.gem_states(from_revision_id: @selected_from_revision_id, to_revision_id: @selected_to_revision_id) : []
148
183
  requested_package_name = package_param(params)
184
+ requested_package_id = params["package_id"]
149
185
  @requested_gem_name = requested_package_name
150
- @selected_package_scope = selected_package_scope(params["scope"], requested_package_name)
151
- @selected_filter = selected_filter(params["filter"], requested_package_name)
152
- @selected_gem = selected_gem_state(requested_package_name)
186
+ @requested_package_id = requested_package_id
187
+ @selected_package_scope = selected_package_scope(params["scope"], requested_package_name, requested_package_id)
188
+ @selected_filter = selected_filter(params["filter"], requested_package_name, requested_package_id)
189
+ @selected_gem = selected_gem_state(requested_package_name, requested_package_id)
153
190
  end
154
191
 
155
192
  def package_param(params)
@@ -191,33 +228,36 @@ module Gemstar
191
228
  valid_ids.include?(candidate) ? candidate : valid_ids.first || "worktree"
192
229
  end
193
230
 
194
- def selected_filter(raw_filter, raw_gem_name)
231
+ def selected_filter(raw_filter, raw_gem_name, raw_package_id = nil)
195
232
  return "all" if @gem_states.empty?
233
+ return "all" unless @gem_states.any? { |gem| gem[:status] != :unchanged }
196
234
  return raw_filter if %w[updated all].include?(raw_filter)
197
235
 
198
- selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name }
236
+ selected_gem = requested_gem_state(raw_gem_name, raw_package_id)
199
237
  return "all" if selected_gem && selected_gem[:status] == :unchanged
200
238
 
201
239
  @gem_states.any? { |gem| gem[:status] != :unchanged } ? "updated" : "all"
202
240
  end
203
241
 
204
- def selected_package_scope(raw_scope, raw_gem_name)
242
+ def selected_package_scope(raw_scope, raw_gem_name, raw_package_id = nil)
205
243
  return "all" if @gem_states.empty?
206
244
 
207
245
  available_scopes = available_package_scopes
208
246
  default_scope = available_scopes == ["gems"] ? "gems" : "all"
209
247
  return raw_scope if raw_scope == "all" || available_scopes.include?(raw_scope)
210
248
 
211
- selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name }
249
+ selected_gem = requested_gem_state(raw_gem_name, raw_package_id)
212
250
  return selected_gem[:package_scope] if selected_gem
213
251
 
214
252
  default_scope
215
253
  end
216
254
 
217
- def selected_gem_state(raw_gem_name)
255
+ def selected_gem_state(raw_gem_name, raw_package_id = nil)
218
256
  return nil if @gem_states.empty?
219
257
 
220
- exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name && gem_visible_in_selected_scope?(gem) }
258
+ exact_match = @gem_states.find { |gem| package_identity(gem) == raw_package_id && gem_visible_in_selected_scope?(gem) } unless raw_package_id.to_s.empty?
259
+ exact_match ||= @gem_states.find { |gem| package_identity(gem) == raw_package_id } unless raw_package_id.to_s.empty?
260
+ exact_match ||= @gem_states.find { |gem| gem[:name] == raw_gem_name && gem_visible_in_selected_scope?(gem) }
221
261
  exact_match ||= @gem_states.find { |gem| gem[:name] == raw_gem_name }
222
262
  return exact_match if exact_match
223
263
 
@@ -227,6 +267,23 @@ module Gemstar
227
267
  @gem_states.first
228
268
  end
229
269
 
270
+ def requested_gem_state(raw_gem_name, raw_package_id)
271
+ unless raw_package_id.to_s.empty?
272
+ identity_match = @gem_states.find { |gem| package_identity(gem) == raw_package_id }
273
+ return identity_match if identity_match
274
+ end
275
+
276
+ @gem_states.find { |gem| gem[:name] == raw_gem_name }
277
+ end
278
+
279
+ def package_identity(gem_state)
280
+ [
281
+ gem_state[:package_scope],
282
+ gem_state[:package_source_file],
283
+ gem_state[:package_lock_key] || gem_state[:name]
284
+ ].join(":")
285
+ end
286
+
230
287
  def gem_visible_in_selected_filter?(gem_state)
231
288
  return false unless gem_visible_in_selected_scope?(gem_state)
232
289
  return true if @selected_filter != "updated"
@@ -394,13 +451,231 @@ module Gemstar
394
451
  <strong>#{@gem_states.count { |gem| gem[:status] != :unchanged }}</strong> changes from #{h(selected_from_revision_label)} to #{h(selected_to_revision_label)}
395
452
  </div>
396
453
  <div class="toolbar-actions">
397
- <button type="button" class="action" disabled="disabled">bundle install</button>
398
- <button type="button" class="action action-primary" disabled="disabled">bundle update</button>
454
+ #{render_project_action_buttons}
399
455
  </div>
400
456
  </section>
401
457
  HTML
402
458
  end
403
459
 
460
+ def render_project_action_buttons
461
+ actions = project_actions
462
+ actions.map do |action|
463
+ primary = action[:primary] ? " action-primary" : ""
464
+ command_label = project_action_display_command(@selected_project, action)
465
+ <<~HTML
466
+ <button
467
+ type="button"
468
+ class="action#{primary}"
469
+ data-project-action="#{h(action[:id])}"
470
+ data-project-action-command="#{h(command_label)}"
471
+ >#{h(action[:label])}</button>
472
+ HTML
473
+ end.join
474
+ end
475
+
476
+ def project_actions(project = @selected_project)
477
+ return [] unless project
478
+
479
+ actions = []
480
+ if project.gemfile? || project.lockfile?
481
+ actions << {
482
+ id: "bundle_install",
483
+ label: "#{bundle_command(project).join(" ")} install",
484
+ command: [*bundle_command(project), "install"]
485
+ }
486
+ actions << {
487
+ id: "bundle_update",
488
+ label: "#{bundle_command(project).join(" ")} update",
489
+ command: [*bundle_command(project), "update"],
490
+ primary: true
491
+ }
492
+ end
493
+
494
+ if project.uv_lock?
495
+ actions << {
496
+ id: "uv_sync",
497
+ label: "uv sync",
498
+ command: %w[uv sync]
499
+ }
500
+ actions << {
501
+ id: "uv_lock_upgrade",
502
+ label: "uv lock --upgrade",
503
+ command: %w[uv lock --upgrade],
504
+ primary: true
505
+ }
506
+ end
507
+
508
+
509
+ if project.cargo_toml? || project.cargo_lock?
510
+ actions << {
511
+ id: "cargo_fetch",
512
+ label: "cargo fetch",
513
+ command: %w[cargo fetch]
514
+ }
515
+ actions << {
516
+ id: "cargo_update",
517
+ label: "cargo update",
518
+ command: %w[cargo update],
519
+ primary: true
520
+ }
521
+ end
522
+
523
+ if project.package_lock?
524
+ actions << {
525
+ id: "npm_install",
526
+ label: "npm install",
527
+ command: %w[npm install]
528
+ }
529
+ actions << {
530
+ id: "npm_update",
531
+ label: "npm update",
532
+ command: %w[npm update],
533
+ primary: true
534
+ }
535
+ elsif project.package_json?
536
+ actions << {
537
+ id: "npm_install",
538
+ label: "npm install",
539
+ command: %w[npm install]
540
+ }
541
+ end
542
+
543
+ if project.importmap?
544
+ actions << {
545
+ id: "importmap_update",
546
+ label: "bin/importmap update",
547
+ command: %w[bin/importmap update]
548
+ }
549
+ end
550
+
551
+ actions
552
+ end
553
+
554
+ def project_action(action_id, project)
555
+ project_actions(project).find { |action| action[:id] == action_id }
556
+ end
557
+
558
+ def run_project_action(project, action)
559
+ command = project_action_command(project, action)
560
+ shell_command = project_action_shell_command(project, command)
561
+ stdout, stderr, status = Open3.capture3(project_action_environment, user_shell, "-lc", shell_command)
562
+ {
563
+ ok: status.success?,
564
+ action: action[:id],
565
+ command: shell_command,
566
+ output: truncated_action_output([stdout, stderr].reject(&:empty?).join("\n").strip)
567
+ }
568
+ rescue Errno::ENOENT => e
569
+ {
570
+ ok: false,
571
+ action: action[:id],
572
+ command: project_action_display_command(project, action),
573
+ output: e.message
574
+ }
575
+ end
576
+
577
+ def project_action_display_command(project, action)
578
+ project_action_shell_command(project, project_action_command(project, action))
579
+ end
580
+
581
+ def project_action_shell_command(project, command)
582
+ "cd #{Shellwords.escape(project.directory)} && #{Shellwords.join(command)}"
583
+ end
584
+
585
+ def project_action_command(project, action)
586
+ return action[:command] unless project_uses_mise?(project)
587
+
588
+ mise = mise_executable
589
+ return action[:command] unless mise
590
+
591
+ [mise, "exec", "--", *mise_command(action[:command])]
592
+ end
593
+
594
+ def mise_command(command)
595
+ executable, *arguments = command
596
+ case executable
597
+ when /\Abin\//
598
+ ["ruby", executable, *arguments]
599
+ when "bundle"
600
+ ["ruby", "-S", executable, *arguments]
601
+ else
602
+ command
603
+ end
604
+ end
605
+
606
+ def project_action_environment
607
+ {
608
+ "BUNDLE_BIN_PATH" => nil,
609
+ "BUNDLE_APP_CONFIG" => nil,
610
+ "BUNDLE_GEMFILE" => nil,
611
+ "BUNDLE_PATH" => nil,
612
+ "BUNDLE_WITHOUT" => nil,
613
+ "BUNDLER_VERSION" => nil,
614
+ "GEM_HOME" => nil,
615
+ "GEM_PATH" => nil,
616
+ "GEM_ROOT" => nil,
617
+ "MY_RUBY_HOME" => nil,
618
+ "RBENV_VERSION" => nil,
619
+ "RUBYLIB" => nil,
620
+ "RUBYGEMS_GEMDEPS" => nil,
621
+ "RUBYOPT" => nil,
622
+ "rvm_path" => nil,
623
+ "rvm_ruby_string" => nil
624
+ }
625
+ end
626
+
627
+ def user_shell
628
+ shell = ENV["SHELL"].to_s
629
+ shell.empty? ? "/bin/sh" : shell
630
+ end
631
+
632
+ def bundle_command(project)
633
+ File.file?(File.join(project.directory, "bin", "bundle")) ? %w[bin/bundle] : %w[bundle]
634
+ end
635
+
636
+ def project_uses_mise?(project)
637
+ directory = project.directory
638
+ loop do
639
+ return true if File.file?(File.join(directory, "mise.toml")) ||
640
+ File.file?(File.join(directory, ".mise.toml")) ||
641
+ File.file?(File.join(directory, ".ruby-version"))
642
+
643
+ parent = File.dirname(directory)
644
+ break if parent == directory
645
+
646
+ directory = parent
647
+ end
648
+
649
+ false
650
+ end
651
+
652
+ def mise_executable
653
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |directory|
654
+ path = File.join(directory, "mise")
655
+ return path if File.executable?(path)
656
+ end
657
+
658
+ [
659
+ "/opt/homebrew/bin/mise",
660
+ "/usr/local/bin/mise",
661
+ File.expand_path("~/.local/bin/mise")
662
+ ].find { |path| File.executable?(path) }
663
+ end
664
+
665
+ def truncated_action_output(output)
666
+ return output if output.length <= 8_000
667
+
668
+ "#{output[0, 8_000]}\n\n... output truncated ..."
669
+ end
670
+
671
+ def clear_runtime_state!(project)
672
+ project&.clear_cache! if project.respond_to?(:clear_cache!)
673
+ self.class.opts[:change_sections_cache].clear
674
+ self.class.opts[:detail_html_cache].clear
675
+ self.class.opts[:detail_request_cache].clear
676
+ self.class.opts[:metadata_cache].clear
677
+ end
678
+
404
679
  def selected_from_revision_label
405
680
  @revision_options.find { |option| option[:id] == @selected_from_revision_id }&.dig(:label) || "worktree"
406
681
  end
@@ -443,19 +718,22 @@ module Gemstar
443
718
  HTML
444
719
 
445
720
  items = @gem_states.map do |gem|
446
- selected = gem[:name] == @selected_gem[:name] ? " is-selected" : ""
721
+ identity = package_identity(gem)
722
+ selected = identity == package_identity(@selected_gem) ? " is-selected" : ""
447
723
  status_class = " status-#{gem[:status]}"
448
724
  updated = gem[:status] != :unchanged
449
- hidden = (!gem_visible_in_selected_scope?(gem) || (@selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name)) ? ' hidden="hidden"' : ""
725
+ requested = identity == @requested_package_id || (@requested_package_id.to_s.empty? && gem[:name] == @requested_gem_name)
726
+ hidden = (!gem_visible_in_selected_scope?(gem) || (@selected_filter == "updated" && !updated && !requested)) ? ' hidden="hidden"' : ""
450
727
  <<~HTML
451
728
  <a
452
729
  class="gem-row#{selected}#{status_class}"
453
- href="#{project_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name])}"
730
+ href="#{project_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name], package_id: identity)}"
454
731
  data-gem-link="true"
455
732
  data-gem-name="#{h(gem[:name])}"
733
+ data-package-id="#{h(identity)}"
456
734
  data-gem-updated="#{updated}"
457
735
  data-package-scope="#{h(gem[:package_scope])}"
458
- data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name]))}"
736
+ data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name], package_id: identity))}"
459
737
  #{hidden}
460
738
  >
461
739
  <span class="gem-name-row">
@@ -480,10 +758,10 @@ module Gemstar
480
758
  HTML
481
759
  end
482
760
 
483
- def render_detail
761
+ def render_detail(force_refresh: false, use_github_cli: false)
484
762
  return empty_detail_html unless @selected_gem
485
763
 
486
- metadata = metadata_for(@selected_gem, refresh_if_missing: true)
764
+ metadata = metadata_for(@selected_gem, refresh_if_missing: true, force_refresh: force_refresh)
487
765
  cache_key = [
488
766
  CACHE_VERSION,
489
767
  @selected_project_index,
@@ -502,14 +780,15 @@ module Gemstar
502
780
  @selected_gem[:status]
503
781
  ]
504
782
  detail_cache = self.class.opts[:detail_html_cache]
505
- return detail_cache[cache_key] if detail_cache.key?(cache_key)
783
+ return detail_cache[cache_key] if !force_refresh && detail_cache.key?(cache_key)
506
784
 
507
- groups = grouped_change_sections(@selected_gem)
785
+ groups = grouped_change_sections(@selected_gem, force_refresh: force_refresh, use_github_cli: use_github_cli)
508
786
  detail_pending = detail_pending?(@selected_gem[:name], metadata, groups)
509
787
 
510
788
  detail_html = <<~HTML
511
- <section class="detail" data-detail-panel tabindex="0" data-detail-pending="#{detail_pending}" data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: @selected_gem[:name]))}">
789
+ <section class="detail" data-detail-panel tabindex="0" data-detail-pending="#{detail_pending}" data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: @selected_gem[:name], package_id: package_identity(@selected_gem)))}">
512
790
  #{render_detail_hero(metadata)}
791
+ #{render_selected_dependency_details}
513
792
  #{render_detail_loading_notice if detail_pending}
514
793
  #{render_detail_revision_panel(groups)}
515
794
  </section>
@@ -528,7 +807,8 @@ module Gemstar
528
807
  to: @selected_to_revision_id,
529
808
  filter: @selected_filter,
530
809
  scope: @selected_package_scope,
531
- package: @selected_gem[:name]
810
+ package: @selected_gem[:name],
811
+ package_id: package_identity(@selected_gem)
532
812
  )
533
813
 
534
814
  <<~HTML
@@ -541,11 +821,13 @@ module Gemstar
541
821
  end
542
822
 
543
823
  def empty_detail_html(loading: false)
824
+ package_label = @selected_project&.package_item_label || "package"
825
+
544
826
  <<~HTML
545
827
  <section class="detail" data-detail-panel tabindex="0">
546
828
  <div class="empty-panel">
547
- <h2>#{loading ? "Loading details" : "No gem selected"}</h2>
548
- <p>#{loading ? "Preparing the selected gem details." : "Choose a gem from the list to inspect its current version and changelog revisions."}</p>
829
+ <h2>#{loading ? "Loading details" : "No #{h(package_label)} selected"}</h2>
830
+ <p>#{loading ? "Preparing the selected #{h(package_label)} details." : "Choose a #{h(package_label)} from the list to inspect its current version and changelog revisions."}</p>
549
831
  </div>
550
832
  </section>
551
833
  HTML
@@ -553,10 +835,7 @@ module Gemstar
553
835
 
554
836
  def render_detail_hero(metadata)
555
837
  description = metadata&.dig("info")
556
- bundle_origins = Array(@selected_gem[:bundle_origins])
557
- requirement_names = selected_gem_requirements
558
838
  bundled_version = detail_bundled_version(metadata)
559
- added_on = selected_gem_added_on
560
839
  title_url = metadata&.dig("homepage_uri")
561
840
  title_url = repo_url_for(@selected_gem, metadata: metadata) if title_url.to_s.empty?
562
841
  title_markup = if title_url.to_s.empty?
@@ -570,17 +849,50 @@ module Gemstar
570
849
  <div class="detail-hero-copy">
571
850
  <div class="detail-title-row">
572
851
  <div class="detail-title-lockup">
573
- <h2>#{title_markup}#{bundled_version ? %(<span class="detail-title-version"> #{h(bundled_version)}</span>) : ""}</h2>
852
+ <div class="detail-heading">
853
+ <h2>#{title_markup}#{bundled_version ? %(<span class="detail-title-version"> #{h(bundled_version)}</span>) : ""}</h2>
854
+ #{render_package_type_badge(@selected_gem)}
855
+ </div>
574
856
  </div>
575
857
  #{render_detail_links(metadata)}
576
858
  </div>
577
859
  <div class="detail-subtitle">#{render_detail_subtitle(description)}</div>
578
- #{render_dependency_details(bundle_origins, requirement_names, added_on)}
579
860
  </div>
580
861
  </section>
581
862
  HTML
582
863
  end
583
864
 
865
+ def render_package_type_badge(package_state)
866
+ scope = package_state[:package_scope].to_s
867
+ label, accessible_label, icon_type = case scope
868
+ when "gems"
869
+ ["Gem", "Ruby gem", :rubygems]
870
+ when "cargo"
871
+ ["Crate", "Rust crate", :cargo]
872
+ when "js"
873
+ ["JS", "JavaScript package", :javascript]
874
+ when "python"
875
+ ["Python", "Python package", :python]
876
+ else
877
+ [package_state[:package_type_label] || "Package", "Package", :package]
878
+ end
879
+
880
+ <<~HTML.chomp
881
+ <span class="detail-package-type detail-package-type-#{h(scope)}" aria-label="#{h(accessible_label)}" title="#{h(accessible_label)}">
882
+ <span class="detail-package-type-icon" aria-hidden="true">#{icon_svg(icon_type)}</span>
883
+ <span>#{h(label)}</span>
884
+ </span>
885
+ HTML
886
+ end
887
+
888
+ def render_selected_dependency_details
889
+ render_dependency_details(
890
+ Array(@selected_gem[:bundle_origins]),
891
+ selected_gem_requirements,
892
+ selected_gem_added_on
893
+ )
894
+ end
895
+
584
896
  def render_detail_subtitle(description)
585
897
  text = description.to_s.strip
586
898
  return "<p>Metadata will appear here when package information is available.</p>" if text.empty?
@@ -697,6 +1009,7 @@ module Gemstar
697
1009
  @selected_gem[:name],
698
1010
  package_scope: @selected_gem[:package_scope],
699
1011
  source_file: @selected_gem[:package_source_file],
1012
+ package_key: @selected_gem[:package_lock_key],
700
1013
  revision_id: revision_id
701
1014
  )
702
1015
  end
@@ -754,6 +1067,26 @@ module Gemstar
754
1067
  else
755
1068
  ["npm (#{h(remote)})"]
756
1069
  end
1070
+ when :pypi
1071
+ remote = source[:remote]
1072
+ registry_url = source[:registry_url]
1073
+ if remote.to_s.empty? && registry_url.to_s.empty?
1074
+ ["PyPI"]
1075
+ elsif remote.to_s.empty?
1076
+ ["PyPI (#{h(registry_url)})"]
1077
+ else
1078
+ ["PyPI (#{h(remote)})"]
1079
+ end
1080
+ when :cargo
1081
+ remote = source[:remote]
1082
+ registry_url = source[:registry_url]
1083
+ if remote.to_s.empty? && registry_url.to_s.empty?
1084
+ ["Cargo registry"]
1085
+ elsif remote.to_s.empty?
1086
+ ["Cargo registry (#{h(registry_url)})"]
1087
+ else
1088
+ ["Cargo registry (#{h(remote)})"]
1089
+ end
757
1090
  else
758
1091
  []
759
1092
  end
@@ -792,13 +1125,26 @@ module Gemstar
792
1125
  def render_detail_revision_panel(groups)
793
1126
  <<~HTML
794
1127
  <section class="revision-panel">
795
- #{render_revision_group("Latest", groups[:latest], empty_message: nil) if groups[:latest].any?}
796
- #{render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries in this revision range.")}
797
- #{render_revision_group("Earlier changes", groups[:previous], empty_message: nil) if groups[:previous].any?}
1128
+ #{render_revision_group("Available", groups[:latest], empty_message: nil) if groups[:latest].any?}
1129
+ #{render_current_revision_group(groups)}
1130
+ #{render_previous_revision_group(groups)}
798
1131
  </section>
799
1132
  HTML
800
1133
  end
801
1134
 
1135
+ def render_current_revision_group(groups)
1136
+ return "" if unchanged_worktree_package? && groups[:previous].any?
1137
+
1138
+ render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries in this revision range.")
1139
+ end
1140
+
1141
+ def render_previous_revision_group(groups)
1142
+ return "" if groups[:previous].empty?
1143
+
1144
+ title = unchanged_worktree_package? ? "Included in #{selected_from_revision_label}" : "Earlier changes"
1145
+ render_revision_group(title, groups[:previous], empty_message: nil)
1146
+ end
1147
+
802
1148
  def render_detail_loading_notice
803
1149
  <<~HTML
804
1150
  <section class="empty-panel">
@@ -860,8 +1206,8 @@ module Gemstar
860
1206
  %(<span class="revision-release-date" title="Estimated release date">#{h(release_date)}</span>)
861
1207
  end
862
1208
 
863
- def grouped_change_sections(gem_state)
864
- sections = change_sections(gem_state)
1209
+ def grouped_change_sections(gem_state, force_refresh: false, use_github_cli: false)
1210
+ sections = change_sections(gem_state, force_refresh: force_refresh, use_github_cli: use_github_cli)
865
1211
  latest = sections.select { |section| section[:kind] == :future }
866
1212
  current = sections.select { |section| section[:kind] == :current }
867
1213
  previous = sections.select { |section| section[:kind] == :previous }
@@ -878,8 +1224,8 @@ module Gemstar
878
1224
  }
879
1225
  end
880
1226
 
881
- def change_sections(gem_state)
882
- metadata_hash = metadata_for(gem_state) || {}
1227
+ def change_sections(gem_state, force_refresh: false, use_github_cli: false)
1228
+ metadata_hash = metadata_for(gem_state, refresh_if_missing: force_refresh, force_refresh: force_refresh) || {}
883
1229
  current_version = effective_package_version(gem_state, metadata_hash)
884
1230
  cache_key = [
885
1231
  CACHE_VERSION,
@@ -894,18 +1240,18 @@ module Gemstar
894
1240
  gem_state[:status]
895
1241
  ]
896
1242
  change_sections_cache = self.class.opts[:change_sections_cache]
897
- return change_sections_cache[cache_key] if change_sections_cache.key?(cache_key)
1243
+ return change_sections_cache[cache_key] if !force_refresh && change_sections_cache.key?(cache_key)
898
1244
 
899
1245
  return [] if gem_state[:new_version].nil? &&
900
1246
  gem_state[:old_version].nil? &&
901
1247
  (current_version.nil? || current_version.to_s.empty?)
902
1248
  metadata = metadata_adapter_for(gem_state)
903
1249
  return change_sections_cache[cache_key] = [] unless metadata
904
- sections = resolved_sections(metadata, gem_state)
1250
+ sections = resolved_sections(metadata, gem_state, force_refresh: force_refresh, use_github_cli: use_github_cli)
905
1251
  return change_sections_cache[cache_key] = [] if sections.nil? || sections.empty?
906
1252
 
907
1253
  previous_version = gem_state[:old_version]
908
- release_dates = resolved_release_dates(metadata, sections.keys, gem_state)
1254
+ release_dates = resolved_release_dates(metadata, sections.keys, gem_state, force_refresh: force_refresh)
909
1255
 
910
1256
  rendered_sections = sections.keys.filter_map do |version|
911
1257
  kind = section_kind(version, previous_version, current_version, gem_state[:status])
@@ -922,15 +1268,15 @@ module Gemstar
922
1268
  }
923
1269
  end
924
1270
 
925
- change_sections_cache[cache_key] = rendered_sections.sort_by { |section| section_sort_key(section) }
1271
+ change_sections_cache[cache_key] = sort_change_sections(rendered_sections)
926
1272
  rescue StandardError
927
1273
  []
928
1274
  end
929
1275
 
930
- def resolved_sections(metadata, gem_state)
1276
+ def resolved_sections(metadata, gem_state, force_refresh: false, use_github_cli: false)
931
1277
  changelog = Gemstar::ChangeLog.new(metadata)
932
- cached_sections = changelog.sections(cache_only: true) || {}
933
- return cached_sections unless selected_gem_requires_refresh?(gem_state, cached_sections)
1278
+ cached_sections = force_refresh ? {} : changelog.sections(cache_only: true) || {}
1279
+ return cached_sections unless force_refresh || selected_gem_requires_refresh?(gem_state, cached_sections)
934
1280
 
935
1281
  @metadata_cache.delete([gem_state[:package_scope], gem_state[:name]])
936
1282
  metadata.meta(cache_only: false, force_refresh: true)
@@ -938,17 +1284,18 @@ module Gemstar
938
1284
  refreshed_sections = metadata.changelog_sections(
939
1285
  versions: relevant_package_versions(gem_state, metadata),
940
1286
  cache_only: false,
941
- force_refresh: true
1287
+ force_refresh: true,
1288
+ use_github_cli: use_github_cli
942
1289
  )
943
1290
  cached_sections.merge(refreshed_sections || {})
944
1291
  end
945
1292
 
946
- def resolved_release_dates(metadata, versions, gem_state)
1293
+ def resolved_release_dates(metadata, versions, gem_state, force_refresh: false)
947
1294
  changelog = Gemstar::ChangeLog.new(metadata)
948
- cached_dates = changelog.release_dates(versions: versions, cache_only: true) || {}
1295
+ cached_dates = force_refresh ? {} : changelog.release_dates(versions: versions, cache_only: true) || {}
949
1296
  return cached_dates unless selected_gem_missing_release_dates?(gem_state, versions, cached_dates)
950
1297
 
951
- fetched_dates = changelog.release_dates(versions: versions, cache_only: false) || {}
1298
+ fetched_dates = changelog.release_dates(versions: versions, cache_only: false, force_refresh: force_refresh) || {}
952
1299
  cached_dates.merge(fetched_dates)
953
1300
  rescue StandardError
954
1301
  {}
@@ -972,12 +1319,15 @@ module Gemstar
972
1319
  value = version.to_s.strip
973
1320
  return nil if value.empty?
974
1321
 
975
- value.sub(/\Av/i, "")
1322
+ value = value.sub(/\Av/i, "")
1323
+ Gem::Version.new(value.gsub(/-[\w\-]+$/, "")).canonical_segments.join(".")
1324
+ rescue ArgumentError
1325
+ value
976
1326
  end
977
1327
 
978
1328
  def relevant_package_versions(gem_state, metadata)
979
1329
  metadata_hash = metadata_for(gem_state) || {}
980
- [
1330
+ explicit_versions = [
981
1331
  gem_state[:old_version],
982
1332
  gem_state[:new_version],
983
1333
  gem_state[:raw_old_version],
@@ -985,6 +1335,27 @@ module Gemstar
985
1335
  gem_state.dig(:source, :package_version),
986
1336
  metadata_hash["version"]
987
1337
  ].compact
1338
+
1339
+ (explicit_versions + registry_versions_between(metadata, gem_state, metadata_hash)).uniq
1340
+ end
1341
+
1342
+ def registry_versions_between(metadata, gem_state, metadata_hash)
1343
+ return [] unless metadata.respond_to?(:registry_release_dates)
1344
+
1345
+ lower_bound = gem_state[:old_version] || gem_state[:raw_old_version] || gem_state.dig(:source, :package_version)
1346
+ current_version = effective_package_version(gem_state, metadata_hash)
1347
+ upper_bound = metadata_hash["version"] || current_version
1348
+ return [] if lower_bound.to_s.empty? || upper_bound.to_s.empty?
1349
+
1350
+ release_dates = metadata.registry_release_dates(cache_only: true)
1351
+ release_dates = metadata.registry_release_dates(cache_only: false) if release_dates.nil? || release_dates.empty?
1352
+
1353
+ release_dates.keys.select do |version|
1354
+ compare_versions(version, lower_bound) == 1 &&
1355
+ compare_versions(version, upper_bound) <= 0
1356
+ end
1357
+ rescue StandardError
1358
+ []
988
1359
  end
989
1360
 
990
1361
  def selected_gem_requires_refresh?(gem_state, cached_sections)
@@ -1031,17 +1402,14 @@ module Gemstar
1031
1402
  :previous if compare_versions(version, lower_bound) <= 0
1032
1403
  end
1033
1404
 
1034
- def section_sort_key(section)
1035
- kind_rank = { future: 0, current: 1, previous: 2 }.fetch(section[:kind], 9)
1036
- [kind_rank, -sortable_version_number(section[:version])]
1037
- end
1405
+ def sort_change_sections(sections)
1406
+ kind_ranks = { future: 0, current: 1, previous: 2 }
1407
+ sections.sort do |left, right|
1408
+ kind_comparison = kind_ranks.fetch(left[:kind], 9) <=> kind_ranks.fetch(right[:kind], 9)
1409
+ next kind_comparison unless kind_comparison.zero?
1038
1410
 
1039
- def sortable_version_number(version)
1040
- Gem::Version.new(version.to_s.gsub(/-[\w\-]+$/, "")).segments.take(6).each_with_index.sum do |segment, index|
1041
- segment.to_i * (10**(10 - index * 2))
1411
+ compare_versions(right[:version], left[:version])
1042
1412
  end
1043
- rescue ArgumentError
1044
- 0
1045
1413
  end
1046
1414
 
1047
1415
  def changelog_content(lines, heading_version: nil)
@@ -1077,7 +1445,7 @@ module Gemstar
1077
1445
  end
1078
1446
 
1079
1447
  def strip_leading_version_heading(text, heading_version)
1080
- stripped = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "")
1448
+ stripped = text.sub(/\A\s*#+\s*[\[(]?(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b[^\n]*\n+/i, "")
1081
1449
  return strip_leading_heading_separator(stripped) unless stripped == text
1082
1450
 
1083
1451
  lines = text.lines
@@ -1085,8 +1453,8 @@ module Gemstar
1085
1453
 
1086
1454
  first_line = lines.first.to_s
1087
1455
  heading_like =
1088
- first_line.match?(/\A\s*v?#{Regexp.escape(heading_version)}\b/i) ||
1089
- first_line.match?(/\A\s*[\[(]?v?#{Regexp.escape(heading_version)}\b/i)
1456
+ first_line.match?(/\A\s*(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b/i) ||
1457
+ first_line.match?(/\A\s*[\[(]?(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b/i)
1090
1458
 
1091
1459
  return text unless heading_like
1092
1460
 
@@ -1096,7 +1464,7 @@ module Gemstar
1096
1464
  end
1097
1465
 
1098
1466
  def strip_leading_heading_separator(text)
1099
- text.sub(/\A\s*(?:#{Regexp.escape("#")}{4,}|[-=]{3,})\s*\n+/, "")
1467
+ text.sub(/\A\s*(?:#{Regexp.escape("#")}{4,}|[-=~`^"']{3,})\s*\n+/, "")
1100
1468
  end
1101
1469
 
1102
1470
  def compare_versions(left, right)
@@ -1105,26 +1473,26 @@ module Gemstar
1105
1473
  left.to_s <=> right.to_s
1106
1474
  end
1107
1475
 
1108
- def metadata_for(package_state_or_name, refresh_if_missing: false)
1476
+ def metadata_for(package_state_or_name, refresh_if_missing: false, force_refresh: false)
1109
1477
  package_state = package_state_or_name.is_a?(Hash) ? package_state_or_name : { name: package_state_or_name, package_scope: "gems" }
1110
1478
  gem_name = package_state[:name]
1111
1479
  cache_key = [package_state[:package_scope], gem_name]
1112
1480
  cached = @metadata_cache[cache_key]
1113
- return nil if cached.equal?(MISSING_METADATA)
1114
- return cached if cached
1481
+ return nil if !force_refresh && cached.equal?(MISSING_METADATA)
1482
+ return cached if !force_refresh && cached
1115
1483
 
1116
1484
  if package_state[:package_scope] != "gems"
1117
1485
  metadata = local_package_metadata(package_state)
1118
1486
  adapter = metadata_adapter_for(package_state)
1119
- remote_metadata = adapter&.meta(cache_only: true)
1487
+ remote_metadata = force_refresh ? nil : adapter&.meta(cache_only: true)
1120
1488
  remote_metadata = adapter&.meta(cache_only: false, force_refresh: true) if remote_metadata.nil? && refresh_if_missing
1121
1489
  metadata = metadata.compact.merge(remote_metadata || {})
1122
1490
  @metadata_cache[cache_key] = metadata || MISSING_METADATA
1123
1491
  return metadata
1124
1492
  end
1125
1493
 
1126
- metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
1127
- if metadata.nil? && refresh_if_missing
1494
+ metadata = force_refresh ? nil : Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
1495
+ if metadata.nil? && (refresh_if_missing || force_refresh)
1128
1496
  metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: false, force_refresh: true)
1129
1497
  end
1130
1498
 
@@ -1143,6 +1511,34 @@ module Gemstar
1143
1511
  package_version = source[:package_version].to_s
1144
1512
  registry_url = source[:registry_url].to_s
1145
1513
  provider_gem = source[:provider_gem].to_s
1514
+ if package_state[:package_scope] == "python"
1515
+ package_name = package_state[:name].to_s if package_name.empty?
1516
+ package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty?
1517
+ registry_url = "https://pypi.org/project/#{package_name}/" if registry_url.empty? && !package_name.empty?
1518
+
1519
+ return {
1520
+ "info" => "Python package `#{package_name}` locked to `#{package_version}` in uv.lock",
1521
+ "project_uri" => registry_url.empty? ? nil : registry_url,
1522
+ "homepage_uri" => absolute_url?(remote) ? remote : nil,
1523
+ "source_code_uri" => repo_url.empty? ? nil : repo_url
1524
+ }
1525
+ end
1526
+
1527
+ if package_state[:package_scope] == "cargo"
1528
+ package_name = package_state[:metadata_package_name].to_s
1529
+ package_name = source[:package_name].to_s if package_name.empty?
1530
+ package_name = package_state[:name].to_s if package_name.empty?
1531
+ package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty?
1532
+ registry_url = "https://crates.io/crates/#{package_name}" if registry_url.empty? && !package_name.empty?
1533
+
1534
+ return {
1535
+ "info" => "Rust crate `#{package_name}` locked to `#{package_version}` in Cargo.lock",
1536
+ "project_uri" => registry_url.empty? ? nil : registry_url,
1537
+ "homepage_uri" => absolute_url?(remote) ? remote : nil,
1538
+ "source_code_uri" => repo_url.empty? ? nil : repo_url
1539
+ }
1540
+ end
1541
+
1146
1542
  package_name = package_state[:name].to_s if package_name.empty? && source[:type] == :npm
1147
1543
  package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty? && source[:type] == :npm
1148
1544
  registry_url = "https://www.npmjs.com/package/#{package_name}" if registry_url.empty? && !package_name.empty?
@@ -1190,6 +1586,11 @@ module Gemstar
1190
1586
 
1191
1587
  def metadata_adapter_for(package_state)
1192
1588
  return Gemstar::RubyGemsMetadata.new(package_state[:name]) if package_state[:package_scope] == "gems"
1589
+ return Gemstar::PyPIMetadata.new(package_state[:name]) if package_state[:package_scope] == "python"
1590
+ if package_state[:package_scope] == "cargo"
1591
+ package_name = package_state[:metadata_package_name] || package_state.dig(:source, :package_name) || package_state[:name]
1592
+ return Gemstar::CratesIOMetadata.new(package_name)
1593
+ end
1193
1594
  return nil unless package_state[:package_scope] == "js"
1194
1595
 
1195
1596
  provider_gem = package_state.dig(:source, :provider_gem)
@@ -1224,7 +1625,15 @@ module Gemstar
1224
1625
  when :home
1225
1626
  '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 .8 1.2 6.3v8.9h4.3V10h5v5.2h4.3V6.3L8 .8Zm5.2 13.3h-1.8V8.9H4.6v5.2H2.8V6.8L8 2.6l5.2 4.2v7.3Z"/></svg>'
1226
1627
  when :rubygems
1227
- '<svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="2.6" fill="#fff"/><path fill="#111" d="m8 2.35 4.55 2.63v5.24L8 12.85l-4.55-2.63V4.98L8 2.35Zm0 1.3L4.58 5.62v3.96L8 11.55l3.42-1.97V5.62L8 3.65Zm0 1.07 2.5 1.44v2.88L8 10.48 5.5 9.04V6.16L8 4.72Z"/></svg>'
1628
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="#cc342d" d="M4.2 2.2h7.6l2.7 4.1L8 14.2 1.5 6.3l2.7-4.1Z"/><path fill="#ef5b55" d="m4.2 2.2 1.5 4.1H1.5l2.7-4.1Zm7.6 0 2.7 4.1h-4.2l1.5-4.1Z"/><path fill="#a91f1a" d="M5.7 6.3h4.6L8 14.2 5.7 6.3Z"/><path fill="#fff" fill-opacity=".42" d="m5 3.15.7 2H3.3l1.3-2h.4Z"/></svg>'
1629
+ when :cargo
1630
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" d="m2.2 5 5.8-3 5.8 3v6L8 14l-5.8-3V5Z"/><path fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" d="M2.4 5 8 8l5.6-3M8 8v6M5.2 3.45 11 6.5"/></svg>'
1631
+ when :javascript
1632
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.2"/><text x="8" y="10.8" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, monospace" font-size="6.3" font-weight="700" fill="currentColor">JS</text></svg>'
1633
+ when :python
1634
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" d="M8 1.8H5.6c-1.7 0-2.4.8-2.4 2.3v2.1h5.6v1.6H4.5C2.8 7.8 2 8.7 2 10.2c0 1.7 1 2.5 2.6 2.5H7"/><path fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" d="M8 14.2h2.4c1.7 0 2.4-.8 2.4-2.3V9.8H7.2V8.2h4.3c1.7 0 2.5-.9 2.5-2.4 0-1.7-1-2.5-2.6-2.5H9"/><circle cx="6" cy="4.2" r=".7" fill="currentColor"/><circle cx="10" cy="11.8" r=".7" fill="currentColor"/></svg>'
1635
+ when :package
1636
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" d="m2.2 5 5.8-3 5.8 3v6L8 14l-5.8-3V5ZM2.4 5 8 8l5.6-3M8 8v6"/></svg>'
1228
1637
  when :info
1229
1638
  '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8" cy="4.5" r="0.9" fill="currentColor"/><path d="M8 7v4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
1230
1639
  else
@@ -1240,6 +1649,10 @@ module Gemstar
1240
1649
  end
1241
1650
  end
1242
1651
 
1652
+ def unchanged_worktree_package?
1653
+ @selected_to_revision_id == "worktree" && @selected_gem && @selected_gem[:status] == :unchanged
1654
+ end
1655
+
1243
1656
  def range_label(gem_state)
1244
1657
  old_version = gem_state[:old_version]
1245
1658
  new_version = gem_state[:new_version]
@@ -1251,7 +1664,7 @@ module Gemstar
1251
1664
  end
1252
1665
 
1253
1666
  def previous_section_version(versions, current_version)
1254
- ordered_versions = versions.sort_by { |version| -sortable_version_number(version) }
1667
+ ordered_versions = versions.sort { |left, right| compare_versions(right, left) }
1255
1668
  current_index = ordered_versions.index(current_version)
1256
1669
  return nil if current_index.nil?
1257
1670
 
@@ -1305,16 +1718,27 @@ module Gemstar
1305
1718
  else
1306
1719
  %(<a href="#{h(fallback_url)}" target="_blank" rel="noreferrer">#{h(fallback_label)}</a>)
1307
1720
  end
1721
+ github_cli_action = render_github_cli_release_button(version, repo_url)
1308
1722
 
1309
1723
  {
1310
1724
  version: version,
1311
1725
  title: version,
1312
1726
  kind: :current,
1313
1727
  previous_version: fallback_previous_version_for(gem_state, previous_sections),
1314
- html: "<p>No release information available. Check #{fallback_link} for more information.</p>"
1728
+ html: "<p>No release information available. Check #{fallback_link} for more information.</p>#{github_cli_action}"
1315
1729
  }
1316
1730
  end
1317
1731
 
1732
+ def render_github_cli_release_button(version, repo_url)
1733
+ return "" unless repo_url.to_s.include?("github.com")
1734
+
1735
+ <<~HTML
1736
+ <div class="detail-inline-actions">
1737
+ <button type="button" class="action" data-detail-use-gh data-release-version="#{h(version)}">Use GitHub CLI</button>
1738
+ </div>
1739
+ HTML
1740
+ end
1741
+
1318
1742
  def fallback_previous_version_for(gem_state, previous_sections)
1319
1743
  return gem_state[:old_version] if gem_state[:new_version]
1320
1744
  return previous_sections.first[:version] if previous_sections.any?
@@ -1361,18 +1785,19 @@ module Gemstar
1361
1785
  html
1362
1786
  end
1363
1787
 
1364
- def detail_query(project:, from:, to:, filter:, scope:, package:)
1365
- "/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, scope: scope, package: package)}"
1788
+ def detail_query(project:, from:, to:, filter:, scope:, package:, package_id: nil)
1789
+ "/detail?#{URI.encode_www_form({project: project, from: from, to: to, filter: filter, scope: scope, package: package, package_id: package_id}.compact)}"
1366
1790
  end
1367
1791
 
1368
- def project_query(project:, from:, to:, filter:, scope:, package:)
1792
+ def project_query(project:, from:, to:, filter:, scope:, package:, package_id: nil)
1369
1793
  params = {
1370
1794
  project: project,
1371
1795
  from: from,
1372
1796
  to: to,
1373
1797
  filter: filter,
1374
1798
  scope: scope,
1375
- package: package
1799
+ package: package,
1800
+ package_id: package_id
1376
1801
  }.compact
1377
1802
 
1378
1803
  "/?#{URI.encode_www_form(params)}"