gemstar 1.0.4 → 1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@
3
3
  require_relative "command"
4
4
  require "concurrent-ruby"
5
5
  require "tmpdir"
6
+ require "pathname"
7
+ require "uri"
6
8
 
7
9
  module Gemstar
8
10
  module Commands
@@ -16,39 +18,39 @@ module Gemstar
16
18
  attr_reader :lockfile_full_path
17
19
  attr_reader :output_file
18
20
  attr_reader :output_format
21
+ attr_reader :project
22
+ attr_reader :ecosystem
23
+ attr_reader :since
24
+ attr_reader :considered_commits
25
+ attr_reader :since_cutoff_commit
19
26
 
20
27
  def initialize(options)
21
28
  super
22
29
 
23
30
  @debug_gem_regex = Regexp.new(options[:debug_gem_regex] || ENV["GEMSTAR_DEBUG_GEM_REGEX"] || ".*")
24
31
 
32
+ @since = normalize_since(options[:since])
25
33
  @from = options[:from] || "HEAD"
26
34
  @to = options[:to]
27
35
  @lockfile = options[:lockfile] || "Gemfile.lock"
28
36
  @output_format = normalize_output_format(options[:format] || options[:output_format])
29
37
  @output_file = options[:output_file] || default_output_file
38
+ @project = options[:project] ? Gemstar::Project.from_cli_argument(options[:project]) : nil
39
+ @ecosystem = normalize_ecosystem(options[:ecosystem])
40
+ @considered_commits = []
41
+ @since_cutoff_commit = nil
30
42
 
31
- @git_repo = Gemstar::GitRepo.new(File.dirname(@lockfile))
43
+ @git_repo = project ? project.git_repo : Gemstar::GitRepo.new(File.dirname(@lockfile))
44
+ @from = resolve_since_commit if since
32
45
  end
33
46
 
34
47
  def run
35
- # logic to diff from/to, find updated gems, fetch changelogs
36
-
37
- #+++ edit_gitignore?
38
-
39
- @lockfile_full_path = git_repo.get_full_path(File.basename(lockfile))
40
- puts "Lockfile path: #{lockfile_full_path}"
41
-
42
- old = LockFile.new(content: git_repo.show_blob_at(@from, lockfile_full_path))
43
- new = @to ?
44
- LockFile.new(content: git_repo.show_blob_at(@to, lockfile_full_path)) :
45
- LockFile.new(path: lockfile)
46
-
47
- collect_updates(new_lockfile: new, old_lockfile: old)
48
+ project ? run_project_diff : run_lockfile_diff
49
+ @considered_commits = collect_considered_commits
48
50
 
49
51
  rendered_output = output_renderer.render_diff(self)
50
52
  File.write(output_file, rendered_output)
51
- puts "✅ Changelog report created: #{File.expand_path(output_file)}"
53
+ puts "✅ Changelog report created: #{output_file_url}"
52
54
 
53
55
  if failed.any?
54
56
  puts "\n⚠️ The following gems failed to process:"
@@ -65,11 +67,46 @@ module Gemstar
65
67
  :html
66
68
  end
67
69
 
70
+ def normalize_ecosystem(value)
71
+ normalized = value.to_s.strip.downcase
72
+ return "all" if normalized.empty?
73
+ return normalized if %w[all gems js].include?(normalized)
74
+
75
+ raise Thor::Error, "Unsupported ecosystem #{value.inspect}. Expected one of: all, gems, js"
76
+ end
77
+
78
+ def normalize_since(value)
79
+ normalized = value.to_s.strip
80
+ return nil if normalized.empty?
81
+
82
+ if @options[:from].to_s.strip != ""
83
+ raise Thor::Error, "--since cannot be combined with --from"
84
+ end
85
+
86
+ normalized.match?(/\bago\z/i) ? normalized : "#{normalized} ago"
87
+ end
88
+
89
+ def resolve_since_commit
90
+ commit = git_repo.commit_before(since)
91
+ @since_cutoff_commit = git_repo.commit_info(commit)
92
+ commit
93
+ end
94
+
95
+ def log_since_cutoff
96
+ return unless since
97
+
98
+ puts "Since cutoff: #{since} -> #{format_commit(since_cutoff_commit, fallback_revision: from)}"
99
+ end
100
+
68
101
  def default_output_file
69
102
  extension = output_format == :markdown ? "md" : "html"
70
103
  File.join(Dir.tmpdir, "gem_update_changelog.#{extension}")
71
104
  end
72
105
 
106
+ def output_file_url
107
+ "file://#{URI::DEFAULT_PARSER.escape(File.expand_path(output_file))}"
108
+ end
109
+
73
110
  def output_renderer
