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,6 +1,9 @@
1
1
  require_relative "command"
2
+ require_relative "../project"
3
+ require "rack/mock"
2
4
  require "socket"
3
5
  require "shellwords"
6
+ require "uri"
4
7
  require "rbconfig"
5
8
 
6
9
  module Gemstar
@@ -31,6 +34,8 @@ module Gemstar
31
34
  end
32
35
 
33
36
  def run
37
+ projects = load_projects
38
+
34
39
  restart_with_rerun if reload_requested?
35
40
 
36
41
  require "rackup"
@@ -42,23 +47,28 @@ module Gemstar
42
47
  Gemstar::Config.ensure_home_directory!
43
48
  @port = resolve_port
44
49
 
45
- projects = load_projects
46
50
  log_loaded_projects(projects)
47
51
  cache_warmer = build_cache_warmer
48
- app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory, cache_warmer: cache_warmer)
52
+ web_app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory, cache_warmer: cache_warmer)
53
+ cache_warmer.detail_cache_fetcher = build_detail_cache_fetcher(web_app)
54
+ app = web_app
49
55
  app = Gemstar::RequestLogger.new(app, io: $stderr) if debug_request_logging?
50
56
 
51
57
  puts "Gemstar server listening on http://#{bind}:#{port}"
52
58
  puts "Config home: #{Gemstar::Config.home_directory}"
53
- Rackup::Server.start(
54
- app: app,
55
- server: "webrick",
56
- Host: bind,
57
- Port: port,
58
- AccessLog: [],
59
- Logger: Gemstar::WEBrickLogger.new($stderr, WEBrick::BasicLog::INFO),
60
- StartCallback: server_start_callback(projects, cache_warmer)
61
- )
59
+ begin
60
+ Rackup::Server.start(
61
+ app: app,
62
+ server: "webrick",
63
+ Host: bind,
64
+ Port: port,
65
+ AccessLog: [],
66
+ Logger: Gemstar::WEBrickLogger.new($stderr, WEBrick::BasicLog::INFO),
67
+ StartCallback: server_start_callback(projects, cache_warmer)
68
+ )
69
+ ensure
70
+ stop_background_cache_refresh(cache_warmer)
71
+ end
62
72
  end
63
73
 
64
74
  private
@@ -143,6 +153,9 @@ module Gemstar
143
153
 
144
154
  def load_projects
145
155
  project_inputs.map { |input| Gemstar::Project.from_cli_argument(input) }
156
+ rescue Gemstar::Project::UnsupportedProjectError => e
157
+ warn e.message
158
+ exit 1
146
159
  end
147
160
 
148
161
  def log_loaded_projects(projects)
@@ -183,8 +196,21 @@ module Gemstar
183
196
  end
184
197
 
185
198
  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")
199
+ package_states = projects.flat_map.with_index do |project, project_index|
200
+ from_revision_id = project.default_from_revision_id
201
+ to_revision_id = "worktree"
202
+ states = project.gem_states(from_revision_id: from_revision_id, to_revision_id: to_revision_id)
203
+ detail_cache_contexts = detail_cache_contexts_for(
204
+ project: project,
205
+ project_index: project_index,
206
+ from_revision_id: from_revision_id,
207
+ to_revision_id: to_revision_id,
208
+ package_states: states
209
+ )
210
+
211
+ states.map do |state|
212
+ state.merge(detail_cache_contexts: detail_cache_contexts_for_package(state, detail_cache_contexts))
213
+ end
188
214
  end
189
215
 
190
216
  return nil if package_states.empty?
@@ -192,16 +218,75 @@ module Gemstar
192
218
  cache_warmer.enqueue_many(package_states)
193
219
  end
194
220
 
