spm_version_updates 1.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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "git_operations"
5
+
6
+ # Parsing for `Package.resolved` files.
7
+ #
8
+ # Handles both the v1 format (pins nested under `"object"`) and the v2+ format
9
+ # (pins at the top level). This is shared between the Xcode-project source mode
10
+ # and the Swift package manifest source mode.
11
+ module PackageResolved
12
+ # Raised when a `Package.resolved` file exists but is not valid JSON.
13
+ class MalformedFileError < StandardError
14
+ attr_reader :path
15
+
16
+ def initialize(path, parse_message)
17
+ @path = path
18
+ super("Malformed Package.resolved at #{path}: #{parse_message}")
19
+ end
20
+ end
21
+
22
+ # Extract the resolved version (or revision, when no version is pinned) for
23
+ # every pin in a `Package.resolved` file.
24
+ #
25
+ # @param [String] path The path to a `Package.resolved` file
26
+ # @raise [MalformedFileError] if the file is not valid JSON
27
+ # @return [Hash<String, String>] normalized repository URL => version or revision
28
+ def self.versions_from(path)
29
+ contents = load_contents(path)
30
+ pins = contents["pins"] || contents.dig("object", "pins") || []
31
+ pins.to_h { |pin|
32
+ [
33
+ GitOperations.trim_repo_url(pin["location"] || pin["repositoryURL"]),
34
+ pin.dig("state", "version") || pin.dig("state", "revision"),
35
+ ]
36
+ }
37
+ end
38
+
39
+ def self.load_contents(path)
40
+ JSON.load_file!(path)
41
+ rescue JSON::ParserError => error
42
+ raise(MalformedFileError.new(path, error.message))
43
+ end
44
+
45
+ private_class_method :load_contents
46
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ # Parses supported git remote URLs and renders host-specific links.
6
+ class RepositoryLink
7
+ HOSTS = {
8
+ "github.com" => {
9
+ path_normalizer: ->(segments) { segments.first(2).join("/") if segments.size >= 2 },
10
+ compare: ->(current, available) { "/compare/#{current}...#{available}" },
11
+ release: ["Releases", "/releases"]
12
+ },
13
+ "gitlab.com" => {
14
+ path_normalizer: ->(segments) { segments.join("/").sub(%r{/-/.*\z}, "").then { |path| path if path.count("/") >= 1 } },
15
+ compare: ->(current, available) { "/-/compare/#{current}...#{available}" },
16
+ release: ["Releases", "/-/releases"]
17
+ },
18
+ "bitbucket.org" => {
19
+ path_normalizer: ->(segments) { segments.first(2).join("/") if segments.size >= 2 },
20
+ compare: ->(current, available) { "/branches/compare/#{available}..#{current}" },
21
+ release: ["Tags", "/downloads/?tab=tags"]
22
+ }
23
+ }.freeze
24
+ SUPPORTED_HOSTS_PATTERN = Regexp.union(HOSTS.keys).source
25
+ REMOTE_PATTERNS = [
26
+ %r{\A(?:https?|git|ssh)://(?:[^@/\s]+@)?(?<host>#{SUPPORTED_HOSTS_PATTERN})(?::\d+)?/(?<path>.+)\z}i,
27
+ %r{\A(?:[^@/\s]+@)?(?<host>#{SUPPORTED_HOSTS_PATTERN})[:/](?<path>.+)\z}i,
28
+ ].freeze
29
+
30
+ private_constant :HOSTS,
31
+ :SUPPORTED_HOSTS_PATTERN,
32
+ :REMOTE_PATTERNS
33
+
34
+ def self.from(repository_url)
35
+ link = new(repository_url)
36
+ link if link.valid?
37
+ end
38
+
39
+ def initialize(repository_url)
40
+ @value = repository_url.to_s.strip
41
+ @host = nil
42
+ @raw_path = nil
43
+ @path = nil
44
+ configure_remote(remote_match)
45
+ end
46
+
47
+ def valid?
48
+ @host && @path
49
+ end
50
+
51
+ def compare_url(current, available)
52
+ current_ref = URI.encode_www_form_component(current.to_s)
53
+ available_ref = URI.encode_www_form_component(available.to_s)
54
+ "#{base_url}#{link_builder.fetch(:compare).call(current_ref, available_ref)}"
55
+ end
56
+
57
+ def release_link
58
+ label, path = link_builder.fetch(:release)
59
+ "[#{label}](#{base_url}#{path})"
60
+ end
61
+
62
+ def markdown_links(updates, separator: "<br>")
63
+ compare_links = updates.map.with_index(1) { |update, index|
64
+ label = updates.size == 1 ? "Compare" : "Compare #{index}"
65
+ "[#{label}](#{compare_url(update[:current], update[:available])})"
66
+ }
67
+ (compare_links + [release_link]).join(separator)
68
+ end
69
+
70
+ private
71
+
72
+ def remote_match
73
+ REMOTE_PATTERNS.each { |pattern|
74
+ match = @value.match(pattern)
75
+ return match if match
76
+ }
77
+
78
+ nil
79
+ end
80
+
81
+ def configure_remote(match)
82
+ return unless match
83
+
84
+ @host = match[:host].downcase
85
+ @raw_path = match[:path]
86
+ @path = normalized_path
87
+ end
88
+
89
+ def normalized_path
90
+ link_builder.fetch(:path_normalizer).call(path_segments)
91
+ end
92
+
93
+ def path_segments
94
+ @raw_path.to_s
95
+ .split(/[?#]/, 2)
96
+ .first
97
+ .to_s
98
+ .sub(%r{\A/+}, "")
99
+ .sub(%r{/+\z}, "")
100
+ .sub(/\.git\z/i, "")
101
+ .split("/")
102
+ .reject(&:empty?)
103
+ end
104
+
105
+ def base_url
106
+ "https://#{@host}/#{@path}"
107
+ end
108
+
109
+ def link_builder
110
+ HOSTS.fetch(@host)
111
+ end
112
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "git_operations"
5
+ require_relative "semver"
6
+ require_relative "update_severity"
7
+
8
+ # Loads and evaluates per-repository semantic update suppression rules.
9
+ class RepositoryUpdateRules
10
+ SEMANTIC_TYPES = ["version", "above_maximum"].freeze
11
+ SEVERITY_RANK = {
12
+ "patch" => 0,
13
+ "minor" => 1,
14
+ "major" => 2
15
+ }.freeze
16
+ YAML_KEYS = {
17
+ repositories: "repositories",
18
+ url: "url",
19
+ ignore_until: "ignore-until",
20
+ allowed_updates: "allowed-updates"
21
+ }.freeze
22
+ VALID_YAML_KEYS = {
23
+ root: [YAML_KEYS.fetch(:repositories)],
24
+ entry: YAML_KEYS.values_at(:url, :ignore_until, :allowed_updates)
25
+ }.freeze
26
+
27
+ # One normalized repository rule from repo-rules YAML.
28
+ Rule = Struct.new(:normalized_url, :ignore_until, :allowed_updates, keyword_init: true) {
29
+ def suppressed?(record)
30
+ return true if suppress_until_version?(record)
31
+ return true if suppress_disallowed_severity?(record)
32
+
33
+ false
34
+ end
35
+
36
+ private
37
+
38
+ def suppress_until_version?(record)
39
+ return false unless ignore_until
40
+
41
+ available = RepositoryUpdateRules.semver(RepositoryUpdateRules.record_value(record, "available_version"))
42
+ available && available < ignore_until
43
+ end
44
+
45
+ def suppress_disallowed_severity?(record)
46
+ return false unless allowed_updates
47
+
48
+ severity = UpdateSeverity.for_versions(
49
+ RepositoryUpdateRules.record_value(record, "current_version"),
50
+ RepositoryUpdateRules.record_value(record, "available_version")
51
+ )
52
+ severity && SEVERITY_RANK.fetch(severity) > SEVERITY_RANK.fetch(allowed_updates)
53
+ end
54
+ }
55
+ private_constant :Rule
56
+
57
+ def self.empty
58
+ new({})
59
+ end
60
+
61
+ def self.load_file(path)
62
+ path = validated_file_path(path)
63
+ yaml_config = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
64
+ from_hash(yaml_config, source: path)
65
+ rescue Psych::Exception => error
66
+ raise(ArgumentError, "repo-rules YAML is invalid in #{path}: #{error.message}")
67
+ end
68
+
69
+ def self.from_hash(config = {}, source: "repo rules", **keyword_config)
70
+ effective_config = keyword_config.empty? ? config : keyword_config
71
+ effective_config ||= {}
72
+ raise(ArgumentError, "#{source} must contain a YAML mapping") unless effective_config.kind_of?(Hash)
73
+
74
+ new(parse_repositories(repositories_from(effective_config, source), source))
75
+ end
76
+
77
+ def self.semver(value)
78
+ SpmVersionUpdates::Semver.new(value.to_s)
79
+ rescue ArgumentError
80
+ nil
81
+ end
82
+
83
+ def self.parse_repositories(repositories, source)
84
+ repositories.each_with_object({}).with_index(1) { |(entry, rules), index|
85
+ rule = parse_entry(entry, "#{source} repositories[#{index}]")
86
+ normalized_url = rule.normalized_url
87
+ raise(ArgumentError, "duplicate repo-rules entry for #{normalized_url}") if rules.key?(normalized_url)
88
+
89
+ rules[normalized_url] = rule
90
+ }
91
+ end
92
+
93
+ def self.parse_entry(entry, source)
94
+ string_keys = rule_entry_from(entry, source)
95
+ rule = rule_attributes(string_keys, source)
96
+
97
+ Rule.new(**rule)
98
+ end
99
+
100
+ def self.validated_file_path(path)
101
+ path = path.to_s.strip
102
+ raise(ArgumentError, "repo-rules-path was set but no file path was provided") if path.empty?
103
+ raise(ArgumentError, "repo-rules-path file does not exist: #{path}") unless File.file?(path)
104
+
105
+ path
106
+ end
107
+
108
+ def self.repositories_from(config, source)
109
+ string_keys = config.transform_keys(&:to_s)
110
+ validate_keys!(string_keys, VALID_YAML_KEYS.fetch(:root), "#{source} root")
111
+ repositories = string_keys.compact.fetch(yaml_key(:repositories), [])
112
+ raise(ArgumentError, "#{source} repositories must be a list") unless repositories.kind_of?(Array)
113
+
114
+ repositories
115
+ end
116
+
117
+ def self.rule_entry_from(entry, source)
118
+ raise(ArgumentError, "#{source} must be a mapping") unless entry.kind_of?(Hash)
119
+
120
+ entry.transform_keys(&:to_s).tap { |string_keys| validate_keys!(string_keys, VALID_YAML_KEYS.fetch(:entry), source) }
121
+ end
122
+
123
+ def self.rule_attributes(string_keys, source)
124
+ normalized_url = normalized_url_for(required_value(string_keys, yaml_key(:url), source))
125
+ ignore_until = parse_ignore_until(string_keys, source)
126
+ allowed_updates = parse_allowed_updates(string_keys, source)
127
+ raise(ArgumentError, "#{source} must set #{yaml_key(:ignore_until)} or #{yaml_key(:allowed_updates)}") unless ignore_until || allowed_updates
128
+
129
+ { normalized_url:, ignore_until:, allowed_updates: }
130
+ end
131
+
132
+ def self.validate_keys!(values, allowed, source)
133
+ unknown = values.keys - allowed
134
+ return if unknown.empty?
135
+
136
+ raise(ArgumentError, "#{source} contains unknown key(s): #{unknown.join(', ')}")
137
+ end
138
+
139
+ def self.required_value(values, key, source)
140
+ value = values[key].to_s.strip
141
+ raise(ArgumentError, "#{source} #{key} must be set") if value.empty?
142
+
143
+ value
144
+ end
145
+
146
+ def self.normalized_url_for(value)
147
+ normalized = GitOperations.trim_repo_url(value)
148
+ raise(ArgumentError, "repo-rules url must normalize to a repository URL") if normalized.empty?
149
+
150
+ normalized
151
+ end
152
+
153
+ def self.parse_ignore_until(values, source)
154
+ key = yaml_key(:ignore_until)
155
+ return unless values.key?(key)
156
+
157
+ semver(values[key].to_s.strip).tap { |version|
158
+ raise(ArgumentError, "#{source} #{key} must be a semantic version") unless version
159
+ }
160
+ end
161
+
162
+ def self.parse_allowed_updates(values, source)
163
+ key = yaml_key(:allowed_updates)
164
+ return unless values.key?(key)
165
+
166
+ value = values[key].to_s.strip.downcase
167
+ return value if SEVERITY_RANK.key?(value)
168
+
169
+ raise(ArgumentError, "#{source} #{key} must be patch, minor, or major")
170
+ end
171
+
172
+ def self.record_value(record, key)
173
+ record[key] || record[key.to_sym]
174
+ end
175
+
176
+ def self.yaml_key(name)
177
+ YAML_KEYS.fetch(name)
178
+ end
179
+
180
+ private_class_method(
181
+ :parse_repositories,
182
+ :parse_entry,
183
+ :validated_file_path,
184
+ :repositories_from,
185
+ :rule_entry_from,
186
+ :rule_attributes,
187
+ :validate_keys!,
188
+ :required_value,
189
+ :normalized_url_for,
190
+ :parse_ignore_until,
191
+ :parse_allowed_updates,
192
+ :yaml_key
193
+ )
194
+
195
+ def initialize(rules_by_repo)
196
+ @rules_by_repo = rules_by_repo
197
+ end
198
+
199
+ def empty?
200
+ @rules_by_repo.empty?
201
+ end
202
+
203
+ def suppressed?(record)
204
+ return false unless semantic_record?(record)
205
+
206
+ rule = @rules_by_repo[normalized_record_url(record)]
207
+ rule ? rule.suppressed?(record) : false
208
+ end
209
+
210
+ private
211
+
212
+ def semantic_record?(record)
213
+ SEMANTIC_TYPES.include?(self.class.record_value(record, "type").to_s)
214
+ end
215
+
216
+ def normalized_record_url(record)
217
+ rules = self.class
218
+ GitOperations.trim_repo_url(
219
+ rules.record_value(record, "normalized_url") || rules.record_value(record, "repository_url")
220
+ )
221
+ end
222
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semverify"
4
+
5
+ module SpmVersionUpdates
6
+ # SemVer value object used by both the GitHub Action and legacy Danger plugin.
7
+ class Semver
8
+ include Comparable
9
+
10
+ def self.normalize(value)
11
+ value.to_s
12
+ .sub(/\Av(?=\d)/, "")
13
+ .sub(/\A(\d+)\.(\d+)(?=\z|[-+])/, '\1.\2.0')
14
+ end
15
+
16
+ def initialize(value)
17
+ @version = Semverify::Semver.new(self.class.normalize(value))
18
+ rescue Semverify::Error => error
19
+ raise(ArgumentError, error.message)
20
+ end
21
+
22
+ def <=>(other)
23
+ semver_class = self.class
24
+ other_version = other.kind_of?(semver_class) ? other.version : semver_class.new(other).version
25
+ version <=> other_version
26
+ rescue ArgumentError
27
+ nil
28
+ end
29
+
30
+ def major
31
+ version.major.to_i
32
+ end
33
+
34
+ def minor
35
+ version.minor.to_i
36
+ end
37
+
38
+ def patch
39
+ version.patch.to_i
40
+ end
41
+
42
+ def pre
43
+ version.pre_release.to_s.then { |value| value.empty? ? nil : value }
44
+ end
45
+ alias pre_release pre
46
+
47
+ def to_s
48
+ version.to_s
49
+ end
50
+
51
+ protected
52
+
53
+ attr_reader :version
54
+ end
55
+ end