74
111
  @output_renderer ||= case output_format
75
112
  when :markdown
@@ -79,8 +116,42 @@ module Gemstar
79
116
  end
80
117
  end
81
118
 
82
- def build_entry(gem_name:, old_version:, new_version:)
83
- metadata = Gemstar::RubyGemsMetadata.new(gem_name)
119
+ def collect_considered_commits
120
+ git_repo.commits_between(from, commit_log_to_revision)
121
+ rescue StandardError => e
122
+ warn "Could not collect considered commits: #{e.message}"
123
+ []
124
+ end
125
+
126
+ def format_commit(commit, fallback_revision:)
127
+ return fallback_revision.to_s if commit.nil?
128
+
129
+ date = commit[:authored_at].to_s.split("T").first
130
+ label = [commit[:short_sha] || commit[:id], commit[:subject]].compact.join(" ")
131
+ date.empty? ? label : "#{label} (#{date})"
132
+ end
133
+
134
+ public :format_commit
135
+
136
+ def commit_log_to_revision
137
+ return "HEAD" if to.nil? || to == "worktree"
138
+
139
+ to
140
+ end
141
+
142
+ def project_name
143
+ return project.name if project
144
+
145
+ Pathname.getwd.basename.to_s
146
+ end
147
+
148
+ public :project_name
149
+
150
+ def build_entry(package_state:)
151
+ package_name = package_state[:name]
152
+ old_version = package_state[:old_version]
153
+ new_version = package_state[:new_version]
154
+ metadata = metadata_for(package_state)
84
155
  repo_url = metadata.repo_uri
85
156
  changelog = Gemstar::ChangeLog.new(metadata)
86
157
  sections = changelog.extract_relevant_sections(old_version, new_version)
@@ -102,14 +173,17 @@ module Gemstar
102
173
  end
103
174
  end
104
175
 
105
- homepage_url = metadata.meta["homepage_uri"] || metadata.meta["source_code_uri"] || "https://rubygems.org/gems/#{gem_name}"
176
+ homepage_url = metadata.meta["homepage_uri"] || metadata.meta["source_code_uri"] || "https://rubygems.org/gems/#{package_name}"
106
177
  description = metadata.meta["info"]
107
178
 
108
179
  entry = {
109
180
  old: old_version,
110
181
  new: new_version,
111
182
  homepage_url: homepage_url,
112
- description: description
183
+ description: description,
184
+ package_scope: package_state[:package_scope],
185
+ package_type_label: package_state[:package_type_label],
186
+ version_label: package_state[:version_label]
113
187
  }
114
188
  entry[:sections] = sections unless sections.nil? || sections.empty?
115
189
  entry[:compare_url] = compare_url if compare_url
@@ -133,29 +207,81 @@ module Gemstar
133
207
  entry
134
208
  end
135
209
 
136
- def collect_updates(new_lockfile:, old_lockfile:)
210
+ def metadata_for(package_state)
211
+ if package_state[:package_scope] == "js"
212
+ Gemstar::NpmMetadata.new(package_state[:name])
213
+ else
214
+ Gemstar::RubyGemsMetadata.new(package_state[:name])
215
+ end
216
+ end
217
+
218
+ def run_lockfile_diff
219
+ validate_lockfile_ecosystem!
220
+
221
+ @lockfile_full_path = git_repo.get_full_path(File.basename(lockfile))
222
+ puts "Lockfile path: #{lockfile_full_path}"
223
+ log_since_cutoff
224
+
225
+ old = LockFile.new(content: git_repo.show_blob_at(@from, lockfile_full_path))
226
+ new = @to ?
227
+ LockFile.new(content: git_repo.show_blob_at(@to, lockfile_full_path)) :
228
+ LockFile.new(path: lockfile)
229
+
230
+ collect_lockfile_updates(new_lockfile: new, old_lockfile: old)
231
+ end
232
+
233
+ def run_project_diff
234
+ puts "Project path: #{project.directory}"
235
+ log_since_cutoff
236
+
237
+ changed_states = project.gem_states(from_revision_id: from, to_revision_id: to || "worktree")
238
+ .select { |package_state| include_package_state?(package_state) }
239
+ .reject { |package_state| package_state[:status] == :unchanged }
240
+ changed_states = disambiguate_duplicate_names(changed_states)
241
+ collect_project_updates(changed_states)
242
+ end
243
+
244
+ def collect_lockfile_updates(new_lockfile:, old_lockfile:)
245
+ package_states = new_lockfile.specs.keys.sort.map do |gem_name|
246
+ old_version = old_lockfile.specs[gem_name]
247
+ new_version = new_lockfile.specs[gem_name]
248
+ next if old_version == new_version
249
+
250
+ {
251
+ name: gem_name,
252
+ display_name: gem_name,
253
+ package_scope: "gems",
254
+ package_type_label: "Gem",
255
+ old_version: old_version,
256
+ new_version: new_version,
257
+ version_label: version_label(old_version, new_version)
258
+ }
259
+ end.compact
260
+
261
+ collect_project_updates(package_states)
262
+ end
263
+
264
+ def collect_project_updates(package_states)
137
265
  @updates = {}
