diff --git a/updater/lib/dependabot/dependency_change.rb b/updater/lib/dependabot/dependency_change.rb index a540a5a4e10..d8288941ebc 100644 --- a/updater/lib/dependabot/dependency_change.rb +++ b/updater/lib/dependabot/dependency_change.rb @@ -95,7 +95,13 @@ def pr_message dependency_group: dependency_group, pr_message_max_length: pr_message_max_length, pr_message_encoding: pr_message_encoding, - ignore_conditions: job.ignore_conditions, + ignore_conditions: job.ignore_conditions.map do |ic| + { + "dependency-name" => ic.dependency_name, + "version-requirement" => ic.version_requirement, + "source" => ic.source + }.compact + end, notices: notices ).message diff --git a/updater/lib/dependabot/job.rb b/updater/lib/dependabot/job.rb index d17ae4201ea..416549f1184 100644 --- a/updater/lib/dependabot/job.rb +++ b/updater/lib/dependabot/job.rb @@ -15,6 +15,10 @@ require "dependabot/package/release_cooldown_options" require "dependabot/updater/update_type_helper" +require "dependabot/job/allowed_update" +require "dependabot/job/ignore_condition" +require "dependabot/job/security_advisory_entry" + # Describes a single Dependabot workload within the GitHub-integrated Service # # This primarily acts as a value class to hold inputs for various Core objects @@ -61,7 +65,7 @@ class Job # rubocop:disable Metrics/ClassLength T::Array[Symbol] ) - sig { returns(T::Array[T::Hash[String, T.untyped]]) } + sig { returns(T::Array[Dependabot::Job::AllowedUpdate]) } attr_reader :allowed_updates sig { returns(T::Array[Dependabot::Credential]) } @@ -85,7 +89,7 @@ class Job # rubocop:disable Metrics/ClassLength sig { returns(String) } attr_reader :command - sig { returns(T::Array[T.untyped]) } + sig { returns(T::Array[Dependabot::Job::IgnoreCondition]) } attr_reader :ignore_conditions sig { returns(String) } @@ -94,7 +98,7 @@ class Job # rubocop:disable Metrics/ClassLength sig { returns(T.nilable(Dependabot::RequirementsUpdateStrategy)) } attr_reader :requirements_update_strategy - sig { returns(T::Array[T.untyped]) } + sig { returns(T::Array[Dependabot::Job::SecurityAdvisoryEntry]) } attr_reader :security_advisories sig { returns(T::Boolean) } @@ -161,8 +165,11 @@ def self.standardise_keys(hash) def initialize(attributes) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength @id = T.let(attributes.fetch(:id), String) @command = T.let(attributes.fetch(:command, ""), String) - @allowed_updates = T.let(attributes.fetch(:allowed_updates), T::Array[T.untyped]) - @commit_message_options = T.let( + @allowed_updates = T.let( + attributes.fetch(:allowed_updates).map { |h| AllowedUpdate.from_hash(h) }, + T::Array[AllowedUpdate] + ) + @commit_message_options = T.let( attributes.fetch(:commit_message_options, {}), T.nilable(T::Hash[T.untyped, T.untyped]) ) @@ -188,7 +195,10 @@ def initialize(attributes) # rubocop:disable Metrics/AbcSize,Metrics/MethodLengt attributes.fetch(:experiments, {}), T.nilable(T::Hash[T.untyped, T.untyped]) ) - @ignore_conditions = T.let(attributes.fetch(:ignore_conditions), T::Array[T.untyped]) + @ignore_conditions = T.let( + attributes.fetch(:ignore_conditions).map { |h| Job::IgnoreCondition.from_hash(h) }, + T::Array[Job::IgnoreCondition] + ) @package_manager = T.let(attributes.fetch(:package_manager), String) @reject_external_code = T.let(attributes.fetch(:reject_external_code, false), T::Boolean) @repo_contents_path = T.let(attributes.fetch(:repo_contents_path, nil), T.nilable(String)) @@ -200,7 +210,10 @@ def initialize(attributes) # rubocop:disable Metrics/AbcSize,Metrics/MethodLengt T.nilable(Dependabot::RequirementsUpdateStrategy) ) - @security_advisories = T.let(attributes.fetch(:security_advisories), T::Array[T.untyped]) + @security_advisories = T.let( + attributes.fetch(:security_advisories).map { |h| SecurityAdvisoryEntry.from_hash(h) }, + T::Array[SecurityAdvisoryEntry] + ) @security_updates_only = T.let(attributes.fetch(:security_updates_only), T::Boolean) @source = T.let(build_source(attributes.fetch(:source)), Dependabot::Source) @token = T.let(attributes.fetch(:token, nil), T.nilable(String)) @@ -316,18 +329,17 @@ def allowed_update?(dependency, check_previous_version: false) allowed_updates.any? do |update| # Check the update-type (defaulting to all) - update_type = update.fetch("update-type", "all") # NOTE: Preview supports specifying a "security" update type whereas # native will say "security-updates-only" - security_update = update_type == "security" || security_updates_only? + security_update = update.update_type == "security" || security_updates_only? next false if security_update && !vulnerable?(dependency, check_previous_version) # Check the dependency-name (defaulting to matching) - condition_name = update.fetch("dependency-name", dependency.name) + condition_name = update.dependency_name || dependency.name next false unless name_match?(condition_name, dependency.name) # Check the dependency-type (defaulting to all) - dep_type = update.fetch("dependency-type", "all") + dep_type = update.dependency_type next false if dep_type == "indirect" && dependency.requirements.any? # In dependabot-api, dependency-type is defaulting to "direct" not "all". Ignoring @@ -417,18 +429,14 @@ def commit_message_options def security_advisories_for(dependency) relevant_advisories = security_advisories - .select { |adv| adv.fetch("dependency-name").casecmp(dependency.name).zero? } + .select { |adv| T.must(adv.dependency_name.casecmp(dependency.name)).zero? } relevant_advisories.map do |adv| - vulnerable_versions = adv["affected-versions"] || [] - safe_versions = (adv["patched-versions"] || []) + - (adv["unaffected-versions"] || []) - Dependabot::SecurityAdvisory.new( dependency_name: dependency.name, package_manager: package_manager, - vulnerable_versions: vulnerable_versions, - safe_versions: safe_versions + vulnerable_versions: T.unsafe(adv.affected_versions), + safe_versions: T.unsafe(adv.patched_versions + adv.unaffected_versions) ) end end @@ -459,17 +467,15 @@ def ignore_conditions_for(dependency) # were created via "@dependabot ignore version" commands sig { params(dependency: Dependabot::Dependency).void } def log_ignore_conditions_for(dependency) - conditions = ignore_conditions.select { |ic| name_match?(ic["dependency-name"], dependency.name) } + conditions = ignore_conditions.select { |ic| name_match?(ic.dependency_name, dependency.name) } return if conditions.empty? Dependabot.logger.info("Ignored versions:") conditions.each do |ic| - unless ic["version-requirement"].nil? - Dependabot.logger.info(" #{ic['version-requirement']} - from #{ic['source']}") - end + Dependabot.logger.info(" #{ic.version_requirement} - from #{ic.source}") unless ic.version_requirement.nil? - ic["update-types"]&.each do |update_type| - msg = " #{update_type} - from #{ic['source']}" + ic.update_types&.each do |update_type| + msg = " #{update_type} - from #{ic.source}" msg += " (doesn't apply to security update)" if security_updates_only? Dependabot.logger.info(msg) end @@ -524,26 +530,26 @@ def collect_permitted_update_types(dependency) return [] if matching_rules.any? { |r| allow_rule_permits_all_types?(r) } matching_rules - .flat_map { |r| r.fetch("update-types", []) } + .flat_map(&:update_types) .filter_map { |t| t.is_a?(String) ? t.downcase.strip : nil } .select { |t| Dependabot::Updater::UpdateTypeHelper::ALL_SEMVER_UPDATE_TYPES.include?(t) } .uniq end - sig { params(dependency: Dependabot::Dependency).returns(T::Array[T::Hash[String, T.untyped]]) } + sig { params(dependency: Dependabot::Dependency).returns(T::Array[AllowedUpdate]) } def matching_allow_rules(dependency) allowed_updates.select do |update| allow_rule_matches_dependency?(update, dependency) end end - sig { params(update: T::Hash[String, T.untyped], dependency: Dependabot::Dependency).returns(T::Boolean) } + sig { params(update: AllowedUpdate, dependency: Dependabot::Dependency).returns(T::Boolean) } def allow_rule_matches_dependency?(update, dependency) - condition_name = update.fetch("dependency-name", nil) + condition_name = update.dependency_name return false if condition_name && !name_match?(condition_name, dependency.name) - dep_type = update.fetch("dependency-type", nil) - return true if dep_type.nil? || dep_type == "all" + dep_type = update.dependency_type + return true if dep_type == "all" # Indirect deps don't match top-level type rules (matching allowed_update? behavior) return false if dependency.requirements.none? && TOP_LEVEL_DEPENDENCY_TYPES.include?(dep_type) @@ -557,9 +563,9 @@ def allow_rule_matches_dependency?(update, dependency) end end - sig { params(rule: T::Hash[String, T.untyped]).returns(T::Boolean) } + sig { params(rule: AllowedUpdate).returns(T::Boolean) } def allow_rule_permits_all_types?(rule) - !rule.key?("update-types") || !rule["update-types"].is_a?(Array) || rule["update-types"].empty? + rule.update_types.empty? end sig { params(dependency: Dependabot::Dependency).returns(T.nilable(Dependabot::Version)) } @@ -678,17 +684,16 @@ def clean_directories(source_details) def calculate_update_config update_config_ignore_conditions = ignore_conditions.map do |ic| Dependabot::Config::IgnoreCondition.new( - dependency_name: T.let(ic["dependency-name"], String), - versions: T.let([ic["version-requirement"]].compact, T::Array[String]), - update_types: T.let(ic["update-types"], T.nilable(T::Array[String])) + dependency_name: ic.dependency_name, + versions: [ic.version_requirement].compact, + update_types: ic.update_types ) end - update_config = Dependabot::Config::UpdateConfig.new( - ignore_conditions: T.let(update_config_ignore_conditions, T::Array[Dependabot::Config::IgnoreCondition]), - exclude_paths: T.let(exclude_paths, T.nilable(T::Array[String])) + Dependabot::Config::UpdateConfig.new( + ignore_conditions: update_config_ignore_conditions, + exclude_paths: exclude_paths ) - T.let(update_config, Dependabot::Config::UpdateConfig) end end end diff --git a/updater/lib/dependabot/job/allowed_update.rb b/updater/lib/dependabot/job/allowed_update.rb new file mode 100644 index 00000000000..e71be504a9b --- /dev/null +++ b/updater/lib/dependabot/job/allowed_update.rb @@ -0,0 +1,44 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dependabot + class Job + # Parsed representation of an allowed update rule from the job definition. + # + # Replaces the raw T::Hash[String, T.untyped] with a typed struct so + # downstream code gets compile-time checked field access instead of + # hash key lookups that return T.untyped. + class AllowedUpdate < T::ImmutableStruct + extend T::Sig + + const :dependency_name, T.nilable(String) + const :dependency_type, String, default: "all" + const :update_type, String, default: "all" + const :update_types, T::Array[String], default: [] + + sig { params(hash: T::Hash[String, T.untyped]).returns(AllowedUpdate) } + def self.from_hash(hash) + new( + dependency_name: hash["dependency-name"], + dependency_type: hash.fetch("dependency-type", "all"), + update_type: hash.fetch("update-type", "all"), + update_types: hash.fetch("update-types", []) || [] + ) + end + + # Temporary bridge: returns the original hash format for code that + # still expects hash access. Remove once all callers are migrated. + sig { returns(T::Hash[String, T.untyped]) } + def to_hash + h = T.let({}, T::Hash[String, T.untyped]) + h["dependency-name"] = dependency_name if dependency_name + h["dependency-type"] = dependency_type + h["update-type"] = update_type + h["update-types"] = update_types unless update_types.empty? + h + end + end + end +end diff --git a/updater/lib/dependabot/job/ignore_condition.rb b/updater/lib/dependabot/job/ignore_condition.rb new file mode 100644 index 00000000000..43d0e0c1bb4 --- /dev/null +++ b/updater/lib/dependabot/job/ignore_condition.rb @@ -0,0 +1,32 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dependabot + class Job + # Parsed representation of an ignore condition from the job definition. + # + # The raw JSON uses string keys like "dependency-name" and "version-requirement". + # This struct parses those into typed fields at the boundary so the rest of + # the codebase gets compile-time field access. + class IgnoreCondition < T::ImmutableStruct + extend T::Sig + + const :dependency_name, String + const :version_requirement, T.nilable(String) + const :update_types, T.nilable(T::Array[String]) + const :source, T.nilable(String) + + sig { params(hash: T::Hash[String, T.untyped]).returns(IgnoreCondition) } + def self.from_hash(hash) + new( + dependency_name: hash.fetch("dependency-name"), + version_requirement: hash["version-requirement"], + update_types: hash["update-types"], + source: hash["source"] + ) + end + end + end +end diff --git a/updater/lib/dependabot/job/security_advisory_entry.rb b/updater/lib/dependabot/job/security_advisory_entry.rb new file mode 100644 index 00000000000..58e8699e2a9 --- /dev/null +++ b/updater/lib/dependabot/job/security_advisory_entry.rb @@ -0,0 +1,28 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dependabot + class Job + # Parsed representation of a security advisory from the job definition. + class SecurityAdvisoryEntry < T::ImmutableStruct + extend T::Sig + + const :dependency_name, String + const :affected_versions, T::Array[String], default: [] + const :patched_versions, T::Array[String], default: [] + const :unaffected_versions, T::Array[String], default: [] + + sig { params(hash: T::Hash[String, T.untyped]).returns(SecurityAdvisoryEntry) } + def self.from_hash(hash) + new( + dependency_name: hash.fetch("dependency-name"), + affected_versions: hash.fetch("affected-versions", []) || [], + patched_versions: hash.fetch("patched-versions", []) || [], + unaffected_versions: hash.fetch("unaffected-versions", []) || [] + ) + end + end + end +end