gemstar 1.0.2 → 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.
@@ -1,8 +1,17 @@
1
1
  # frozen_string_literal: true
2
+ require "cgi"
3
+ require "json"
2
4
 
3
5
  module Gemstar
4
6
  class ChangeLog
5
7
  @@candidates_found = Hash.new(0)
8
+ DEFAULT_CHANGELOG_PATHS = %w[
9
+ CHANGELOG.md releases.md CHANGES.md
10
+ Changelog.md changelog.md ChangeLog.md
11
+ Changes.md changes.md
12
+ HISTORY.md History.md history.md
13
+ History CHANGELOG.rdoc
14
+ ].freeze
6
15
 
7
16
  def initialize(metadata)
8
17
  @metadata = metadata
@@ -19,23 +28,84 @@ module Gemstar
19
28
  end
20
29
 
21
30
  def sections(cache_only: false, force_refresh: false)
22
- return @sections if !cache_only && defined?(@sections)
31
+ return @sections if !cache_only && defined?(@sections) && !force_refresh
23
32
 
24
- result = begin
25
- s = parse_changelog_sections(cache_only: cache_only, force_refresh: force_refresh)
26
- if s.nil? || s.empty?
27
- s = parse_github_release_sections(cache_only: cache_only, force_refresh: force_refresh)
33
+ metadata_key = @metadata.respond_to?(:cache_key) ? @metadata.cache_key : @metadata.gem_name
34
+ cache_key = "sections-v4-#{metadata_key}"
35
+ serialized = if cache_only
36
+ Cache.peek(cache_key)
37
+ else
38
+ Cache.fetch(cache_key, force: force_refresh) do
39
+ JSON.generate(compute_sections(force_refresh: force_refresh))
28
40
  end
41
+ end
29
42
 
30
- pp @@candidates_found if Gemstar.debug? && !cache_only
31
-
32
- s
43
+ result = if serialized
44
+ decode_sections(serialized)
45
+ elsif cache_only
46
+ nil
47
+ else
48
+ compute_sections(force_refresh: force_refresh)
33
49
  end
34
50
 
35
51
  @sections = result unless cache_only
36
52
  result
37
53
  end
38
54
 
55
+ def sections_for_versions(versions, cache_only: false, force_refresh: false)
56
+ requested_versions = normalize_requested_versions(versions)
57
+ return {} if requested_versions.empty?
58
+
59
+ cached_sections = sections(cache_only: true) || {}
60
+ result = cached_sections.select { |version, _| requested_versions.include?(normalize_version_key(version)) }
61
+ return result if cache_only
62
+
63
+ changelog_sections = parse_changelog_sections(cache_only: false, force_refresh: force_refresh) || {}
64
+ changelog_sections.each do |version, lines|
65
+ result[version] ||= lines if requested_versions.include?(normalize_version_key(version))
66
+ end
67
+
68
+ repo_uri = @metadata&.repo_uri(cache_only: false, force_refresh: force_refresh)
69
+ if repo_uri&.include?("github.com")
70
+ missing_versions = requested_versions - result.keys.map { |version| normalize_version_key(version) }
71
+ missing_versions.each do |version|
72
+ specific_release = parse_specific_github_release_pages(
73
+ repo_uri,
74
+ version,
75
+ cache_only: false,
76
+ force_refresh: force_refresh
77
+ )
78
+ result.merge!(specific_release) if specific_release
79
+ end
80
+ end
81
+
82
+ result
83
+ end
84
+
85
+ def compute_sections(force_refresh: false)
86
+ changelog_sections = parse_changelog_sections(cache_only: false, force_refresh: force_refresh) || {}
87
+ github_sections = parse_github_release_sections(cache_only: false, force_refresh: force_refresh) || {}
88
+
89
+ sections = merge_section_sources(changelog_sections, github_sections)
90
+
91
+ pp @@candidates_found if Gemstar.debug?
92
+
93
+ sections
94
+ end
95
+
96
+ def decode_sections(serialized)
97
+ JSON.parse(serialized)
98
+ rescue JSON::ParserError
99
+ nil
100
+ end
101
+
102
+ def merge_section_sources(changelog_sections, github_sections)
103
+ 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)
107
+ end
108
+
39
109
  def extract_relevant_sections(old_version, new_version)