138
266
  @failed = []
139
267
  mutex = Mutex.new
140
268
  pool = Concurrent::FixedThreadPool.new(10)
141
269
 
142
- new_lockfile.specs.keys.sort.each do |gem_name|
270
+ package_states.each do |package_state|
143
271
  pool.post do
144
- next unless @debug_gem_regex.match?(gem_name)
145
-
146
- old_version = old_lockfile.specs[gem_name]
147
- new_version = new_lockfile.specs[gem_name]
148
- next if old_version == new_version
272
+ package_name = package_state[:name]
273
+ next unless @debug_gem_regex.match?(package_name)
149
274
 
150
- puts "#{gem_name} (#{old_version || "new"} #{new_version})..."
275
+ puts "#{package_state[:display_name] || package_name} (#{package_state[:version_label]})..."
151
276
 
152
277
  begin
153
- entry = build_entry(gem_name: gem_name, old_version: old_version, new_version: new_version)
278
+ entry = build_entry(package_state: package_state)
279
+ display_name = package_state[:display_name] || package_name
154
280
 
155
- mutex.synchronize { updates[gem_name] = entry }
281
+ mutex.synchronize { updates[display_name] = entry }
156
282
  rescue => e
157
- mutex.synchronize { failed << [gem_name, e.message] }
158
- puts "⚠️ Failed to process #{gem_name}: #{e.message}"
283
+ mutex.synchronize { failed << [package_name, e.message] }
284
+ puts "⚠️ Failed to process #{package_name}: #{e.message}"
159
285
  end
160
286
  end
161
287
  end
@@ -165,6 +291,46 @@ module Gemstar
165
291
 
166
292
  @updates = updates
167
293
  end
294
+
295
+ def include_package_state?(package_state)
296
+ ecosystem == "all" || package_state[:package_scope] == ecosystem
297
+ end
298
+
299
+ def validate_lockfile_ecosystem!
300
+ return if %w[all gems].include?(ecosystem)
301
+
302
+ raise Thor::Error, "--ecosystem=#{ecosystem} requires --project because lockfile mode only supports gems"
303
+ end
304
+
305
+ def disambiguate_duplicate_names(package_states)
306
+ counts = package_states.each_with_object(Hash.new(0)) do |package_state, index|
307
+ index[package_state[:name]] += 1
308
+ end
309
+
310
+ package_states.map do |package_state|
311
+ next package_state.merge(display_name: package_state[:name]) if counts[package_state[:name]] == 1
312
+
313
+ suffix = case package_state[:package_source_file]
314
+ when :importmap
315
+ "importmap"
316
+ when :package_lock
317
+ "package-lock"
318
+ else
319
+ package_state[:package_scope]
320
+ end
321
+
322
+ package_state.merge(display_name: "#{package_state[:name]} (#{suffix})")
323
+ end
324
+ end
325
+
326
+ def version_label(old_version, new_version)
327
+ return "new → #{new_version}" if old_version.nil? && !new_version.nil?
328
+ return "#{old_version} → removed" if !old_version.nil? && new_version.nil?
329
+ return new_version.to_s if old_version == new_version
330
+
331
+ "#{old_version} → #{new_version}"
332
+ end
333
+
168
334
  end
169
335
  end
170
336
  end
@@ -1,5 +1,7 @@
1
1
  require_relative "command"
2
+ require "socket"
2
3
  require "shellwords"
4
+ require "rbconfig"
3
5
 
4
6
  module Gemstar
5
7
  module Commands
@@ -14,14 +16,18 @@ module Gemstar
14
16
  attr_reader :port
15
17
  attr_reader :project_inputs
16
18
  attr_reader :reload
19
+ attr_reader :open_browser
20
+ attr_reader :explicit_port
17
21
 
18
22
  def initialize(options)
19
23
  super
20
24
 
21
25
  @bind = options[:bind] || DEFAULT_BIND
26
+ @explicit_port = !options[:port].nil?
22
27
  @port = (options[:port] || DEFAULT_PORT).to_i
