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.
@@ -0,0 +1,193 @@
1
+ require "json"
2
+
3
+ module Gemstar
4
+ class ImportmapFile
5
+ Pin = Struct.new(:name, :target, :source, keyword_init: true)
6
+
7
+ IMPORTMAP_PACKAGE_METADATA_PATH = File.expand_path("data/importmap_package_metadata.json", __dir__)
8
+
9
+ def self.package_metadata
10
+ @package_metadata ||= begin
11
+ JSON.parse(File.read(IMPORTMAP_PACKAGE_METADATA_PATH)).transform_values do |attributes|
12
+ attributes.each_with_object({}) do |(key, value), metadata|
13
+ metadata[key.to_sym] = value
14
+ end
15
+ end
16
+ rescue Errno::ENOENT, JSON::ParserError
17
+ {}
18
+ end
19
+ end
20
+
21
+ def initialize(path: nil, content: nil, vendor_reader: nil)
22
+ @path = path
23
+ @vendor_reader = vendor_reader
24
+ @pins = parse_content(content || File.read(path))
25
+ end
26
+
27
+ attr_reader :pins
28
+
29
+ def specs
30
+ pins.transform_values(&:target)
31
+ end
32
+
33
+ def source_for(name)
34
+ pins[name]&.source
35
+ end
36
+
37
+ private
38
+
39
+ def parse_content(content)
40
+ content.each_line.with_object({}) do |line, pins|
41
+ next unless line =~ /^\s*pin\s+["']([^"']+)["'](.*)$/
42
+
43
+ name = Regexp.last_match(1)
44
+ rest = Regexp.last_match(2).to_s
45
+ target = rest[/\bto:\s*["']([^"']+)["']/, 1] || name
46
+ inline_version = rest[/#\s*@([^\s]+)/, 1]
47
+
48
+ pins[name] = Pin.new(
49
+ name: name,
50
+ target: target,
51
+ source: build_source_for(name, target, inline_version: inline_version)
52
+ )
53
+ end
54
+ end
55
+
56
+ def build_source_for(name, target, inline_version: nil)
57
+ {
58
+ type: :importmap,
59
+ remote: target
60
+ }
61
+ .merge(default_package_metadata_for(name, inline_version: inline_version))
62
+ .merge(vendored_package_metadata_for(target))
63
+ .merge(cdn_package_metadata_for(target))
64
+ .merge(repo_source_for(name, target))
65
+ end
66
+
67
+ def default_package_metadata_for(name, inline_version: nil)
68
+ return {} unless package_like_pin_name?(name)
69
+
70
+ metadata = { package_name: name, registry_url: "https://www.npmjs.com/package/#{name}" }
71
+ unless inline_version.to_s.empty?
72
+ key = exact_package_version?(inline_version) ? :package_version : :package_requirement
73
+ metadata[key] = inline_version
74
+ end
75
+ metadata
76
+ end
77
+
78
+ def vendored_package_metadata_for(target)
79
+ return {} unless local_javascript_target?(target)
80
+
81
+ first_line = vendored_file_first_line(target)
82
+ return {} if first_line.empty?
83
+
84
+ if first_line =~ %r{\A//\s+((?:@[^/]+/)?[^@\s]+(?:/[^@\s]+)?)@([^\s]+)\s+downloaded from\s+(https?://\S+)}
85
+ package_name = Regexp.last_match(1)
86
+ package_version = Regexp.last_match(2)
87
+ remote = Regexp.last_match(3)
88
+ remote_metadata = cdn_package_metadata_for(remote)
89
+ package_name = remote_metadata[:package_name] || package_name
90
+ package_version = remote_metadata[:package_version] || package_version
91
+ {
92
+ package_name: package_name,
93
+ package_version: package_version,
94
+ registry_url: "https://www.npmjs.com/package/#{package_name}",
95
+ remote: remote
96
+ }
97
+ else
98
+ {}
99
+ end
100
+ rescue EOFError
101
+ {}
102
+ end
103
+
104
+ def vendored_file_first_line(target)
105
+ if @vendor_reader
106
+ content = @vendor_reader.call(target.to_s)
107
+ return content.to_s.lines.first.to_s if content
108
+ end
109
+
110
+ return "" unless @path
111
+
112
+ vendor_path = File.expand_path(File.join(File.dirname(@path), "..", "vendor", "javascript", target.to_s))
113
+ return "" unless File.file?(vendor_path)
114
+
115
+ File.open(vendor_path, &:readline).to_s
116
+ rescue EOFError
117
+ ""
118
+ end
119
+
120
+ def cdn_package_metadata_for(target)
121
+ value = target.to_s
122
+ return {} if value.empty?
123
+
124
+ package_name, package_version =
125
+ case value
126
+ when %r{\Ahttps://esm\.sh/((?:@[^/]+/)?[^@/?]+)@([^/?]+)}
127
+ [Regexp.last_match(1), Regexp.last_match(2)]
128
+ when %r{\Ahttps://ga\.jspm\.io/npm:((?:@[^/]+/)?[^@/]+)@([^/]+)/}
129
+ [Regexp.last_match(1), Regexp.last_match(2)]
130
+ when %r{\Ahttps://cdn\.jsdelivr\.net/npm/((?:@[^/]+/)?[^@/]+)@([^/]+)/}
131
+ [Regexp.last_match(1), Regexp.last_match(2)]
132
+ when %r{\Ahttps://unpkg\.com/((?:@[^/]+/)?[^@/?]+)@([^/?]+)}
133
+ [Regexp.last_match(1), Regexp.last_match(2)]
134
+ else
135
+ [nil, nil]
136
+ end
137
+
138
+ return {} unless package_name
139
+
140
+ version_metadata = exact_package_version?(package_version) ? { package_version: package_version } : { package_requirement: package_version }
141
+
142
+ version_metadata.merge(
143
+ package_name: package_name,
144
+ registry_url: "https://www.npmjs.com/package/#{package_name}"
145
+ )
146
+ end
147
+
148
+ def repo_source_for(name, target)
149
+ package_metadata = self.class.package_metadata[name]
150
+ repo_url = github_repo_url_for(target) || package_metadata&.dig(:repo_url)
151
+ metadata = {}
152
+ metadata[:repo_url] = repo_url if repo_url
153
+ metadata[:provider_gem] = package_metadata[:provider_gem] if package_metadata&.dig(:provider_gem)
154
+ metadata
155
+ end
156
+
157
+ def github_repo_url_for(target)
158
+ value = target.to_s
159
+ return nil if value.empty?
160
+
161
+ case value
162
+ when %r{\Ahttps://raw\.githubusercontent\.com/([^/]+/[^/]+)/}
163
+ "https://github.com/#{Regexp.last_match(1)}"
164
+ when %r{\Ahttps://github\.com/([^/]+/[^/]+)}
165
+ "https://github.com/#{Regexp.last_match(1)}"
166
+ else
167
+ nil
168
+ end
169
+ end
170
+
171
+ def package_like_pin_name?(name)
172
+ value = name.to_s
173
+ return false if value.empty?
174
+ return false if value.start_with?("controllers/")
175
+ return false if value.include?("_controller")
176
+ return false if value.start_with?("./", "../")
177
+
178
+ value.start_with?("@") || value.match?(/\A[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*\z/i)
179
+ end
180
+
181
+ def exact_package_version?(version)
182
+ version.to_s.match?(/\Av?\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*\z/)
183
+ end
184
+
185
+ def local_javascript_target?(target)
186
+ value = target.to_s
187
+ return false if value.empty?
188
+ return false if value.match?(%r{\A[a-z]+://}i)
189
+
190
+ value.end_with?(".js", ".mjs")
191
+ end
192
+ end
193
+ end
@@ -5,26 +5,72 @@ module Gemstar
5
5
  parsed = content ? parse_content(content) : parse_lockfile(path)
6
6
  @specs = parsed[:specs]
7
7
  @dependency_graph = parsed[:dependency_graph]
8
+ @dependency_requirements = parsed[:dependency_requirements]
8
9
  @direct_dependencies = parsed[:direct_dependencies]
10
+ @direct_dependency_requirements = parsed[:direct_dependency_requirements]
11
+ @spec_sources = parsed[:spec_sources]
9
12
  end
10
13
 
11
14
  attr_reader :specs
12
15
  attr_reader :dependency_graph
16
+ attr_reader :dependency_requirements
13
17
  attr_reader :direct_dependencies
18
+ attr_reader :direct_dependency_requirements
19
+ attr_reader :spec_sources
14
20
 
15
21
  def origins_for(gem_name)
16
- return [{ type: :direct, path: [gem_name] }] if direct_dependencies.include?(gem_name)
22
+ if direct_dependencies.include?(gem_name)
23
+ return [{
24
+ type: :direct,
25
+ path: [gem_name],
26
+ requirement: direct_dependency_requirements[gem_name]
27
+ }]
28
+ end
17
29
 
18
30
  direct_dependencies.filter_map do |root_dependency|
19
31
  path = shortest_path_from(root_dependency, gem_name)
20
32
  next if path.nil?
21
33
 
22
- { type: :transitive, path: path }
34
+ parent_name = path[-2]
35
+ {
36
+ type: :transitive,
37
+ path: path,
38
+ requirement: dependency_requirements.dig(parent_name, gem_name)
39
+ }
23
40
  end
24
41
  end
25
42
 
43
+ def source_for(gem_name)
44
+ spec_sources[gem_name]
45
+ end
46
+
47
+ def platform_for(gem_name)
48
+ version = specs[gem_name].to_s
49
+ parts = version.split("-")
50
+ return nil if parts.length < 2
51
+
52
+ 1.upto(parts.length - 1) do |index|
53
+ candidate_version = parts[0...index].join("-")
54
+ candidate_platform = parts[index..].join("-")
55
+ next unless plausible_platform_suffix?(candidate_platform)
56
+
57
+ begin
58
+ Gem::Version.new(candidate_version)
59
+ return candidate_platform
60
+ rescue ArgumentError
61
+ next
62
+ end
63
+ end
64
+
65
+ nil
66
+ end
67
+
26
68
  private
27
69
 
70
+ def plausible_platform_suffix?(suffix)
71
+ suffix.match?(/darwin|linux|mingw|mswin|musl|java|x86|arm|universal/i)
72
+ end
73
+
28
74
  def shortest_path_from(root_dependency, target_gem)
29
75
  queue = [[root_dependency, [root_dependency]]]
30
76
  visited = {}
@@ -53,9 +99,13 @@ module Gemstar
53
99
  def parse_content(content)
54
100
  specs = {}
55
101
  dependency_graph = Hash.new { |hash, key| hash[key] = [] }
102
+ dependency_requirements = Hash.new { |hash, key| hash[key] = {} }
56
103
  direct_dependencies = []
104
+ direct_dependency_requirements = {}
105
+ spec_sources = {}
57
106
  current_section = nil
58
107
  current_spec = nil
108
+ current_source = nil
59
109
 
60
110
  content.each_line do |line|
61
111
  stripped = line.strip
@@ -68,12 +118,28 @@ module Gemstar
68
118
  if stripped == "GEM"
69
119
  current_section = :gem
70
120
  current_spec = nil
121
+ current_source = { type: :rubygems }
122
+ next
123
+ end
124
+
125
+ if stripped == "PATH"
126
+ current_section = :path
127
+ current_spec = nil
128
+ current_source = { type: :path }
129
+ next
130
+ end
131
+
132
+ if stripped == "GIT"
133
+ current_section = :git
134
+ current_spec = nil
135
+ current_source = { type: :git }
71
136
  next
72
137
  end
73
138
 
74
139
  if stripped == "DEPENDENCIES"
75
140
  current_section = :dependencies
76
141
  current_spec = nil
142
+ current_source = nil
77
143
  next
78
144
  end
79
145
 
@@ -83,17 +149,30 @@ module Gemstar
83
149
  end
84
150
 
85
151
  case current_section
86
- when :gem
87
- if line =~ /^\s{4}(\S+) \((.+)\)/
152
+ when :gem, :path, :git
153
+ if line =~ /^\s{2}remote:\s+(.+)$/
154
+ current_source = (current_source || {}).merge(remote: Regexp.last_match(1))
155
+ elsif line =~ /^\s{2}(revision|branch|tag|ref|glob|submodules):\s+(.+)$/
156
+ current_source = (current_source || {}).merge(Regexp.last_match(1).to_sym => Regexp.last_match(2))
157
+ elsif line =~ /^\s{2}path:\s+(.+)$/
158
+ current_source = (current_source || {}).merge(path: Regexp.last_match(1))
159
+ elsif line =~ /^\s{4}(\S+) \((.+)\)/
88
160
  name, version = Regexp.last_match(1), Regexp.last_match(2)
89
161
  specs[name] = version
162
+ spec_sources[name] = (current_source || {}).dup
90
163
  current_spec = name
91
- elsif current_spec && line =~ /^\s{6}([^\s(]+)/
92
- dependency_graph[current_spec] << Regexp.last_match(1)
164
+ elsif current_spec && line =~ /^\s{6}([^\s(]+)(?: \(([^)]+)\))?/
165
+ dependency_name = Regexp.last_match(1)
166
+ requirement = Regexp.last_match(2)
167
+ dependency_graph[current_spec] << dependency_name
168
+ dependency_requirements[current_spec][dependency_name] = requirement if requirement && !requirement.empty?
93
169
  end
94
170
  when :dependencies
95
- if line =~ /^\s{2}([^\s!(]+)/
96
- direct_dependencies << Regexp.last_match(1)
171
+ if line =~ /^\s{2}([^\s!(]+)(?: \(([^)]+)\))?/
172
+ dependency_name = Regexp.last_match(1)
173
+ requirement = Regexp.last_match(2)
174
+ direct_dependencies << dependency_name
175
+ direct_dependency_requirements[dependency_name] = requirement if requirement && !requirement.empty?
97
176
  end
98
177
  end
99
178
  end
@@ -101,7 +180,10 @@ module Gemstar
101
180
  {
102
181
  specs: specs,
103
182
  dependency_graph: dependency_graph.transform_values(&:uniq),
104
- direct_dependencies: direct_dependencies.uniq
183
+ dependency_requirements: dependency_requirements,
184
+ direct_dependencies: direct_dependencies.uniq,
185
+ direct_dependency_requirements: direct_dependency_requirements,
186
+ spec_sources: spec_sources
105
187
  }
106
188
  end
107
189
  end
@@ -0,0 +1,159 @@
1
+ require "open-uri"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Gemstar
6
+ class NpmMetadata
7
+ def initialize(package_name)
8
+ @gem_name = package_name
9
+ end
10
+
11
+ attr_reader :gem_name
12
+
13
+ def cache_key
14
+ "npm-#{gem_name}"
15
+ end
16
+
17
+ def meta(cache_only: false, force_refresh: false)
18
+ return @meta if !cache_only && defined?(@meta)
19
+
20
+ json = if cache_only
21
+ Cache.peek(cache_key)
22
+ else
23
+ url = "https://registry.npmjs.org/#{URI.encode_www_form_component(gem_name)}"
24
+ Cache.fetch(cache_key, force: force_refresh) do
25
+ URI.open(url, read_timeout: 8).read
26
+ end
27
+ end
28
+
29
+ parsed = begin
30
+ JSON.parse(json) if json
31
+ rescue
32
+ nil
33
+ end
34
+
35
+ normalized = normalize_meta(parsed)
36
+ @meta = normalized unless cache_only
37
+ normalized
38
+ end
39
+
40
+ def repo_uri(cache_only: false, force_refresh: false)
41
+ resolved_meta = meta(cache_only: cache_only, force_refresh: force_refresh)
42
+ return nil unless resolved_meta
43
+
44
+ return @repo_uri if !cache_only && defined?(@repo_uri)
45
+
46
+ repo = begin
47
+ uri = resolved_meta["source_code_uri"]
48
+ uri ||= resolved_meta["homepage_uri"] if resolved_meta["homepage_uri"].to_s.include?("github.com")
49
+ uri ||= ""
50
+
51
+ uri = uri.sub(/\Agit\+/, "")
52
+ uri = uri.sub("git://", "https://")
53
+ uri = uri.sub("http://", "https://")
54
+ uri = uri.gsub(/\.git$/, "")
55
+
56
+ if uri.include?("github.com")
57
+ uri = uri[%r{\Ahttps?://github\.com/[^/]+/[^/]+}] || uri
58
+ end
59
+
60
+ uri
61
+ end
62
+
63
+ @repo_uri = repo unless cache_only
64
+ repo
65
+ end
66
+
67
+ def changelog_sections(versions: nil, cache_only: false, force_refresh: false)
68
+ requested_versions = Array(versions).compact
69
+ changelog = Gemstar::ChangeLog.new(self)
70
+ if requested_versions.empty?
71
+ changelog.sections(cache_only: cache_only, force_refresh: force_refresh)
72
+ else
73
+ changelog.sections_for_versions(requested_versions, cache_only: cache_only, force_refresh: force_refresh)
74
+ end
75
+ end
76
+
77
+ def warm_cache(versions: nil)
78
+ meta
79
+ repo_uri
80
+ changelog_sections(versions: versions)
81
+ end
82
+
83
+ def discover_github_tag_sections?
84
+ true
85
+ end
86
+
87
+ def github_tag_candidates(version)
88
+ raw = version.to_s
89
+ candidates = [raw, (raw.start_with?("v") ? raw : "v#{raw}")]
90
+
91
+ package_name = gem_name.to_s
92
+ unless package_name.empty?
93
+ candidates << "#{package_name}@#{raw}"
94
+ candidates << "#{package_name}@v#{raw}" unless raw.start_with?("v")
95
+
96
+ short_name = package_name.split("/").last
97
+ if short_name && short_name != package_name
98
+ candidates << "#{short_name}@#{raw}"
99
+ candidates << "#{short_name}@v#{raw}" unless raw.start_with?("v")
100
+ end
101
+ end
102
+
103
+ candidates.uniq
104
+ end
105
+
106
+ def github_tag_matches?(tag_name)
107
+ decoded = URI.decode_www_form_component(tag_name.to_s.split("?").first.to_s)
108
+ package_name = gem_name.to_s
109
+ return true if package_name.empty?
110
+
111
+ if decoded.include?("@")
112
+ prefix = decoded.sub(/@v?\d[\w.\-]*\z/i, "")
113
+ return true if prefix == package_name
114
+
115
+ short_name = package_name.split("/").last
116
+ return true if short_name && prefix == short_name
117
+
118
+ return false
119
+ end
120
+
121
+ true
122
+ end
123
+
124
+ private
125
+
126
+ def normalize_meta(parsed)
127
+ return nil unless parsed.is_a?(Hash)
128
+
129
+ latest_version = parsed.dig("dist-tags", "latest").to_s
130
+ version_meta = parsed.dig("versions", latest_version)
131
+ source_code_uri = repository_url(version_meta || parsed)
132
+ homepage_uri = homepage_url(version_meta || parsed)
133
+
134
+ {
135
+ "name" => parsed["name"],
136
+ "version" => latest_version.empty? ? nil : latest_version,
137
+ "info" => (version_meta || parsed)["description"],
138
+ "homepage_uri" => homepage_uri,
139
+ "source_code_uri" => source_code_uri,
140
+ "project_uri" => "https://www.npmjs.com/package/#{parsed["name"]}",
141
+ "documentation_uri" => homepage_uri
142
+ }
143
+ end
144
+
145
+ def repository_url(meta)
146
+ repository = meta["repository"]
147
+ case repository
148
+ when Hash
149
+ repository["url"]
150
+ when String
151
+ repository
152
+ end
153
+ end
154
+
155
+ def homepage_url(meta)
156
+ meta["homepage"]
157
+ end
158
+ end
159
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "basic"
4
4
  require "pathname"
5
+ require "cgi"
5
6
  require "kramdown"
6
7
  begin
7
8
  require "kramdown-parser-gfm"
@@ -14,7 +15,7 @@ module Gemstar
14
15
  class HTML < Basic
15
16
  def render_diff(diff_command)
16
17
  body = diff_command.updates.sort.map do |gem_name, info|
17
- icon = info[:homepage_url]&.include?("github.com") ? "🐙" : "💎"
18
+ icon = icon_for(info)
18
19
  tooltip = info[:description] ? "title=\"#{info[:description].gsub('"', "&quot;")}\"" : ""
19
20
  link = "<a href=\"#{info[:homepage_url]}\" #{tooltip} target=\"_blank\">#{gem_name}</a>"
20
21
  html = if info[:sections]
@@ -38,14 +39,14 @@ module Gemstar
38
39
 
39
40
  <<~HTML
40
41
  <section>
41
- <h2>#{icon} #{link}: #{info[:old] || "new"} → #{info[:new]}</h2>
42
+ <h2>#{icon} #{link}: #{version_label(info)}</h2>
42
43
  #{"<p><a href='#{info[:release_page]}' target='_blank'>View all GitHub release notes</a></p>" if info[:release_page]}
43
44
  #{html}
44
45
  </section>
45
46
  HTML
46
47
  end.join("\n")
47
48
 
48
- project_name = Pathname.getwd.basename.to_s
49
+ project_name = diff_command.project_name
49
50
 
50
51
  <<~HTML
51
52
  <!DOCTYPE html>
@@ -63,13 +64,61 @@ module Gemstar
63
64
  </style>
64
65
  </head>
65
66
  <body>
66
- <h1>#{project_name}: Gem Updates</h1>
67
+ <h1>#{project_name}: Package Updates</h1>
67
68
  <p><i>Showing changes from #{diff_command.from} to #{diff_command.to || "now"}, generated on #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}.</i></p>
69
+ #{range_details(diff_command)}
68
70
  #{body}
71
+ #{considered_commits(diff_command)}
69
72
  </body>
70
73
  </html>
71
74
  HTML
72
75
  end
76
+
77
+ private
78
+
79
+ def icon_for(info)
80
+ return "📦" if info[:package_scope] == "js"
81
+ return "🐙" if info[:homepage_url]&.include?("github.com")
82
+
83
+ "💎"
84
+ end
85
+
86
+ def version_label(info)
87
+ info[:version_label] || "#{info[:old] || "new"} → #{info[:new]}"
88
+ end
89
+
90
+ def range_details(diff_command)
91
+ return "" unless diff_command.since
92
+
93
+ cutoff = diff_command.format_commit(diff_command.since_cutoff_commit, fallback_revision: diff_command.from)
94
+ <<~HTML
95
+ <section>
96
+ <h2>Diff Range</h2>
97
+ <p>Since cutoff <code>#{h(diff_command.since)}</code> resolved to #{h(cutoff)}.</p>
98
+ </section>
99
+ HTML
100
+ end
101
+
102
+ def considered_commits(diff_command)
103
+ commits = Array(diff_command.considered_commits)
104
+ items = commits.map do |commit|
105
+ %(<li><code>#{h(commit[:short_sha] || commit[:id])}</code> #{h(commit[:authored_at])} #{h(commit[:subject])}</li>)
106
+ end.join("\n")
107
+ items = "<li>No commits found in this range.</li>" if items.empty?
108
+
109
+ <<~HTML
110
+ <section>
111
+ <h2>Commits Considered</h2>
112
+ <ul>
113
+ #{items}
114
+ </ul>
115
+ </section>
116
+ HTML
117
+ end
118
+
119
+ def h(value)
120
+ CGI.escapeHTML(value.to_s)
121
+ end
73
122
  end
74
123
  end
75
124
  end
@@ -9,17 +9,21 @@ module Gemstar
9
9
  module Outputs
10
10
  class Markdown < Basic
11
11
  def render_diff(diff_command)
12
- project_name = Pathname.getwd.basename.to_s
12
+ project_name = diff_command.project_name
13
13
  body = diff_command.updates.sort.map do |gem_name, info|
14
14
  render_entry(gem_name, info)
15
15
  end.join("\n\n---\n\n")
16
16
 
17
17
  <<~MARKDOWN
18
- # #{project_name}: Gem Updates
18
+ # #{project_name}: Package Updates
19
19
 
20
20
  _Showing changes from #{diff_command.from} to #{diff_command.to || "now"}, generated on #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}._
21
21
 
22
+ #{range_details(diff_command)}
23
+
22
24
  #{body}
25
+
26
+ #{considered_commits(diff_command)}
23
27
  MARKDOWN
24
28
  end
25
29
 
@@ -35,7 +39,7 @@ module Gemstar
35
39
  parts = []
36
40
  parts << title
37
41
  parts << ""
38
- parts << "*#{info[:old] || "new"} → #{info[:new]}*"
42
+ parts << "*#{info[:version_label] || "#{info[:old] || "new"} → #{info[:new]}"}*"
39
43
  parts << ""
40
44
  parts << info[:description].to_s unless info[:description].to_s.empty?
41
45
  parts << "" unless info[:description].to_s.empty?
@@ -53,6 +57,28 @@ module Gemstar
53
57
  parts.join("\n").strip
54
58
  end
55
59
 
60
+ def range_details(diff_command)
61
+ return "" unless diff_command.since
62
+
63
+ cutoff = diff_command.format_commit(diff_command.since_cutoff_commit, fallback_revision: diff_command.from)
64
+ "## Diff Range\n\nSince cutoff `#{diff_command.since}` resolved to #{cutoff}."
65
+ end
66
+
67
+ def considered_commits(diff_command)
68
+ commits = Array(diff_command.considered_commits)
69
+ lines = ["## Commits Considered", ""]
70
+
71
+ if commits.empty?
72
+ lines << "No commits found in this range."
73
+ else
74
+ lines.concat(commits.map do |commit|
75
+ "- `#{commit[:short_sha] || commit[:id]}` #{commit[:authored_at]} #{commit[:subject]}"
76
+ end)
77
+ end
78
+
79
+ lines.join("\n")
80
+ end
81
+
56
82
  def link_lines(info)
57
83
  lines = []
58
84
  lines << "[Compare changes](#{info[:compare_url]})" if info[:compare_url]