40
110
  from = Gem::Version.new(old_version.gsub(/-[\w\-]+$/, "")) rescue nil if old_version
41
111
  from ||= Gem::Version.new("0.0.0")
@@ -56,6 +126,17 @@ module Gemstar
56
126
 
57
127
  private
58
128
 
129
+ def normalize_requested_versions(versions)
130
+ Array(versions).filter_map { |version| normalize_version_key(version) }.uniq
131
+ end
132
+
133
+ def normalize_version_key(version)
134
+ value = version.to_s.strip
135
+ return nil if value.empty?
136
+
137
+ value.sub(/\Av/i, "")
138
+ end
139
+
59
140
  # Extract a version token from a heading line, preferring explicit version forms
60
141
  # and avoiding returning a date string when both are present.
61
142
  def extract_version_from_heading(line)
@@ -80,30 +161,11 @@ module Gemstar
80
161
  repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
81
162
  return [] if repo_uri.nil? || repo_uri.empty?
82
163
 
83
- if repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
84
- base = "https://raw.githubusercontent.com/aws/aws-sdk-ruby/refs/heads/version-3/gems/#{@metadata.gem_name}"
85
- aws_style = true
86
- else
87
- base = repo_uri.sub("https://github.com", "https://raw.githubusercontent.com")
88
- aws_style = false
89
- end
90
-
91
- base = base.chomp("/")
92
-
93
- paths = aws_style ? ["CHANGELOG.md"] : %w[
94
- CHANGELOG.md releases.md CHANGES.md
95
- Changelog.md changelog.md ChangeLog.md
96
- Changes.md changes.md
97
- HISTORY.md History.md history.md
98
- History CHANGELOG.rdoc
99
- ]
164
+ changelog_source = metadata_changelog_source(repo_uri, cache_only: cache_only, force_refresh: force_refresh)
165
+ return [] unless changelog_source
100
166
 
101
- remote_repository = RemoteRepository.new(base)
102
-
103
- branches = aws_style ? [""] : remote_repository.find_main_branch(cache_only: cache_only, force_refresh: force_refresh)
104
-
105
- candidates += paths.product(branches).map do |file, branch|
106
- uri = aws_style ? "#{base}/#{file}" : "#{base}/#{branch}/#{file}"
167
+ candidates += changelog_source[:paths].product(changelog_source[:branches]).map do |file, branch|
168
+ [changelog_source[:base], branch, file].reject { |segment| segment.to_s.empty? }.join("/")
107
169
  end
108
170
 
109
171
  # Add the gem's changelog_uri last as it's usually not the most parsable:
@@ -117,6 +179,19 @@ module Gemstar
117
179
  candidates
118
180
  end
119
181
 
182
+ def metadata_changelog_source(repo_uri, cache_only:, force_refresh:)
183
+ if @metadata.respond_to?(:changelog_source)
184
+ return @metadata.changelog_source(repo_uri: repo_uri, cache_only: cache_only, force_refresh: force_refresh)
185
+ end
186
+
187
+ base = repo_uri.sub("https://github.com", "https://raw.githubusercontent.com").chomp("/")
188
+ {
189
+ base: base,
190
+ paths: DEFAULT_CHANGELOG_PATHS,
191
+ branches: RemoteRepository.new(base).find_main_branch(cache_only: cache_only, force_refresh: force_refresh)
192
+ }
193
+ end
194
+
120
195
  def fetch_changelog_content(cache_only: false, force_refresh: false)
121
196
  content = nil
122
197
 