23
28
  @project_inputs = normalize_project_inputs(options[:project])
24
29
  @reload = options[:reload]
30
+ @open_browser = options[:open]
25
31
  end
26
32
 
27
33
  def run
@@ -34,10 +40,11 @@ module Gemstar
34
40
  require "gemstar/web/app"
35
41
 
36
42
  Gemstar::Config.ensure_home_directory!
43
+ @port = resolve_port
37
44
 
38
45
  projects = load_projects
39
46
  log_loaded_projects(projects)
40
- cache_warmer = start_background_cache_refresh(projects)
47
+ cache_warmer = build_cache_warmer
41
48
  app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory, cache_warmer: cache_warmer)
42
49
  app = Gemstar::RequestLogger.new(app, io: $stderr) if debug_request_logging?
43
50
 
@@ -49,7 +56,8 @@ module Gemstar
49
56
  Host: bind,
50
57
  Port: port,
51
58
  AccessLog: [],
52
- Logger: Gemstar::WEBrickLogger.new($stderr, WEBrick::BasicLog::INFO)
59
+ Logger: Gemstar::WEBrickLogger.new($stderr, WEBrick::BasicLog::INFO),
60
+ StartCallback: server_start_callback(projects, cache_warmer)
53
61
  )
54
62
  end
55
63
 
@@ -122,9 +130,10 @@ module Gemstar
122
130
  def server_arguments_without_reload
123
131
  args = [
124
132
  "server",
125
- "--bind", bind,
126
- "--port", port.to_s
133
+ "--bind", bind
127
134
  ]
135
+ args += ["--port", port.to_s] if explicit_port
136
+ args << "--open" if open_browser
128
137
  project_inputs.each do |project|
129
138
  args << "--project"
130
139
  args << project
@@ -147,14 +156,88 @@ module Gemstar
147
156
  ENV["DEBUG"] == "1"
148
157
  end
149
158
 
150
- def start_background_cache_refresh(projects)
151
- gem_names = projects.flat_map do |project|
152
- project.current_lockfile&.specs&.keys || []
153
- end.uniq.sort
159
+ def resolve_port
160
+ return port if explicit_port
154
161
 
155
- return nil if gem_names.empty?
162
+ find_available_port(starting_at: port)
163
+ end
164
+
165
+ def find_available_port(starting_at:, limit: 100)
166
+ starting_at.upto(starting_at + limit - 1) do |candidate|
167
+ return candidate if port_available?(candidate)
168
+ end
169
+
170
+ raise Thor::Error, "No available port found from #{starting_at} to #{starting_at + limit - 1}"
171
+ end
172
+
173
+ def port_available?(candidate)
174
+ server = TCPServer.new(bind, candidate)
175
+ server.close
176
+ true
177
+ rescue Errno::EADDRINUSE, Errno::EACCES, SocketError
178
+ false
179
+ end
180
+
181
+ def build_cache_warmer
182
+ Gemstar::CacheWarmer.new(io: $stderr, debug: debug_request_logging? || Gemstar.debug?, thread_count: 10)
183
+ end
184
+
185
+ 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")
188
+ end
189
+
190
+ return nil if package_states.empty?
191
+
192
+ cache_warmer.enqueue_many(package_states)
193
+ end
194
+
195
+ def server_start_callback(projects, cache_warmer)
196
+ proc do
197
+ Thread.new do
198
+ sleep 0.15
199
+ start_background_cache_refresh(projects, cache_warmer)
200
+ launch_browser
201
+ end
202
+ end
203
+ end
204
+
205
+ def launch_browser
206
+ return unless open_browser
207
+
208
+ command = browser_command(root_url)
209
+ return unless command
210
+
211
+ pid = spawn(*command, out: File::NULL, err: File::NULL)
212
+ Process.detach(pid)
213
+ rescue StandardError => e
214
+ warn "Could not open browser automatically: #{e.message}"
215
+ end
216
+
217
+ def browser_command(url)
218
+ host_os = RbConfig::CONFIG["host_os"].to_s
219
+
220
+ if host_os.include?("darwin")
221
+ [find_executable("open") || "/usr/bin/open", url]
222
+ elsif host_os.match?(/linux|bsd/)
223
+ executable = find_executable("xdg-open")
224
+ executable ? [executable, url] : nil
225
+ elsif host_os.match?(/mswin|mingw|cygwin/)
226
+ ["cmd", "/c", "start", "", url]
227
+ end
228
+ end
229
+
230
+ def find_executable(name)
231
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
232
+ candidate = File.join(directory, name)
233
+ return candidate if File.file?(candidate) && File.executable?(candidate)
234
+ end
235
+
236
+ nil
237
+ end
156
238
 