221
+ def detail_cache_contexts_for(project:, project_index:, from_revision_id:, to_revision_id:, package_states:)
222
+ default_filter = package_states.any? { |state| state[:status] != :unchanged } ? "updated" : "all"
223
+ default_scope = project.package_scope_options.map { |option| option[:id] } == ["gems"] ? "gems" : "all"
224
+
225
+ [
226
+ {
227
+ project: project_index,
228
+ from: from_revision_id,
229
+ to: to_revision_id,
230
+ filter: default_filter,
231
+ scope: default_scope
232
+ },
233
+ {
234
+ project: project_index,
235
+ from: from_revision_id,
236
+ to: to_revision_id,
237
+ filter: "all",
238
+ scope: default_scope
239
+ }
240
+ ].uniq
241
+ end
242
+
243
+ def detail_cache_contexts_for_package(package_state, base_contexts)
244
+ package_scope = package_state[:package_scope]
245
+
246
+ base_contexts.flat_map do |context|
247
+ [
248
+ context,
249
+ context.merge(scope: package_scope)
250
+ ]
251
+ end.uniq
252
+ end
253
+
254
+ def build_detail_cache_fetcher(app)
255
+ lambda do |package_state, context|
256
+ params = context.merge(package: package_state[:name])
257
+ env = Rack::MockRequest.env_for(
258
+ "/detail?#{URI.encode_www_form(params)}",
259
+ "REQUEST_METHOD" => "GET",
260
+ "gemstar.detail_cache_warm" => true
261
+ )
262
+ response = app.call(env)
263
+ body = response[2]
264
+ body.close if body.respond_to?(:close)
265
+ end
266
+ end
267
+
195
268
  def server_start_callback(projects, cache_warmer)
196
269
  proc do
197
- Thread.new do
270
+ @server_start_thread = Thread.new do
271
+ Thread.current.name = "gemstar-server-start" if Thread.current.respond_to?(:name=)
198
272
  sleep 0.15
199
273
  start_background_cache_refresh(projects, cache_warmer)
200
274
  launch_browser
275
+ rescue StandardError => e
276
+ warn "Background cache refresh failed: #{e.class}: #{e.message}"
201
277
  end
202
278
  end
203
279
  end
204
280
 
281
+ def stop_background_cache_refresh(cache_warmer)
282
+ cache_warmer.shutdown if cache_warmer.respond_to?(:shutdown)
283
+
284
+ return unless @server_start_thread&.alive?
285
+
286
+ @server_start_thread.kill
287
+ @server_start_thread.join(0.5)
288
+ end
289
+
205
290
  def launch_browser
206
291
  return unless open_browser