@@ -261,6 +336,7 @@ module Gemstar
261
336
  heading = sec.at_css('h2.sr-only')
262
337
  next unless heading
263
338
  text = heading.text.to_s.strip
339
+ next unless github_tag_matches_metadata?(text)
264
340
  next unless text[/v?(\d[\w.\-]+)/i]
265
341
  version = $1
266
342
 
@@ -284,6 +360,7 @@ module Gemstar
284
360
  link = container.at_xpath('ancestor::*[self::section or self::div][.//a[contains(@href, "/releases/tag/")]][1]//a[contains(@href, "/releases/tag/")]')
285
361
  text = link&.text.to_s
286
362
  text = File.basename(URI(link["href"]).path) if (text.nil? || text.empty?) && link
363
+ next unless github_tag_matches_metadata?(text)
287
364
  next unless text && text[/v?(\d[\w.\-]+)/i]
288
365
  version = $1
289
366
 
@@ -294,6 +371,29 @@ module Gemstar
294
371
  end
295
372
  end
296
373
 
374
+ if sections.empty?
375
+ current_version = @metadata&.meta(cache_only: cache_only, force_refresh: force_refresh)&.dig("version")
376
+ current_release_sections = parse_specific_github_release_pages(
377
+ repo_uri,
378
+ current_version,
379
+ cache_only: cache_only,
380
+ force_refresh: force_refresh
381
+ )
382
+ tag_sections = parse_github_tag_sections(
383
+ repo_uri,
384
+ cache_only: cache_only,
385
+ force_refresh: force_refresh
386
+ )
387
+ sections = tag_sections.merge(current_release_sections)
388
+ elsif discover_github_tag_sections?
389
+ tag_sections = parse_github_tag_sections(
390
+ repo_uri,
391
+ cache_only: cache_only,
392
+ force_refresh: force_refresh
393
+ )
394
+ sections = tag_sections.merge(sections)
395
+ end
396
+
297
397
  if Gemstar.debug?
298
398
  puts "parse_github_release_sections #{@metadata.gem_name}:"
299
399
  pp sections.keys
@@ -308,5 +408,211 @@ module Gemstar
308
408
  return nil if repo.empty?
309
409
  "#{repo}/releases"
310
410
  end