157
- Gemstar::CacheWarmer.new(io: $stderr, debug: debug_request_logging? || Gemstar.debug?, thread_count: 10).enqueue_many(gem_names)
239
+ def root_url
240
+ "http://#{bind}:#{port}/"
158
241
  end
159
242
  end
160
243
  end
@@ -0,0 +1,22 @@
1
+ {
2
+ "@rails/actioncable": {
3
+ "provider_gem": "actioncable",
4
+ "repo_url": "https://github.com/rails/rails"
5
+ },
6
+ "@rails/actiontext": {
7
+ "provider_gem": "actiontext",
8
+ "repo_url": "https://github.com/rails/rails"
9
+ },
10
+ "@rails/activestorage": {
11
+ "provider_gem": "activestorage",
12
+ "repo_url": "https://github.com/rails/rails"
13
+ },
14
+ "@hotwired/stimulus-loading": {
15
+ "provider_gem": "stimulus-rails",
16
+ "repo_url": "https://github.com/hotwired/stimulus-rails"
17
+ },
18
+ "@hotwired/turbo-rails": {
19
+ "provider_gem": "turbo-rails",
20
+ "repo_url": "https://github.com/hotwired/turbo-rails"
21
+ }
22
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "aws-*": {
3
+ "changelog": {
4
+ "raw_base": "https://raw.githubusercontent.com/aws/aws-sdk-ruby/refs/heads/version-3/gems/{gem_name}",
5
+ "paths": ["CHANGELOG.md"],
6
+ "branches": [""]
7
+ }
8
+ }
9
+ }
@@ -54,9 +54,7 @@ module Gemstar
54
54
  # If it looks like a pure date (or you want to support "date only"),
55
55
  # map it to "latest commit before date on default_branch".
56
56
  if revish =~ /\d{4}-\d{2}-\d{2}/ || revish =~ /\d{1,2}:\d{2}/i
57
- sha = run_git_command(["rev-list", "-1", "--before", revish, default_branch])
58
- raise "No commit before #{revish} on #{default_branch}" if sha.empty?
59
- return sha
57
+ return commit_before(revish, default_branch:)
60
58
  end
61
59
 
62
60
  # Otherwise let Git parse whatever the user typed.
@@ -65,6 +63,13 @@ module Gemstar
65
63
  sha
66
64
  end
67
65
 
66
+ def commit_before(time_expression, default_branch: "HEAD")
67
+ sha = run_git_command(["rev-list", "-1", "--before", time_expression, default_branch])
68
+ raise "No commit before #{time_expression} on #{default_branch}" if sha.empty?
69
+
70
+ sha
71
+ end
72
+
68
73
  def show_blob_at(revish, path)
69
74
  commit = resolve_commit(revish)
70
75
  run_git_command(["show", "#{commit}:#{path}"])
@@ -101,8 +106,41 @@ module Gemstar
101
106
  run_git_command(command, in_directory: tree_root_directory)
102
107
  end
103
108
 
109
+ def commits_between(from_revision, to_revision = "HEAD")
110
+ return [] if tree_root_directory.nil? || tree_root_directory.empty?
111
+
112
+ range = "#{from_revision}..#{to_revision || "HEAD"}"
113
+ format = "%H%x1f%h%x1f%aI%x1f%s"
114
+ output = try_git_command(["log", "--reverse", "--pretty=format:#{format}", range], in_directory: tree_root_directory)
115
+ return [] if output.nil? || output.empty?
116
+
117
+ output.lines.filter_map { |line| parse_commit_log_line(line) }
118
+ end
119
+
120
+ def commit_info(revision)
121
+ return nil if tree_root_directory.nil? || tree_root_directory.empty?
122
+
123
+ format = "%H%x1f%h%x1f%aI%x1f%s"
124
+ output = try_git_command(["show", "-s", "--pretty=format:#{format}", revision], in_directory: tree_root_directory)
125
+ return nil if output.nil? || output.empty?
126
+
127
+ parse_commit_log_line(output)
128
+ end
129
+
104
130
  private
105
131
 
132
+ def parse_commit_log_line(line)
133
+ full_sha, short_sha, authored_at, subject = line.strip.split("\u001f", 4)
134
+ return nil if full_sha.nil? || full_sha.empty?
135
+
136
+ {
137
+ id: full_sha,
138
+ short_sha: short_sha,
139
+ authored_at: authored_at,
140
+ subject: subject
141
+ }
142
+ end
143
+
106
144
  def normalize_remote_url(remote)
107
145
  normalized = remote.strip.sub(%r{\.git\z}, "")
108
146