207
292
 
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open-uri"
5
+ require "time"
6
+ require "uri"
7
+ require_relative "version"
8
+
9
+ module Gemstar
10
+ class CratesIOMetadata
11
+ USER_AGENT = "gemstar/#{Gemstar::VERSION} (+https://github.com/FDj/gemstar)"
12
+
13
+ def initialize(crate_name)
14
+ @crate_name = crate_name
15
+ end
16
+
17
+ attr_reader :crate_name
18
+
19
+ alias_method :gem_name, :crate_name
20
+
21
+ def cache_key
22
+ "crates-io-#{crate_name}"
23
+ end
24
+
25
+ def meta(cache_only: false, force_refresh: false)
26
+ return @meta if !cache_only && defined?(@meta)
27
+
28
+ parsed = raw_meta(cache_only: cache_only, force_refresh: force_refresh)
29
+ normalized = normalize_meta(parsed)
30
+ @meta = normalized unless cache_only
31
+ normalized
32
+ end
33
+
34
+ def repo_uri(cache_only: false, force_refresh: false)
35
+ resolved_meta = meta(cache_only: cache_only, force_refresh: force_refresh)
36
+ return nil unless resolved_meta
37
+ return @repo_uri if !cache_only && defined?(@repo_uri)
38
+
39
+ uri = normalize_repo_uri(resolved_meta["source_code_uri"])
40
+ @repo_uri = uri unless cache_only
41
+ uri
42
+ end
43
+
44
+ def changelog_sections(versions: nil, cache_only: false, force_refresh: false, use_github_cli: false)
45
+ requested_versions = Array(versions).compact
46
+ changelog = Gemstar::ChangeLog.new(self)
47
+ if requested_versions.empty?
48
+ changelog.sections(cache_only: cache_only, force_refresh: force_refresh)
49
+ else
50
+ changelog.sections_for_versions(requested_versions, cache_only: cache_only, force_refresh: force_refresh, use_github_cli: use_github_cli)
51
+ end
52
+ end
53
+
54
+ def registry_release_dates(cache_only: false, force_refresh: false)
55
+ parsed = raw_meta(cache_only: cache_only, force_refresh: force_refresh)
56
+ Array(parsed&.dig("versions")).each_with_object({}) do |version, dates|
57
+ number = version["num"]
58
+ created_at = version["created_at"]
59
+ next if number.to_s.empty? || created_at.to_s.empty?
60
+
61
+ dates[number] = Time.parse(created_at).utc.strftime("%b %-d, %Y")
62
+ end
63
+ rescue JSON::ParserError, ArgumentError
64
+ {}
65
+ end
66
+
67
+ def warm_cache(versions: nil)
68
+ meta
69
+ repo_uri
70
+ changelog_sections(versions: versions)
71
+ end
72
+
73
+ def discover_github_tag_sections?
74
+ true
75
+ end
76
+
77
+ def github_tag_candidates(version)
78
+ raw = version.to_s
79
+ [
80
+ raw,
81
+ (raw.start_with?("v") ? raw : "v#{raw}"),
82
+ "#{crate_name}-#{raw}",
83
+ "#{crate_name}-v#{raw}",
84
+ "#{crate_name}@#{raw}"
85
+ ].uniq
86
+ end
87
+
88
+ def github_tag_matches?(tag_name)
89
+ decoded = URI.decode_www_form_component(tag_name.to_s.split("?").first.to_s)
90
+ return true if decoded.match?(/\Av?\d/)
91
+
92
+ decoded.match?(/\A#{Regexp.escape(crate_name)}(?:-|@)v?\d/i)
93
+ end
94
+
95
+ private
96
+
97
+ def raw_meta(cache_only: false, force_refresh: false)
98
+ json = if cache_only
99
+ Cache.peek(cache_key)
100
+ else
101
+ url = "https://crates.io/api/v1/crates/#{URI.encode_www_form_component(crate_name)}"
102
+ Cache.fetch(cache_key, force: force_refresh) do
103
+ options = {read_timeout: 8}
104
+ options["User-Agent"] = USER_AGENT
105
+ URI.parse(url).open(options).read
106
+ end
107
+ end
108
+
109
+ JSON.parse(json) if json
110
+ end
111
+
112
+ def normalize_meta(parsed)
113
+ return nil unless parsed.is_a?(Hash)
114
+
115
+ crate = parsed["crate"] || {}
116
+ name = crate["name"] || crate["id"] || crate_name
117
+ {
118
+ "name" => name,
119
+ "version" => crate["max_version"] || crate["newest_version"],
120
+ "info" => crate["description"],
121
+ "homepage_uri" => crate["homepage"],
122
+ "source_code_uri" => crate["repository"],
123
+ "project_uri" => "https://crates.io/crates/#{name}",
124
+ "documentation_uri" => crate["documentation"]
125
+ }
126
+ end
127
+
128
+ def normalize_repo_uri(uri)
129
+ value = uri.to_s
130
+ return "" if value.empty?
131
+
132
+ value = value.sub(/\Agit\+/, "")
133
+ value = value.sub(/\Agit:\/\//, "https://")
134
+ value = value.sub(/\Ahttp:\/\//, "https://")
135
+ value = value.gsub(/\.git\z/, "")
136
+ value = value[%r{\Ahttps?://github\.com/[^/]+/[^/]+}] || value if value.include?("github.com")
137
+ value
138
+ end
139
+ end
140
+ end
@@ -5,5 +5,15 @@
5
5
  "paths": ["CHANGELOG.md"],
6
6
  "branches": [""]
7
7
  }
8
+ },
9
+ "thruster": {
10
+ "changelog": {
11
+ "raw_base": "https://raw.githubusercontent.com/basecamp/thruster/v{version}",
12
+ "paths": ["CHANGELOG.md"],
13
+ "branches": [""],
14
+ "version_overrides": {
15
+ "0.1.22@2026-07-16": "0.1.23"
16
+ }
17
+ }
8
18
  }
9
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
 
@@ -78,6 +78,8 @@ module Gemstar
78
78
 
79
79
  def icon_for(info)
80
80
  return "📦" if info[:package_scope] == "js"
81
+ return "🐍" if info[:package_scope] == "python"
82
+ return "🦀" if info[:package_scope] == "cargo"
81
83
  return "🐙" if info[:homepage_url]&.include?("github.com")
82
84
 
83
85
  "💎"