411
+
412
+ def github_tags_url(repo_uri = @metadata&.repo_uri)
413
+ return nil unless repo_uri
414
+ repo = repo_uri.chomp("/")
415
+ return nil if repo.empty?
416
+ "#{repo}/tags"
417
+ end
418
+
419
+ def parse_specific_github_release_pages(repo_uri, version, cache_only:, force_refresh:)
420
+ return {} unless repo_uri&.include?("github.com")
421
+ return {} if version.to_s.empty?
422
+
423
+ github_release_tag_urls(repo_uri, version).each do |url|
424
+ html = if cache_only
425
+ Cache.peek("releases-#{url}")
426
+ else
427
+ Cache.fetch("releases-#{url}", force: force_refresh) do
428
+ begin
429
+ URI.open(url, read_timeout: 8)&.read
430
+ rescue => e
431
+ puts "#{url}: #{e}" if Gemstar.debug?
432
+ nil
433
+ end
434
+ end
435
+ end
436
+
437
+ next if html.nil? || html.strip.empty?
438
+
439
+ section = parse_single_github_release_page(html, version)
440
+ return { version => section } if section
441
+ end
442
+
443
+ {}
444
+ end
445
+
446
+ def parse_github_tag_sections(repo_uri, cache_only:, force_refresh:)
447
+ return {} unless repo_uri&.include?("github.com")
448
+
449
+ url = github_tags_url(repo_uri)
450
+ return {} unless url
451
+
452
+ sections = {}
453
+ seen_urls = {}
454
+
455
+ while url && !seen_urls[url]
456
+ seen_urls[url] = true
457
+ html = if cache_only
458
+ Cache.peek("tags-#{url}")
459
+ else
460
+ Cache.fetch("tags-#{url}", force: force_refresh) do
461
+ begin
462
+ URI.open(url, read_timeout: 8)&.read
463
+ rescue => e
464
+ puts "#{url}: #{e}" if Gemstar.debug?
465
+ nil
466
+ end
467
+ end
468
+ end
469
+
470
+ break if html.nil? || html.strip.empty?
471
+
472
+ page_sections, next_url = parse_single_github_tags_page(html, repo_uri)
473
+ sections.merge!(page_sections) { |_version, existing, _new| existing }
474
+ url = next_url
475
+ end
476
+
477
+ sections.keys.each do |version|
478
+ specific_release = parse_specific_github_release_pages(
479
+ repo_uri,
480
+ version,
481
+ cache_only: cache_only,
482
+ force_refresh: force_refresh
483
+ )
484
+ next if specific_release.nil? || specific_release.empty?
485
+
486
+ sections[version] = specific_release[version] if specific_release[version]
487
+ end
488
+
489
+ sections
490
+ end
491
+
492
+ def parse_single_github_tags_page(html, repo_uri)
493
+ require "nokogiri"
494
+
495
+ doc = begin
496
+ Nokogiri::HTML5(html)
497
+ rescue => _
498
+ Nokogiri::HTML(html)
499
+ end
500
+
501
+ sections = {}
502
+ repo_path = URI(repo_uri).path
503
+ release_prefix = "#{repo_path}/releases/tag/"
504
+ tree_prefix = "#{repo_path}/tree/"
505
+
506
+ doc.css("a[href]").each do |link|
507
+ href = link["href"].to_s
508
+ tag_name =
509
+ if href.start_with?(release_prefix)
510
+ href.delete_prefix(release_prefix)
511
+ elsif href.start_with?(tree_prefix)
512
+ href.delete_prefix(tree_prefix)
513
+ end
514
+ next if tag_name.to_s.empty?
515
+ next unless github_tag_matches_metadata?(tag_name)
516
+
517
+ version = normalize_github_tag_version(tag_name)
518
+ next if version.to_s.empty?
519
+
520
+ sections[version] ||= [
521
+ "## #{version}\n",
522
+ "<p>No release information available. Check the release page for more information.</p>"
523
+ ]
524
+ end
525
+
526
+ next_href =
527
+ doc.at_css('a[rel="next"], a.next_page')&.[]("href") ||
528
+ doc.css("a[href]").find do |link|
529
+ href = link["href"].to_s
530
+ text = link.text.to_s.gsub(/\s+/, " ").strip
531
+ href.include?("/tags?after=") && text == "Next"
532
+ end&.[]("href")
533
+ next_url = if next_href && !next_href.empty?
534
+ URI.join(repo_uri, next_href).to_s
535
+ end
536
+
537
+ [sections, next_url]
538
+ rescue LoadError
539
+ [{}, nil]
540
+ end
541
+
542
+ def parse_single_github_release_page(html, version)
543
+ require "nokogiri"
544
+
545
+ doc = begin
546
+ Nokogiri::HTML5(html)
547
+ rescue => _
548
+ Nokogiri::HTML(html)
549
+ end
550
+
551
+ body = doc.at_css('[data-test-selector="body-content"] .markdown-body') ||
552
+ doc.at_css('[data-test-selector="body-content"]') ||
553
+ doc.at_css('.markdown-body')
554
+ if body
555
+ html_chunk = body.inner_html.to_s.strip
556
+ return ["## #{version}\n", html_chunk] unless html_chunk.empty?
557
+ end
558
+
559
+ title = doc.at_css("title")&.text.to_s.strip
560
+ synthetic_title = normalize_github_release_title(title, version)
561
+ return nil if synthetic_title.nil? || synthetic_title.empty?
562
+
563
+ ["## #{version}\n", "<p>#{CGI.escapeHTML(synthetic_title)}</p>"]
564
+ rescue LoadError
565
+ nil
566
+ end
567
+
568
+ def github_release_tag_urls(repo_url, version)
569
+ github_tag_candidates(version).map do |tag|
570
+ encoded_tag = URI.encode_www_form_component(tag)
571
+ "#{repo_url}/releases/tag/#{encoded_tag}"
572
+ end.uniq
573
+ end
574
+
575
+ def github_tag_candidates(version)
576
+ return @metadata.github_tag_candidates(version) if @metadata.respond_to?(:github_tag_candidates)
577
+
578
+ raw = version.to_s
579
+ [raw, (raw.start_with?("v") ? raw : "v#{raw}")].uniq
580
+ end
581
+
582
+ def normalize_github_tag_version(tag_name)
583
+ decoded = URI.decode_www_form_component(tag_name.to_s.split("?").first.to_s)
584
+ match = decoded.match(/\A(?:.+@)?v?(\d[\w.\-]*)\z/i)
585
+ match && match[1]
586
+ end
587
+
588
+ def github_tag_matches_metadata?(tag_name)
589
+ return @metadata.github_tag_matches?(tag_name) if @metadata.respond_to?(:github_tag_matches?)
590
+
591
+ true
592
+ end
593
+
594
+ def discover_github_tag_sections?
595
+ @metadata.respond_to?(:discover_github_tag_sections?) && @metadata.discover_github_tag_sections?
596
+ end
597
+
598
+ def prefer_github_releases_first?(cache_only:, force_refresh:)
599
+ meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
600
+ repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
601
+
602
+ repo_uri.to_s.include?("github.com") && meta["changelog_uri"].to_s.empty?
603
+ rescue StandardError
604
+ false
605
+ end
606
+
607
+ def normalize_github_release_title(title, version)
608
+ return nil if title.to_s.empty?
609
+
610
+ cleaned = title.sub(/\s*·\s*[^·]+\s*·\s*GitHub\z/, "")
611
+ cleaned = cleaned.sub(/\ARelease\s+/, "")
612
+ cleaned = cleaned.strip
613
+ return nil if cleaned.empty? || cleaned == version.to_s
614
+
615
+ cleaned
616
+ end
311
617
  end
