Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion updater/lib/dependabot/dependency_change.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When passing ignore conditions into Dependabot::PullRequestCreator::MessageBuilder, the mapping currently omits "updated-at". MessageBuilder#ignore_conditions_table uses that field to sort and show the latest 20 ignore conditions; without it, all entries sort as Time.at(0) and the “most recent” table becomes misleading. Preserve and forward updated-at (and any other fields MessageBuilder depends on) from Job::IgnoreCondition when building this hash payload.

Suggested change
"source" => ic.source
"source" => ic.source,
"updated-at" => ic.updated_at

Copilot uses AI. Check for mistakes.
}.compact
end,
notices: notices
).message

Expand Down
83 changes: 44 additions & 39 deletions updater/lib/dependabot/job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]) }
Expand All @@ -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) }
Expand All @@ -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
Comment on lines +101 to 102
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security_advisories is now typed as T::Array[SecurityAdvisoryEntry], but there are still call sites treating elements as hash-like (e.g., updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb uses job.security_advisories.first["dependency-name"]). That will raise at runtime because SecurityAdvisoryEntry doesn’t implement #[]. Update remaining callers to use the struct’s typed accessors (e.g., job.security_advisories.first&.dependency_name) instead of hash indexing / T.cast.

Copilot uses AI. Check for mistakes.

sig { returns(T::Boolean) }
Expand Down Expand Up @@ -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])
)
Expand All @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Comment on lines +432 to 440
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security_advisories_for is currently using T.unsafe to pass arrays of version strings into Dependabot::SecurityAdvisory. This undermines the goal of eliminating T.untyped propagation and can hide invalid data until later. Prefer converting adv.affected_versions into the expected Dependabot::Requirement objects (using the requirement class for package_manager) and pass safe_versions as a normal T::Array[String] (which already matches SecurityAdvisory’s safe_versions type) so you can remove the T.unsafe calls entirely.

Copilot uses AI. Check for mistakes.
end
end
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)) }
Expand Down Expand Up @@ -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
44 changes: 44 additions & 0 deletions updater/lib/dependabot/job/allowed_update.rb
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions updater/lib/dependabot/job/ignore_condition.rb
Original file line number Diff line number Diff line change
@@ -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"]
)
Comment on lines +16 to +28
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IgnoreCondition drops fields that other parts of the system can use when rendering ignore-condition tables (notably "updated-at", which Dependabot::PullRequestCreator::MessageBuilder uses to sort “Most Recent Ignore Conditions”). Consider adding an updated_at (or similar) field to this struct and parsing it in from_hash, so downstream code (e.g., DependencyChange#pr_message) can preserve ordering semantics instead of all entries being treated as the epoch (Time.at(0)).

Copilot uses AI. Check for mistakes.
end
end
end
end
28 changes: 28 additions & 0 deletions updater/lib/dependabot/job/security_advisory_entry.rb
Original file line number Diff line number Diff line change
@@ -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
Loading