312
618
  end
data/lib/gemstar/cli.rb CHANGED
@@ -13,6 +13,9 @@ module Gemstar
13
13
  desc "diff", "Show changelogs for updated gems"
14
14
  method_option :from, type: :string, desc: "Git ref or lockfile"
15
15
  method_option :to, type: :string, desc: "Git ref or lockfile"
16
+ method_option :since, type: :string, desc: "Set --from to the latest commit before this relative time (for example: '3 weeks')"
17
+ method_option :project, type: :string, desc: "Project directory or supported project file path"
18
+ method_option :ecosystem, type: :string, desc: "Filter packages by ecosystem (all, gems, js)"
16
19
  method_option :format, type: :string, desc: "Output format (html or markdown)"
17
20
  method_option :output_file, type: :string, desc: "Output file path"
18
21
  method_option :debug_gem_regex, type: :string, desc: "Debug matching gems", hide: true
@@ -22,9 +25,10 @@ module Gemstar
22
25
 
23
26
  desc "server", "Start the interactive web server"
24
27
  method_option :bind, type: :string, default: "127.0.0.1", desc: "Bind address"
25
- method_option :port, type: :numeric, default: 2112, desc: "Port"
28
+ method_option :port, type: :numeric, desc: "Port (default: 2112; auto-increments when omitted)"
26
29
  method_option :project, type: :string, repeatable: true, desc: "Project directories or Gemfile paths"
27
30
  method_option :reload, type: :boolean, default: false, desc: "Restart automatically when files change"
31
+ method_option :open, type: :boolean, default: false, desc: "Open the server root in a browser after startup"
28
32
  def server
29
33
  Gemstar::Commands::Server.new(options).run
30
34
  end