diff --git a/bundler/lib/dependabot/bundler.rb b/bundler/lib/dependabot/bundler.rb index 6053139d41e..bff5d467dee 100644 --- a/bundler/lib/dependabot/bundler.rb +++ b/bundler/lib/dependabot/bundler.rb @@ -10,6 +10,7 @@ require "dependabot/bundler/metadata_finder" require "dependabot/bundler/requirement" require "dependabot/bundler/version" +require "dependabot/bundler/package_manager" require "dependabot/pull_request_creator/labeler" Dependabot::PullRequestCreator::Labeler diff --git a/bundler/lib/dependabot/bundler/file_parser.rb b/bundler/lib/dependabot/bundler/file_parser.rb index e71df450bf6..aafa43b3de2 100644 --- a/bundler/lib/dependabot/bundler/file_parser.rb +++ b/bundler/lib/dependabot/bundler/file_parser.rb @@ -16,11 +16,13 @@ module Dependabot module Bundler class FileParser < Dependabot::FileParsers::Base + extend T::Sig require "dependabot/file_parsers/base/dependency_set" require "dependabot/bundler/file_parser/file_preparer" require "dependabot/bundler/file_parser/gemfile_declaration_finder" require "dependabot/bundler/file_parser/gemspec_declaration_finder" + sig { override.returns(T::Array[Dependabot::Dependency]) } def parse dependency_set = DependencySet.new dependency_set += gemfile_dependencies @@ -30,6 +32,11 @@ def parse dependency_set.dependencies end + sig { returns(PackageManagerBase) } + def package_manager + PackageManager.new(bundler_version) + end + private def check_external_code(dependencies) diff --git a/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb b/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb index 43176eec823..ce5ea86891d 100644 --- a/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb +++ b/bundler/lib/dependabot/bundler/file_parser/file_preparer.rb @@ -5,6 +5,7 @@ require "dependabot/dependency_file" require "dependabot/file_parsers/base" require "dependabot/bundler/file_updater/gemspec_sanitizer" +require "dependabot/bundler/package_manager" module Dependabot module Bundler diff --git a/bundler/lib/dependabot/bundler/package_manager.rb b/bundler/lib/dependabot/bundler/package_manager.rb new file mode 100644 index 00000000000..b5d920de285 --- /dev/null +++ b/bundler/lib/dependabot/bundler/package_manager.rb @@ -0,0 +1,53 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/bundler/version" +require "dependabot/package_manager" + +module Dependabot + module Bundler + PACKAGE_MANAGER = "bundler" + + SUPPORTED_BUNDLER_VERSIONS = T.let([ + Version.new("2") + ].freeze, T::Array[Dependabot::Version]) + + DEPRECATED_BUNDLER_VERSIONS = T.let([ + Version.new("1") + ].freeze, T::Array[Dependabot::Version]) + + class PackageManager < PackageManagerBase + extend T::Sig + + sig { params(version: T.any(String, Dependabot::Version)).void } + def initialize(version) + @version = T.let(Version.new(version), Dependabot::Version) + @name = T.let(PACKAGE_MANAGER, String) + @deprecated_versions = T.let(DEPRECATED_BUNDLER_VERSIONS, T::Array[Dependabot::Version]) + @supported_versions = T.let(SUPPORTED_BUNDLER_VERSIONS, T::Array[Dependabot::Version]) + end + + sig { override.returns(String) } + attr_reader :name + + sig { override.returns(Dependabot::Version) } + attr_reader :version + + sig { override.returns(T::Array[Dependabot::Version]) } + attr_reader :deprecated_versions + + sig { override.returns(T::Array[Dependabot::Version]) } + attr_reader :supported_versions + + sig { override.returns(T::Boolean) } + def deprecated? + deprecated_versions.include?(version) + end + sig { override.returns(T::Boolean) } + def unsupported? + !deprecated? && version < supported_versions.first + end + end + end +end diff --git a/bundler/spec/dependabot/bundler/file_parser_spec.rb b/bundler/spec/dependabot/bundler/file_parser_spec.rb index c6d580272bc..ea0b8d2b2c1 100644 --- a/bundler/spec/dependabot/bundler/file_parser_spec.rb +++ b/bundler/spec/dependabot/bundler/file_parser_spec.rb @@ -849,4 +849,10 @@ end end end + + describe "#package_manager" do + it "returns the correct package manager" do + expect(parser.package_manager).to be_a(Dependabot::Bundler::PackageManager) + end + end end diff --git a/bundler/spec/dependabot/bundler/package_manager_spec.rb b/bundler/spec/dependabot/bundler/package_manager_spec.rb new file mode 100644 index 00000000000..faf8b28a074 --- /dev/null +++ b/bundler/spec/dependabot/bundler/package_manager_spec.rb @@ -0,0 +1,116 @@ +# typed: false +# frozen_string_literal: true + +require "dependabot/bundler/package_manager" +require "dependabot/package_manager" +require "spec_helper" + +RSpec.describe Dependabot::Bundler::PackageManager do + let(:package_manager) { described_class.new(version) } + + describe "#initialize" do + context "when version is a String" do + let(:version) { "2" } + + it "sets the version correctly" do + expect(package_manager.version).to eq(Dependabot::Bundler::Version.new(version)) + end + + it "sets the name correctly" do + expect(package_manager.name).to eq(Dependabot::Bundler::PACKAGE_MANAGER) + end + + it "sets the deprecated_versions correctly" do + expect(package_manager.deprecated_versions).to eq(Dependabot::Bundler::DEPRECATED_BUNDLER_VERSIONS) + end + + it "sets the supported_versions correctly" do + expect(package_manager.supported_versions).to eq(Dependabot::Bundler::SUPPORTED_BUNDLER_VERSIONS) + end + end + + context "when version is a Dependabot::Bundler::Version" do + let(:version) { Dependabot::Bundler::Version.new("2") } + + it "sets the version correctly" do + expect(package_manager.version).to eq(version) + end + + it "sets the name correctly" do + expect(package_manager.name).to eq(Dependabot::Bundler::PACKAGE_MANAGER) + end + + it "sets the deprecated_versions correctly" do + expect(package_manager.deprecated_versions).to eq(Dependabot::Bundler::DEPRECATED_BUNDLER_VERSIONS) + end + + it "sets the supported_versions correctly" do + expect(package_manager.supported_versions).to eq(Dependabot::Bundler::SUPPORTED_BUNDLER_VERSIONS) + end + end + end + + describe "#deprecated?" do + context "when version is deprecated?" do + let(:version) { "1" } + + it "returns true" do + expect(package_manager.deprecated?).to be true + end + end + + context "when version is not deprecated" do + let(:version) { "2" } + + it "returns false" do + expect(package_manager.deprecated?).to be false + end + end + end + + describe "#unsupported" do + context "when version is deprecated?" do + let(:version) { "1" } + + it "returns false" do + expect(package_manager.unsupported?).to be false + end + end + + context "when version is supported" do + let(:version) { "2" } + + it "returns false" do + expect(package_manager.unsupported?).to be false + end + end + + context "when version is unsupported?" do + let(:version) { "0.9" } + + it "returns true" do + expect(package_manager.unsupported?).to be true + end + end + end + + describe "#supported_versions" do + context "when there are supported versions" do + let(:version) { "2" } + + it "returns the correct supported versions" do + expect(package_manager.supported_versions).to eq([Dependabot::Bundler::Version.new("2")]) + end + end + end + + describe "#deprecated_versions" do + context "when there are deprecated versions" do + let(:version) { "2" } + + it "returns the correct deprecated versions" do + expect(package_manager.deprecated_versions).to eq([Dependabot::Bundler::Version.new("1")]) + end + end + end +end diff --git a/common/lib/dependabot/file_parsers/base.rb b/common/lib/dependabot/file_parsers/base.rb index 3251027ec54..450e82bf06c 100644 --- a/common/lib/dependabot/file_parsers/base.rb +++ b/common/lib/dependabot/file_parsers/base.rb @@ -3,6 +3,7 @@ require "sorbet-runtime" require "dependabot/credential" +require "dependabot/package_manager" module Dependabot module FileParsers @@ -53,6 +54,11 @@ def initialize(dependency_files:, source:, repo_contents_path: nil, sig { abstract.returns(T::Array[Dependabot::Dependency]) } def parse; end + sig { returns(T.nilable(PackageManagerBase)) } + def package_manager + nil + end + private sig { abstract.void } diff --git a/common/lib/dependabot/notices.rb b/common/lib/dependabot/notices.rb new file mode 100644 index 00000000000..f4245b332a5 --- /dev/null +++ b/common/lib/dependabot/notices.rb @@ -0,0 +1,164 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/package_manager" + +module Dependabot + class Notice + extend T::Sig + + sig { returns(String) } + attr_reader :mode, :type, :package_manager_name, :message, :markdown + + # Initializes a new Notice object. + # @param mode [String] The mode of the notice (e.g., "WARN", "ERROR"). + # @param type [String] The type of the notice (e.g., "bundler_deprecated_warn"). + # @param package_manager_name [String] The name of the package manager (e.g., "bundler"). + # @param message [String] The main message of the notice. + # @param markdown [String] The markdown formatted message. + sig do + params( + mode: String, + type: String, + package_manager_name: String, + message: String, + markdown: String + ).void + end + def initialize(mode:, type:, package_manager_name:, message: "", markdown: "") + @mode = mode + @type = type + @package_manager_name = package_manager_name + @message = message + @markdown = markdown + end + + # Converts the Notice object to a hash. + # @return [Hash] The hash representation of the notice. + sig { returns(T::Hash[Symbol, T.untyped]) } + def to_hash + { + mode: @mode, + type: @type, + package_manager_name: @package_manager_name, + message: @message, + markdown: @markdown + } + end + + # Generates a message for supported versions. + # @param supported_versions [Array, nil] The supported versions of the package manager. + # @param support_later_versions [Boolean] Whether later versions are supported. + # @return [String, nil] The generated message or nil if no supported versions are provided. + sig do + params( + supported_versions: T.nilable(T::Array[Dependabot::Version]), + support_later_versions: T::Boolean + ).returns(String) + end + def self.generate_supported_versions_message(supported_versions, support_later_versions) + return "" unless supported_versions&.any? + + versions_string = supported_versions.map { |version| "v#{version}" }.join(", ") + + later_message = support_later_versions ? " or later" : "" + + return "Please upgrade to version `#{versions_string}`#{later_message}." if supported_versions.count == 1 + + "Please upgrade to one of the following versions: #{versions_string}#{later_message}." + end + + # Generates a support notice for the given package manager. + # @param package_manager [PackageManagerBase] The package manager object. + # @return [Notice, nil] The generated notice or nil if no notice is applicable. + sig do + params( + package_manager: PackageManagerBase + ).returns(T.nilable(Notice)) + end + def self.generate_support_notice(package_manager) + deprecation_notice = generate_pm_deprecation_notice(package_manager) + + return deprecation_notice if deprecation_notice + + generate_pm_unsupported_notice(package_manager) + end + + # Generates a deprecation notice for the given package manager. + # @param package_manager [PackageManagerBase] The package manager object. + # @return [Notice, nil] The generated deprecation notice or nil if the package manager is not deprecated. + sig do + params( + package_manager: PackageManagerBase + ).returns(T.nilable(Notice)) + end + def self.generate_pm_deprecation_notice(package_manager) + return nil unless package_manager.deprecated? + + mode = "WARN" + supported_versions_message = generate_supported_versions_message( + package_manager.supported_versions, + package_manager.support_later_versions? + ) + notice_type = "#{package_manager.name}_deprecated_#{mode.downcase}" + message = "Dependabot will stop supporting `#{package_manager.name}` `v#{package_manager.version}`!" + ## Create a warning markdown message + markdown = "> [!WARNING]\n" + ## Add the deprecation warning to the message + markdown += "> #{message}\n>\n" + + ## Add the supported versions to the message + unless supported_versions_message.empty? + message += "\n#{supported_versions_message}\n" + markdown += "> #{supported_versions_message}\n>\n" + end + + Notice.new( + mode: mode, + type: notice_type, + package_manager_name: package_manager.name, + message: message, + markdown: markdown + ) + end + + # Generates an unsupported notice for the given package manager. + # @param package_manager [PackageManagerBase] The package manager object. + # @return [Notice, nil] The generated unsupported notice or nil if the package manager is not unsupported. + sig do + params( + package_manager: PackageManagerBase + ).returns(T.nilable(Notice)) + end + def self.generate_pm_unsupported_notice(package_manager) + return nil unless package_manager.unsupported? + + mode = "ERROR" + supported_versions_message = generate_supported_versions_message( + package_manager.supported_versions, + package_manager.support_later_versions? + ) + notice_type = "#{package_manager.name}_unsupported_#{mode.downcase}" + message = "Dependabot no longer supports `#{package_manager.name}` `v#{package_manager.version}`!" + ## Create an error markdown message + markdown = "> [!IMPORTANT]\n" + ## Add the error message to the message + markdown += "> #{message}\n>\n" + + ## Add the supported versions to the message + unless supported_versions_message.empty? + message += "\n#{supported_versions_message}\n" + markdown += "> #{supported_versions_message}\n>\n" + end + + Notice.new( + mode: mode, + type: notice_type, + package_manager_name: package_manager.name, + message: message, + markdown: markdown + ) + end + end +end diff --git a/common/lib/dependabot/package_manager.rb b/common/lib/dependabot/package_manager.rb new file mode 100644 index 00000000000..c4352a91ac0 --- /dev/null +++ b/common/lib/dependabot/package_manager.rb @@ -0,0 +1,84 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dependabot + class PackageManagerBase + extend T::Sig + extend T::Helpers + + abstract! + + # The name of the package manager (e.g., "bundler"). + # @example + # package_manager.name #=> "bundler" + sig { abstract.returns(String) } + def name; end + + # The version of the package manager (e.g., Dependabot::Version.new("2.1.4")). + # @example + # package_manager.version #=> Dependabot::Version.new("2.1.4") + sig { abstract.returns(Dependabot::Version) } + def version; end + + # Returns an array of deprecated versions of the package manager. + # By default, returns an empty array if not overridden in the subclass. + # @example + # package_manager.deprecated_versions #=> [Dependabot::Version.new("1.0.0"), Dependabot::Version.new("1.1.0")] + sig { returns(T::Array[Dependabot::Version]) } + def deprecated_versions + [] + end + + # Returns an array of unsupported versions of the package manager. + # By default, returns an empty array if not overridden in the subclass. + # @example + # package_manager.unsupported_versions #=> [Dependabot::Version.new("0.9.0")] + sig { returns(T::Array[Dependabot::Version]) } + def unsupported_versions + [] + end + + # Returns an array of supported versions of the package manager. + # By default, returns an empty array if not overridden in the subclass. + # @example + # package_manager.supported_versions #=> [Dependabot::Version.new("2.0.0"), Dependabot::Version.new("2.1.0")] + sig { returns(T::Array[Dependabot::Version]) } + def supported_versions + [] + end + + # Checks if the current version is deprecated. + # Returns true if the version is in the deprecated_versions array; false otherwise. + # @example + # package_manager.deprecated? #=> true + sig { returns(T::Boolean) } + def deprecated? + deprecated_versions.include?(version) + end + + # Checks if the current version is unsupported. + # Returns true if the version is in the unsupported_versions array; false otherwise. + # @example + # package_manager.unsupported? #=> false + sig { returns(T::Boolean) } + def unsupported? + return true if unsupported_versions.include?(version) + + supported_versions = self.supported_versions + return version < supported_versions.first if supported_versions.any? + + false + end + + # Indicates if the package manager supports later versions beyond those listed in supported_versions. + # By default, returns false if not overridden in the subclass. + # @example + # package_manager.support_later_versions? #=> true + sig { returns(T::Boolean) } + def support_later_versions? + false + end + end +end diff --git a/common/lib/dependabot/pull_request_creator/message_builder.rb b/common/lib/dependabot/pull_request_creator/message_builder.rb index 521eb88231b..1968cfb3f6c 100644 --- a/common/lib/dependabot/pull_request_creator/message_builder.rb +++ b/common/lib/dependabot/pull_request_creator/message_builder.rb @@ -12,6 +12,7 @@ require "dependabot/metadata_finders" require "dependabot/pull_request_creator" require "dependabot/pull_request_creator/message" +require "dependabot/notices" # rubocop:disable Metrics/ClassLength module Dependabot @@ -64,6 +65,9 @@ class MessageBuilder sig { returns(T::Array[T::Hash[String, String]]) } attr_reader :ignore_conditions + sig { returns(T.nilable(T::Array[Dependabot::Notice])) } + attr_reader :notices + TRUNCATED_MSG = "...\n\n_Description has been truncated_" sig do @@ -80,7 +84,8 @@ class MessageBuilder dependency_group: T.nilable(Dependabot::DependencyGroup), pr_message_max_length: T.nilable(Integer), pr_message_encoding: T.nilable(Encoding), - ignore_conditions: T::Array[T::Hash[String, String]] + ignore_conditions: T::Array[T::Hash[String, String]], + notices: T.nilable(T::Array[Dependabot::Notice]) ) .void end @@ -88,7 +93,8 @@ def initialize(source:, dependencies:, files:, credentials:, pr_message_header: nil, pr_message_footer: nil, commit_message_options: {}, vulnerabilities_fixed: {}, github_redirection_service: DEFAULT_GITHUB_REDIRECTION_SERVICE, - dependency_group: nil, pr_message_max_length: nil, pr_message_encoding: nil, ignore_conditions: []) + dependency_group: nil, pr_message_max_length: nil, pr_message_encoding: nil, + ignore_conditions: [], notices: nil) @dependencies = dependencies @files = files @source = source @@ -102,6 +108,7 @@ def initialize(source:, dependencies:, files:, credentials:, @pr_message_max_length = pr_message_max_length @pr_message_encoding = pr_message_encoding @ignore_conditions = ignore_conditions + @notices = notices end sig { params(pr_message_max_length: Integer).returns(Integer) } @@ -119,7 +126,8 @@ def pr_name sig { returns(String) } def pr_message - msg = "#{suffixed_pr_message_header}" \ + msg = "#{pr_notices}" \ + "#{suffixed_pr_message_header}" \ "#{commit_message_intro}" \ "#{metadata_cascades}" \ "#{ignore_conditions_table}" \ @@ -131,6 +139,18 @@ def pr_message suffixed_pr_message_header + prefixed_pr_message_footer end + sig { returns(T.nilable(String)) } + def pr_notices + notices = @notices || [] + unique_messages = notices.filter_map do |notice| + markdown = notice.markdown if notice + markdown unless markdown.empty? + end.uniq + + message = unique_messages.join("\n\n") + message.empty? ? nil : message + end + # Truncate PR message as determined by the pr_message_max_length and pr_message_encoding instance variables # The encoding is used when calculating length, all messages are returned as ruby UTF_8 encoded string sig { params(msg: String).returns(String) } @@ -316,6 +336,8 @@ def prefixed_pr_message_footer def suffixed_pr_message_header return "" unless pr_message_header + return "#{pr_message_header}\n\n" if notices + "#{pr_message_header}\n\n" end diff --git a/common/spec/dependabot/file_parsers/base_spec.rb b/common/spec/dependabot/file_parsers/base_spec.rb index 6e74833bbe9..dc6e99be775 100644 --- a/common/spec/dependabot/file_parsers/base_spec.rb +++ b/common/spec/dependabot/file_parsers/base_spec.rb @@ -7,15 +7,24 @@ require "dependabot/file_parsers/base" RSpec.describe Dependabot::FileParsers::Base do + let(:package_manager_instance) { nil } # Default value + let(:child_class) do + pm_instance = package_manager_instance + Class.new(described_class) do - def check_required_files + define_method(:check_required_files) do %w(Gemfile).each do |filename| raise "No #{filename}!" unless get_original_file(filename) end end + + define_method(:parse) { [] } + + define_method(:package_manager) { pm_instance } end end + let(:parser_instance) do child_class.new(dependency_files: files, source: source) end @@ -36,6 +45,34 @@ def check_required_files end let(:files) { [gemfile] } + let(:concrete_package_manager_class) do + Class.new(Dependabot::PackageManagerBase) do + def name + "bundler" + end + + def version + Dependabot::Version.new("1.0.0") + end + + def deprecated_versions + [Dependabot::Version.new("1.0.0")] + end + + def unsupported_versions + [Dependabot::Version.new("0.9.0")] + end + + def supported_versions + [Dependabot::Version.new("1.1.0"), Dependabot::Version.new("2.0.0")] + end + + def support_later_versions? + true + end + end + end + describe ".new" do context "when the required file is present" do let(:files) { [gemfile] } @@ -69,4 +106,30 @@ def check_required_files it { is_expected.to be_nil } end end + + describe "#package_manager" do + context "when called on the base class" do + it "returns nil" do + expect(parser_instance.package_manager).to be_nil + end + end + + context "when called on a concrete class" do + let(:package_manager_instance) { concrete_package_manager_class.new } + + it "returns an instance of PackageManagerBase" do + expect(parser_instance.package_manager).to be_a(Dependabot::PackageManagerBase) + end + + it "returns the correct package manager details" do + pm = parser_instance.package_manager + expect(pm.name).to eq("bundler") + expect(pm.version).to eq(Dependabot::Version.new("1.0.0")) + expect(pm.deprecated_versions).to eq([Dependabot::Version.new("1.0.0")]) + expect(pm.unsupported_versions).to eq([Dependabot::Version.new("0.9.0")]) + expect(pm.supported_versions).to eq([Dependabot::Version.new("1.1.0"), Dependabot::Version.new("2.0.0")]) + expect(pm.support_later_versions?).to be true + end + end + end end diff --git a/common/spec/dependabot/notices_spec.rb b/common/spec/dependabot/notices_spec.rb new file mode 100644 index 00000000000..0db1d7876d3 --- /dev/null +++ b/common/spec/dependabot/notices_spec.rb @@ -0,0 +1,232 @@ +# typed: false +# frozen_string_literal: true + +require "dependabot/version" +require "dependabot/package_manager" +require "dependabot/notices" + +# A stub package manager for testing purposes. +class StubPackageManager < Dependabot::PackageManagerBase + def initialize(name:, version:, deprecated_versions: [], unsupported_versions: [], supported_versions: [], + support_later_versions: false) + @name = name + @version = version + @deprecated_versions = deprecated_versions + @unsupported_versions = unsupported_versions + @supported_versions = supported_versions + @support_later_versions = support_later_versions + end + + attr_reader :name + attr_reader :version + attr_reader :deprecated_versions + attr_reader :unsupported_versions + attr_reader :supported_versions + attr_reader :support_later_versions +end + +RSpec.describe Dependabot::Notice do + describe ".generate_supported_versions_message" do + subject(:generate_supported_versions_message) do + described_class.generate_supported_versions_message(supported_versions, support_later_versions) + end + + context "when supported_versions has one version" do + let(:supported_versions) { [Dependabot::Version.new("2")] } + let(:support_later_versions) { false } + + it "returns the correct message" do + expect(generate_supported_versions_message) + .to eq("Please upgrade to version `v2`.") + end + end + + context "when supported_versions has one version and later versions are supported" do + let(:supported_versions) { [Dependabot::Version.new("2")] } + let(:support_later_versions) { true } + + it "returns the correct message" do + expect(generate_supported_versions_message) + .to eq("Please upgrade to version `v2` or later.") + end + end + + context "when supported_versions has multiple versions" do + let(:supported_versions) do + [Dependabot::Version.new("2"), Dependabot::Version.new("3"), + Dependabot::Version.new("4")] + end + let(:support_later_versions) { false } + + it "returns the correct message" do + expect(generate_supported_versions_message) + .to eq("Please upgrade to one of the following versions: v2, v3, v4.") + end + end + + context "when supported_versions has multiple versions and later versions are supported" do + let(:supported_versions) do + [Dependabot::Version.new("2"), Dependabot::Version.new("3"), + Dependabot::Version.new("4")] + end + let(:support_later_versions) { true } + + it "returns the correct message" do + expect(generate_supported_versions_message) + .to eq("Please upgrade to one of the following versions: v2, v3, v4 or later.") + end + end + + context "when supported_versions is nil" do + let(:supported_versions) { nil } + let(:support_later_versions) { false } + + it "returns empty string" do + expect(generate_supported_versions_message).to eq("") + end + end + + context "when supported_versions is empty" do + let(:supported_versions) { [] } + let(:support_later_versions) { false } + + it "returns nil" do + expect(generate_supported_versions_message).to eq("") + end + end + end + + describe ".generate_support_notice" do + subject(:generate_support_notice) do + described_class.generate_support_notice(package_manager) + end + + let(:package_manager) do + StubPackageManager.new( + name: "bundler", + version: Dependabot::Version.new("1"), + deprecated_versions: deprecated_versions, + unsupported_versions: unsupported_versions, + supported_versions: supported_versions + ) + end + + let(:supported_versions) { [Dependabot::Version.new("2"), Dependabot::Version.new("3")] } + let(:deprecated_versions) { [Dependabot::Version.new("1")] } + let(:unsupported_versions) { [] } + + context "when the package manager is deprecated" do + let(:unsupported_versions) { [] } + + it "returns the correct support notice" do + expect(generate_support_notice.to_hash) + .to eq({ + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + }) + end + end + + context "when the package manager is unsupported" do + let(:deprecated_versions) { [] } + let(:unsupported_versions) { [Dependabot::Version.new("1")] } + + it "returns the correct support notice" do + expect(generate_support_notice.to_hash) + .to eq({ + mode: "ERROR", + type: "bundler_unsupported_error", + package_manager_name: "bundler", + message: "Dependabot no longer supports `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!IMPORTANT]\n> Dependabot no longer supports `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + }) + end + end + + context "when the package manager is neither deprecated nor unsupported" do + let(:version) { Dependabot::Version.new("2") } + let(:supported_versions) do + [Dependabot::Version.new("2"), Dependabot::Version.new("3"), + Dependabot::Version.new("4")] + end + let(:deprecated_versions) { [] } + let(:unsupported_versions) { [] } + let(:package_manager) do + StubPackageManager.new( + name: "bundler", + version: Dependabot::Version.new("2"), + deprecated_versions: deprecated_versions, + unsupported_versions: unsupported_versions, + supported_versions: supported_versions + ) + end + + it "returns nil" do + expect(generate_support_notice).to be_nil + end + end + end + + describe ".generate_pm_deprecation_notice" do + subject(:generate_pm_deprecation_notice) do + described_class.generate_pm_deprecation_notice(package_manager) + end + + let(:package_manager) do + StubPackageManager.new( + name: "bundler", + version: Dependabot::Version.new("1"), + deprecated_versions: [Dependabot::Version.new("1")], + supported_versions: [Dependabot::Version.new("2"), Dependabot::Version.new("3")] + ) + end + + it "returns the correct deprecation notice" do + expect(generate_pm_deprecation_notice.to_hash) + .to eq({ + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + }) + end + end + + describe ".generate_pm_unsupported_notice" do + subject(:generate_pm_unsupported_notice) do + described_class.generate_pm_unsupported_notice(package_manager) + end + + let(:package_manager) do + StubPackageManager.new( + name: "bundler", + version: Dependabot::Version.new("1"), + supported_versions: supported_versions + ) + end + let(:supported_versions) { [Dependabot::Version.new("2"), Dependabot::Version.new("3")] } + + it "returns the correct unsupported notice" do + expect(generate_pm_unsupported_notice.to_hash) + .to eq({ + mode: "ERROR", + type: "bundler_unsupported_error", + package_manager_name: "bundler", + message: "Dependabot no longer supports `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!IMPORTANT]\n> Dependabot no longer supports `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + }) + end + end +end diff --git a/common/spec/dependabot/package_manager_spec.rb b/common/spec/dependabot/package_manager_spec.rb new file mode 100644 index 00000000000..d39f9ed9cbf --- /dev/null +++ b/common/spec/dependabot/package_manager_spec.rb @@ -0,0 +1,142 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/package_manager" + +RSpec.describe Dependabot::PackageManagerBase do # rubocop:disable RSpec/FilePath,RSpec/SpecFilePathFormat + let(:concrete_class) do + Class.new(Dependabot::PackageManagerBase) do + def name + "bundler" + end + + def version + Dependabot::Version.new("1.0.0") + end + + def deprecated_versions + [Dependabot::Version.new("1")] + end + + def unsupported_versions + [Dependabot::Version.new("0")] + end + + def supported_versions + [Dependabot::Version.new("1"), Dependabot::Version.new("2")] + end + + def support_later_versions? + true + end + end + end + + let(:default_concrete_class) do + Class.new(Dependabot::PackageManagerBase) do + def name + "bundler" + end + + def version + Dependabot::Version.new("1.0.0") + end + end + end + + let(:package_manager) { concrete_class.new } + let(:default_package_manager) { default_concrete_class.new } + + describe "#name" do + it "returns the name of the package manager" do + expect(package_manager.name).to eq("bundler") + end + end + + describe "#version" do + it "returns the version of the package manager" do + expect(package_manager.version).to eq(Dependabot::Version.new("1.0.0")) + end + end + + describe "#deprecated_versions" do + it "returns an array of deprecated versions" do + expect(package_manager.deprecated_versions).to eq([Dependabot::Version.new("1")]) + end + + it "returns an empty array by default" do + expect(default_package_manager.deprecated_versions).to eq([]) + end + end + + describe "#unsupported_versions" do + it "returns an array of unsupported versions" do + expect(package_manager.unsupported_versions).to eq([Dependabot::Version.new("0")]) + end + + it "returns an empty array by default" do + expect(default_package_manager.unsupported_versions).to eq([]) + end + end + + describe "#supported_versions" do + it "returns an array of supported versions" do + expect(package_manager.supported_versions).to eq([ + Dependabot::Version.new("1"), + Dependabot::Version.new("2") + ]) + end + + it "returns an empty array by default" do + expect(default_package_manager.supported_versions).to eq([]) + end + end + + describe "#deprecated?" do + it "returns true if the current version is deprecated" do + expect(package_manager.deprecated?).to be true + end + + it "returns false if the current version is not deprecated" do + allow(package_manager).to receive(:version).and_return(Dependabot::Version.new("1.1.0")) + expect(package_manager.deprecated?).to be false + end + + it "returns true if the current version is a major version and deprecated" do + allow(package_manager).to receive(:version).and_return(Dependabot::Version.new("1")) + expect(package_manager.deprecated?).to be true + end + end + + describe "#unsupported?" do + it "returns true if the current version is unsupported" do + allow(package_manager).to receive(:version).and_return(Dependabot::Version.new("0.9.0")) + expect(package_manager.unsupported?).to be true + end + + it "returns false if the current version is supported" do + expect(package_manager.unsupported?).to be false + end + + it "returns false if there is no list of supported versions" do + allow(default_package_manager).to receive(:version).and_return(Dependabot::Version.new("1.0.0")) + expect(default_package_manager.unsupported?).to be false + end + + it "returns true if the current version is a major version and unsupported" do + allow(package_manager).to receive(:version).and_return(Dependabot::Version.new("0")) + expect(package_manager.unsupported?).to be true + end + end + + describe "#support_later_versions?" do + it "returns true if the package manager supports later versions" do + expect(package_manager.support_later_versions?).to be true + end + + it "returns false by default" do + expect(default_package_manager.support_later_versions?).to be false + end + end +end diff --git a/common/spec/dependabot/pull_request_creator/message_builder_spec.rb b/common/spec/dependabot/pull_request_creator/message_builder_spec.rb index 8ece97cca72..5947b7f58e3 100644 --- a/common/spec/dependabot/pull_request_creator/message_builder_spec.rb +++ b/common/spec/dependabot/pull_request_creator/message_builder_spec.rb @@ -22,7 +22,8 @@ vulnerabilities_fixed: vulnerabilities_fixed, github_redirection_service: github_redirection_service, dependency_group: dependency_group, - ignore_conditions: ignore_conditions + ignore_conditions: ignore_conditions, + notices: notices ) end @@ -50,6 +51,7 @@ let(:commit_message_options) { { signoff_details: signoff_details, trailers: trailers } } let(:signoff_details) { nil } let(:trailers) { nil } + let(:notices) { [] } let(:vulnerabilities_fixed) { { "business" => [] } } let(:github_redirection_service) { "redirect.github.com" } let(:dependency_group) { nil } @@ -3315,6 +3317,79 @@ def commits_details(base:, head:) expect(pr_message).not_to include("Changelog: dependency") end end + + context "with generated single notices" do + let(:notices) do + [Dependabot::Notice.new( + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + )] + end + + it do + expect(pr_message).to start_with(notices[0].markdown) + end + end + + context "with generated multiple notices" do + let(:notices) do + [Dependabot::Notice.new( + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + ), Dependabot::Notice.new( + mode: "ERROR", + type: "bundler_unsupported_error", + package_manager_name: "bundler", + message: "Dependabot no longer supports `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!IMPORTANT]\n> Dependabot no longer supports `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + )] + end + + it do + expect(pr_message).to start_with( + "#{notices[0].markdown}\n\n#{notices[1].markdown}" + ) + end + end + + context "with duplicate notices" do + let(:notices) do + [Dependabot::Notice.new( + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + ), Dependabot::Notice.new( + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + )] + end + + it "returns a unique message" do + expect(pr_message).to start_with(notices[0].markdown) + expect(pr_message.scan(notices[0].markdown).count).to eq(1) + end + end end describe "#commit_message", :vcr do diff --git a/updater/lib/dependabot/dependency_change.rb b/updater/lib/dependabot/dependency_change.rb index d75b07b4460..a7065986e60 100644 --- a/updater/lib/dependabot/dependency_change.rb +++ b/updater/lib/dependabot/dependency_change.rb @@ -45,15 +45,19 @@ def initialize(deps_no_previous_version:, deps_no_change:) sig { returns(T.nilable(Dependabot::DependencyGroup)) } attr_reader :dependency_group + sig { returns(T::Array[Dependabot::Notice]) } + attr_reader :notices + sig do params( job: Dependabot::Job, updated_dependencies: T::Array[Dependabot::Dependency], updated_dependency_files: T::Array[Dependabot::DependencyFile], - dependency_group: T.nilable(Dependabot::DependencyGroup) + dependency_group: T.nilable(Dependabot::DependencyGroup), + notices: T::Array[Dependabot::Notice] ).void end - def initialize(job:, updated_dependencies:, updated_dependency_files:, dependency_group: nil) + def initialize(job:, updated_dependencies:, updated_dependency_files:, dependency_group: nil, notices: []) @job = job @updated_dependencies = updated_dependencies @updated_dependency_files = updated_dependency_files @@ -61,6 +65,7 @@ def initialize(job:, updated_dependencies:, updated_dependency_files:, dependenc @pr_message = T.let(nil, T.nilable(Dependabot::PullRequestCreator::Message)) ensure_dependencies_have_directories + @notices = notices end sig { returns(Dependabot::PullRequestCreator::Message) } @@ -90,7 +95,8 @@ 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, + notices: notices ).message @pr_message = message @@ -135,9 +141,11 @@ def merge_changes!(dependency_changes) dependency_changes.each do |dependency_change| updated_dependencies.concat(dependency_change.updated_dependencies) updated_dependency_files.concat(dependency_change.updated_dependency_files) + notices.concat(dependency_change.notices) end updated_dependencies.compact! updated_dependency_files.compact! + notices.compact! end sig { returns(T::Boolean) } diff --git a/updater/lib/dependabot/dependency_change_builder.rb b/updater/lib/dependabot/dependency_change_builder.rb index 6e09dcf2174..7ae3894e0f0 100644 --- a/updater/lib/dependabot/dependency_change_builder.rb +++ b/updater/lib/dependabot/dependency_change_builder.rb @@ -30,15 +30,17 @@ class DependencyChangeBuilder job: Dependabot::Job, dependency_files: T::Array[Dependabot::DependencyFile], updated_dependencies: T::Array[Dependabot::Dependency], - change_source: T.any(Dependabot::Dependency, Dependabot::DependencyGroup) + change_source: T.any(Dependabot::Dependency, Dependabot::DependencyGroup), + notices: T::Array[Dependabot::Notice] ).returns(Dependabot::DependencyChange) end - def self.create_from(job:, dependency_files:, updated_dependencies:, change_source:) + def self.create_from(job:, dependency_files:, updated_dependencies:, change_source:, notices: []) new( job: job, dependency_files: dependency_files, updated_dependencies: updated_dependencies, - change_source: change_source + change_source: change_source, + notices: notices ).run end @@ -47,10 +49,11 @@ def self.create_from(job:, dependency_files:, updated_dependencies:, change_sour job: Dependabot::Job, dependency_files: T::Array[Dependabot::DependencyFile], updated_dependencies: T::Array[Dependabot::Dependency], - change_source: T.any(Dependabot::Dependency, Dependabot::DependencyGroup) + change_source: T.any(Dependabot::Dependency, Dependabot::DependencyGroup), + notices: T::Array[Dependabot::Notice] ).void end - def initialize(job:, dependency_files:, updated_dependencies:, change_source:) + def initialize(job:, dependency_files:, updated_dependencies:, change_source:, notices: []) @job = job dir = Pathname.new(job.source.directory).cleanpath @@ -61,6 +64,7 @@ def initialize(job:, dependency_files:, updated_dependencies:, change_source:) @updated_dependencies = updated_dependencies @change_source = change_source + @notices = notices end sig { returns(Dependabot::DependencyChange) } @@ -84,7 +88,8 @@ def run job: job, updated_dependencies: updated_deps, updated_dependency_files: updated_files, - dependency_group: source_dependency_group + dependency_group: source_dependency_group, + notices: notices ) end @@ -102,6 +107,9 @@ def run sig { returns(T.any(Dependabot::Dependency, Dependabot::DependencyGroup)) } attr_reader :change_source + sig { returns(T::Array[Dependabot::Notice]) } + attr_reader :notices + sig { returns(T.nilable(String)) } def source_dependency_name return nil unless change_source.is_a? Dependabot::Dependency diff --git a/updater/lib/dependabot/dependency_snapshot.rb b/updater/lib/dependabot/dependency_snapshot.rb index 9fe29693748..15e4318ba63 100644 --- a/updater/lib/dependabot/dependency_snapshot.rb +++ b/updater/lib/dependabot/dependency_snapshot.rb @@ -65,6 +65,11 @@ def dependencies T.must(@dependencies[@current_directory]) end + sig { returns(T.nilable(Dependabot::PackageManagerBase)) } + def package_manager + @package_manager[@current_directory] + end + # Returns the subset of all project dependencies which are permitted # by the project configuration. sig { returns(T::Array[Dependabot::Dependency]) } @@ -167,6 +172,8 @@ def initialize(job:, base_commit_sha:, dependency_files:) # rubocop:disable Metr @current_directory = T.let("", String) @dependencies = T.let({}, T::Hash[String, T::Array[Dependabot::Dependency]]) + @package_manager = T.let({}, T::Hash[String, T.nilable(Dependabot::PackageManagerBase)]) + directories.each do |dir| @current_directory = dir @dependencies[dir] = parse_files! @@ -216,7 +223,7 @@ def parse_files! def dependency_file_parser assert_current_directory_set! job.source.directory = @current_directory - Dependabot::FileParsers.for_package_manager(job.package_manager).new( + parser = Dependabot::FileParsers.for_package_manager(job.package_manager).new( dependency_files: dependency_files, repo_contents_path: job.repo_contents_path, source: job.source, @@ -224,6 +231,9 @@ def dependency_file_parser reject_external_code: job.reject_external_code?, options: job.experiments ) + # Add 'package_manager' to the depedency_snapshopt to use it in operations' + @package_manager[@current_directory] = parser.package_manager + parser end sig { params(group: Dependabot::DependencyGroup).returns(T::Array[T::Hash[String, String]]) } diff --git a/updater/lib/dependabot/updater/group_update_creation.rb b/updater/lib/dependabot/updater/group_update_creation.rb index 80ffd57d4df..96458e2f3c4 100644 --- a/updater/lib/dependabot/updater/group_update_creation.rb +++ b/updater/lib/dependabot/updater/group_update_creation.rb @@ -6,6 +6,8 @@ require "dependabot/dependency_change_builder" require "dependabot/updater/dependency_group_change_batch" require "dependabot/workspace" +require "dependabot/updater/security_update_helpers" +require "dependabot/notices" # This module contains the methods required to build a DependencyChange for # a single DependencyGroup. @@ -22,6 +24,7 @@ class Updater module GroupUpdateCreation extend T::Sig extend T::Helpers + include PullRequestHelpers abstract! @@ -52,6 +55,14 @@ def compile_all_dependency_changes_for(group) ) original_dependencies = dependency_snapshot.dependencies + notices = [] + + # Add a deprecation notice if the package manager is deprecated + add_deprecation_notice( + notices: notices, + package_manager: dependency_snapshot.package_manager + ) + Dependabot.logger.info("Updating the #{job.source.directory} directory.") group.dependencies.each do |dependency| # We still want to update a dependency if it's been updated in another manifest files, @@ -108,7 +119,8 @@ def compile_all_dependency_changes_for(group) job: job, updated_dependencies: group_changes.updated_dependencies, updated_dependency_files: group_changes.updated_dependency_files, - dependency_group: group + dependency_group: group, + notices: notices ) if Experiments.enabled?("dependency_change_validation") && !dependency_change.all_have_previous_version? diff --git a/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb b/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb index 51af0459a47..ac69fe2d56e 100644 --- a/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb +++ b/updater/lib/dependabot/updater/operations/create_security_update_pull_request.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "dependabot/updater/security_update_helpers" +require "dependabot/notices" # This class implements our strategy for updating a single, insecure dependency # to a secure version. We attempt to make the smallest version update possible, @@ -12,6 +13,7 @@ module Operations class CreateSecurityUpdatePullRequest extend T::Sig include SecurityUpdateHelpers + include PullRequestHelpers sig { params(job: Job).returns(T::Boolean) } def self.applies_to?(job:) @@ -43,6 +45,8 @@ def initialize(service:, job:, dependency_snapshot:, error_handler:) @error_handler = error_handler # TODO: Collect @created_pull_requests on the Job object? @created_pull_requests = T.let([], T::Array[PullRequest]) + + @pr_notices = T.let([], T::Array[Dependabot::Notice]) end # TODO: We currently tolerate multiple dependencies for this operation @@ -55,6 +59,12 @@ def initialize(service:, job:, dependency_snapshot:, error_handler:) def perform Dependabot.logger.info("Starting security update job for #{job.source.repo}") + # Add a deprecation notice if the package manager is deprecated + add_deprecation_notice( + notices: @pr_notices, + package_manager: dependency_snapshot.package_manager + ) + target_dependencies = dependency_snapshot.job_dependencies if target_dependencies.empty? @@ -169,7 +179,8 @@ def check_and_create_pull_request(dependency) job: job, dependency_files: dependency_snapshot.dependency_files, updated_dependencies: updated_deps, - change_source: checker.dependency + change_source: checker.dependency, + notices: @pr_notices ) create_pull_request(dependency_change) diff --git a/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb b/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb index a4bcafaf941..de8ac93120e 100644 --- a/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb +++ b/updater/lib/dependabot/updater/operations/refresh_security_update_pull_request.rb @@ -1,6 +1,9 @@ # typed: strong # frozen_string_literal: true +require "dependabot/updater/security_update_helpers" +require "dependabot/notices" + # This class implements our strategy for 'refreshing' an existing Pull Request # that updates an insecure dependency. # @@ -16,6 +19,7 @@ module Operations class RefreshSecurityUpdatePullRequest extend T::Sig include SecurityUpdateHelpers + include PullRequestHelpers sig { params(job: Job).returns(T::Boolean) } def self.applies_to?(job:) @@ -41,10 +45,21 @@ def initialize(service:, job:, dependency_snapshot:, error_handler:) @job = job @dependency_snapshot = dependency_snapshot @error_handler = error_handler + + @pr_notices = T.let([], T::Array[Dependabot::Notice]) end sig { void } def perform + Dependabot.logger.info("Starting update job for #{job.source.repo}") + Dependabot.logger.info("Checking and updating security pull requests...") + + # Add a deprecation notice if the package manager is deprecated + add_deprecation_notice( + notices: @pr_notices, + package_manager: dependency_snapshot.package_manager + ) + check_and_update_pull_request(dependencies) rescue StandardError => e error_handler.handle_dependency_error(error: e, dependency: dependencies.last) @@ -142,7 +157,8 @@ def check_and_update_pull_request(dependencies) job: job, dependency_files: dependency_snapshot.dependency_files, updated_dependencies: updated_deps, - change_source: checker.dependency + change_source: checker.dependency, + notices: @pr_notices ) # NOTE: Gradle, Maven and Nuget dependency names can be case-insensitive diff --git a/updater/lib/dependabot/updater/operations/refresh_version_update_pull_request.rb b/updater/lib/dependabot/updater/operations/refresh_version_update_pull_request.rb index 7b60c895b05..5da1fce0ac8 100644 --- a/updater/lib/dependabot/updater/operations/refresh_version_update_pull_request.rb +++ b/updater/lib/dependabot/updater/operations/refresh_version_update_pull_request.rb @@ -1,6 +1,9 @@ -# typed: true +# typed: strong # frozen_string_literal: true +require "dependabot/updater/security_update_helpers" +require "dependabot/notices" + # This class implements our strategy for 'refreshing' an existing Pull Request # that updates a dependnency to the latest permitted version. # @@ -12,6 +15,10 @@ module Dependabot class Updater module Operations class RefreshVersionUpdatePullRequest + extend T::Sig + include PullRequestHelpers + + sig { params(job: Dependabot::Job).returns(T::Boolean) } def self.applies_to?(job:) return false if job.security_updates_only? # If we haven't been given metadata about the dependencies present @@ -21,24 +28,44 @@ def self.applies_to?(job:) job.updating_a_pull_request? end + sig { returns(Symbol) } def self.tag_name :update_version_pr end + sig do + params( + service: Dependabot::Service, + job: Dependabot::Job, + dependency_snapshot: Dependabot::DependencySnapshot, + error_handler: ErrorHandler + ).void + end def initialize(service:, job:, dependency_snapshot:, error_handler:) @service = service @job = job @dependency_snapshot = dependency_snapshot @error_handler = error_handler - return unless job.source.directory.nil? && job.source.directories.count == 1 + @pr_notices = T.let([], T::Array[Dependabot::Notice]) - job.source.directory = job.source.directories.first + return unless job.source.directory.nil? && job.source.directories&.count == 1 + + job.source.directory = job.source.directories&.first end + sig { void } def perform - Dependabot.logger.info("Starting PR update job for #{job.source.repo}") + Dependabot.logger.info("Starting update job for #{job.source.repo}") + Dependabot.logger.info("Checking and updating versions pull requests...") dependency = dependencies.last + + # Add a deprecation notice if the package manager is deprecated + add_deprecation_notice( + notices: @pr_notices, + package_manager: dependency_snapshot.package_manager + ) + check_and_update_pull_request(dependencies) rescue StandardError => e error_handler.handle_dependency_error(error: e, dependency: dependency) @@ -46,20 +73,30 @@ def perform private + sig { returns(Dependabot::Job) } attr_reader :job + sig { returns(Dependabot::Service) } attr_reader :service + sig { returns(Dependabot::DependencySnapshot) } attr_reader :dependency_snapshot + sig { returns(Dependabot::Updater::ErrorHandler) } attr_reader :error_handler - attr_reader :created_pull_requests + sig { returns(T::Array[Dependabot::Dependency]) } def dependencies dependency_snapshot.job_dependencies end # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/PerceivedComplexity + # rubocop:disable Metrics/MethodLength + sig do + params(dependencies: T::Array[Dependabot::Dependency]).void + end def check_and_update_pull_request(dependencies) - if dependencies.count != job.dependencies.count + job_dependencies = T.must(job.dependencies) + + if job_dependencies.count.zero? || dependencies.count != job_dependencies.count # If the job dependencies mismatch the parsed dependencies, then # we should close the PR as at least one thing we changed has been # removed from the project. @@ -73,10 +110,19 @@ def check_and_update_pull_request(dependencies) # Note: Gradle, Maven and Nuget dependency names can be case-insensitive # and the dependency name in the security advisory often doesn't match # what users have specified in their manifest. - lead_dep_name = job.dependencies.first.downcase + lead_dep_name = T.must(job_dependencies.first).downcase lead_dependency = dependencies.find do |dep| dep.name.downcase == lead_dep_name end + + if lead_dependency.nil? + # If the lead dependency is not found, it indicates that one of the dependencies + # we attempted to update has been removed from the project. Therefore, we should + # close the PR. + close_pull_request(reason: :dependency_removed) + return + end + checker = update_checker_for(lead_dependency, raise_on_ignored: raise_on_ignored?(lead_dependency)) log_checking_for_update(lead_dependency) @@ -99,13 +145,14 @@ def check_and_update_pull_request(dependencies) job: job, dependency_files: dependency_snapshot.dependency_files, updated_dependencies: updated_deps, - change_source: checker.dependency + change_source: checker.dependency, + notices: @pr_notices ) # NOTE: Gradle, Maven and Nuget dependency names can be case-insensitive # and the dependency name in the security advisory often doesn't match # what users have specified in their manifest. - job_dependencies = job.dependencies.map(&:downcase) + job_dependencies = job_dependencies.map(&:downcase) if dependency_change.updated_dependencies.map { |x| x.name.downcase } != job_dependencies # The dependencies being updated have changed. Close the existing # multi-dependency PR and try creating a new one. @@ -121,7 +168,9 @@ def check_and_update_pull_request(dependencies) end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/MethodLength + sig { params(dependency_change: Dependabot::DependencyChange).void } def create_pull_request(dependency_change) Dependabot.logger.info("Submitting #{dependency_change.updated_dependencies.map(&:name).join(', ')} " \ "pull request for creation") @@ -129,6 +178,7 @@ def create_pull_request(dependency_change) service.create_pull_request(dependency_change, dependency_snapshot.base_commit_sha) end + sig { params(dependency_change: Dependabot::DependencyChange).void } def update_pull_request(dependency_change) Dependabot.logger.info("Submitting #{dependency_change.updated_dependencies.map(&:name).join(', ')} " \ "pull request for update") @@ -136,17 +186,25 @@ def update_pull_request(dependency_change) service.update_pull_request(dependency_change, dependency_snapshot.base_commit_sha) end + sig { params(reason: Symbol).void } def close_pull_request(reason:) + job_dependencies = T.must(job.dependencies) + reason_string = reason.to_s.tr("_", " ") Dependabot.logger.info("Telling backend to close pull request for " \ - "#{job.dependencies.join(', ')} - #{reason_string}") - service.close_pull_request(job.dependencies, reason) + "#{job_dependencies.join(', ')} - #{reason_string}") + service.close_pull_request(job_dependencies, reason) end + sig { params(dependency: Dependabot::Dependency).returns(T::Boolean) } def raise_on_ignored?(dependency) job.ignore_conditions_for(dependency).any? end + sig do + params(dependency: Dependabot::Dependency, raise_on_ignored: T::Boolean) + .returns(Dependabot::UpdateCheckers::Base) + end def update_checker_for(dependency, raise_on_ignored:) Dependabot::UpdateCheckers.for_package_manager(job.package_manager).new( dependency: dependency, @@ -161,6 +219,7 @@ def update_checker_for(dependency, raise_on_ignored:) ) end + sig { params(dependency: Dependabot::Dependency).void } def log_checking_for_update(dependency) Dependabot.logger.info( "Checking if #{dependency.name} #{dependency.version} needs updating" @@ -168,6 +227,9 @@ def log_checking_for_update(dependency) job.log_ignore_conditions_for(dependency) end + sig do + params(dependency: Dependabot::Dependency, checker: Dependabot::UpdateCheckers::Base).returns(T::Boolean) + end def all_versions_ignored?(dependency, checker) Dependabot.logger.info("Latest version is #{checker.latest_version}") false @@ -176,6 +238,9 @@ def all_versions_ignored?(dependency, checker) true end + sig do + params(checker: Dependabot::UpdateCheckers::Base).returns(Symbol) + end def requirements_to_unlock(checker) if !checker.requirements_unlocked_or_can_be? if checker.can_update?(requirements_to_unlock: :none) then :none @@ -189,6 +254,9 @@ def requirements_to_unlock(checker) end end + sig do + params(requirements_to_unlock: Symbol, checker: Dependabot::UpdateCheckers::Base).void + end def log_requirements_for_update(requirements_to_unlock, checker) Dependabot.logger.info("Requirements to unlock #{requirements_to_unlock}") @@ -199,6 +267,10 @@ def log_requirements_for_update(requirements_to_unlock, checker) ) end + sig do + params(updated_dependencies: T::Array[Dependabot::Dependency]) + .returns(T.nilable(Dependabot::PullRequest)) + end def existing_pull_request(updated_dependencies) new_pr = PullRequest.create_from_updated_dependencies(updated_dependencies) job.existing_pull_requests.find { |pr| pr == new_pr } diff --git a/updater/lib/dependabot/updater/operations/update_all_versions.rb b/updater/lib/dependabot/updater/operations/update_all_versions.rb index beffc4491de..9730766f751 100644 --- a/updater/lib/dependabot/updater/operations/update_all_versions.rb +++ b/updater/lib/dependabot/updater/operations/update_all_versions.rb @@ -1,6 +1,9 @@ -# typed: true +# typed: strong # frozen_string_literal: true +require "dependabot/updater/security_update_helpers" +require "dependabot/notices" + # This class implements our strategy for iterating over all of the dependencies # for a specific project folder to find those that are out of date and create # a single PR per Dependency. @@ -9,43 +12,69 @@ class Updater module Operations class UpdateAllVersions extend T::Sig + include PullRequestHelpers + sig { params(_job: Dependabot::Job).returns(T::Boolean) } def self.applies_to?(_job:) false # only called elsewhere end + sig { returns(Symbol) } def self.tag_name :update_all_versions end + sig do + params( + service: Dependabot::Service, + job: Dependabot::Job, + dependency_snapshot: Dependabot::DependencySnapshot, + error_handler: ErrorHandler + ).void + end def initialize(service:, job:, dependency_snapshot:, error_handler:) @service = service @job = job @dependency_snapshot = dependency_snapshot @error_handler = error_handler # TODO: Collect @created_pull_requests on the Job object? - @created_pull_requests = [] + @created_pull_requests = T.let([], T::Array[PullRequest]) + + @pr_notices = T.let([], T::Array[Dependabot::Notice]) - return unless job.source.directory.nil? && job.source.directories.count == 1 + return unless job.source.directory.nil? && job.source.directories&.count == 1 - job.source.directory = job.source.directories.first + job.source.directory = job.source.directories&.first end + sig { void } def perform Dependabot.logger.info("Starting update job for #{job.source.repo}") Dependabot.logger.info("Checking all dependencies for version updates...") + + # Add a deprecation notice if the package manager is deprecated + add_deprecation_notice( + notices: @pr_notices, + package_manager: dependency_snapshot.package_manager + ) + dependencies.each { |dep| check_and_create_pr_with_error_handling(dep) } end private + sig { returns(Dependabot::Job) } attr_reader :job + sig { returns(Dependabot::Service) } attr_reader :service + sig { returns(Dependabot::DependencySnapshot) } attr_reader :dependency_snapshot + sig { returns(Dependabot::Updater::ErrorHandler) } attr_reader :error_handler sig { returns(T::Array[PullRequest]) } attr_reader :created_pull_requests + sig { returns(T::Array[Dependabot::Dependency]) } def dependencies if dependency_snapshot.dependencies.any? && dependency_snapshot.allowed_dependencies.none? Dependabot.logger.info("Found no dependencies to update after filtering allowed updates") @@ -59,6 +88,7 @@ def dependencies end end + sig { params(dependency: Dependabot::Dependency).void } def check_and_create_pr_with_error_handling(dependency) check_and_create_pull_request(dependency) rescue URI::InvalidURIError => e @@ -78,6 +108,7 @@ def check_and_create_pr_with_error_handling(dependency) # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/PerceivedComplexity + sig { params(dependency: Dependabot::Dependency).void } def check_and_create_pull_request(dependency) checker = update_checker_for(dependency, raise_on_ignored: raise_on_ignored?(dependency)) @@ -135,7 +166,8 @@ def check_and_create_pull_request(dependency) job: job, dependency_files: dependency_snapshot.dependency_files, updated_dependencies: updated_deps, - change_source: checker.dependency + change_source: checker.dependency, + notices: @pr_notices ) if dependency_change.updated_dependency_files.empty? @@ -148,16 +180,22 @@ def check_and_create_pull_request(dependency) # rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/AbcSize + sig { params(dependency: Dependabot::Dependency).void } def log_up_to_date(dependency) Dependabot.logger.info( "No update needed for #{dependency.name} #{dependency.version}" ) end + sig { params(dependency: Dependabot::Dependency).returns(T::Boolean) } def raise_on_ignored?(dependency) job.ignore_conditions_for(dependency).any? end + sig do + params(dependency: Dependabot::Dependency, raise_on_ignored: T::Boolean) + .returns(Dependabot::UpdateCheckers::Base) + end def update_checker_for(dependency, raise_on_ignored:) Dependabot::UpdateCheckers.for_package_manager(job.package_manager).new( dependency: dependency, @@ -172,6 +210,7 @@ def update_checker_for(dependency, raise_on_ignored:) ) end + sig { params(dependency: Dependabot::Dependency).void } def log_checking_for_update(dependency) Dependabot.logger.info( "Checking if #{dependency.name} #{dependency.version} needs updating" @@ -179,6 +218,7 @@ def log_checking_for_update(dependency) job.log_ignore_conditions_for(dependency) end + sig { params(error: StandardError, dependency: Dependabot::Dependency).returns(T.untyped) } def process_dependency_error(error, dependency) if error.class.to_s.include?("RegistryError") ex = Dependabot::DependencyFileNotResolvable.new(error.message) @@ -188,6 +228,10 @@ def process_dependency_error(error, dependency) end end + sig do + params(dependency: Dependabot::Dependency, checker: Dependabot::UpdateCheckers::Base) + .returns(T::Boolean) + end def all_versions_ignored?(dependency, checker) Dependabot.logger.info("Latest version is #{checker.latest_version}") false @@ -196,6 +240,7 @@ def all_versions_ignored?(dependency, checker) true end + sig { params(checker: Dependabot::UpdateCheckers::Base).returns(T::Boolean) } def pr_exists_for_latest_version?(checker) latest_version = checker.latest_version&.to_s return false if latest_version.nil? @@ -205,6 +250,10 @@ def pr_exists_for_latest_version?(checker) created_pull_requests.any? { |pr| pr.contains_dependency?(checker.dependency.name, latest_version) } end + sig do + params(updated_dependencies: T::Array[Dependabot::Dependency]) + .returns(T.nilable(Dependabot::PullRequest)) + end def existing_pull_request(updated_dependencies) new_pr = PullRequest.create_from_updated_dependencies(updated_dependencies) @@ -212,6 +261,7 @@ def existing_pull_request(updated_dependencies) created_pull_requests.find { |pr| pr == new_pr } end + sig { params(checker: Dependabot::UpdateCheckers::Base).returns(Symbol) } def requirements_to_unlock(checker) if !checker.requirements_unlocked_or_can_be? if checker.can_update?(requirements_to_unlock: :none) then :none @@ -225,6 +275,7 @@ def requirements_to_unlock(checker) end end + sig { params(requirements_to_unlock: Symbol, checker: Dependabot::UpdateCheckers::Base).void } def log_requirements_for_update(requirements_to_unlock, checker) Dependabot.logger.info("Requirements to unlock #{requirements_to_unlock}") @@ -237,17 +288,19 @@ def log_requirements_for_update(requirements_to_unlock, checker) # If a version update for a peer dependency is possible we should # defer to the PR that will be created for it to avoid duplicate PRs. + sig { params(dependency_name: String, updated_deps: T::Array[Dependabot::Dependency]).returns(T::Boolean) } def peer_dependency_should_update_instead?(dependency_name, updated_deps) updated_deps .reject { |dep| dep.name == dependency_name } .any? do |dep| next true if existing_pull_request([dep]) + next false if dep.previous_requirements.nil? original_peer_dep = ::Dependabot::Dependency.new( name: dep.name, version: dep.previous_version, - requirements: dep.previous_requirements, + requirements: T.must(dep.previous_requirements), package_manager: dep.package_manager ) update_checker_for(original_peer_dep, raise_on_ignored: false) @@ -255,6 +308,7 @@ def peer_dependency_should_update_instead?(dependency_name, updated_deps) end end + sig { params(dependency_change: Dependabot::DependencyChange).void } def create_pull_request(dependency_change) Dependabot.logger.info("Submitting #{dependency_change.updated_dependencies.map(&:name).join(', ')} " \ "pull request for creation") diff --git a/updater/lib/dependabot/updater/security_update_helpers.rb b/updater/lib/dependabot/updater/security_update_helpers.rb index 558a88234c8..253e8fbd39a 100644 --- a/updater/lib/dependabot/updater/security_update_helpers.rb +++ b/updater/lib/dependabot/updater/security_update_helpers.rb @@ -181,5 +181,39 @@ def security_update_not_possible_message(checker, latest_allowed_version, confli end end end + + module PullRequestHelpers + extend T::Sig + extend T::Helpers + + abstract! + + # Add deprecation notices to the list of notices + # if the package manager is deprecated. + # notices << deprecation_notices if deprecation_notices + sig do + params( + notices: T::Array[Dependabot::Notice], + package_manager: T.nilable(PackageManagerBase) + ) + .void + end + def add_deprecation_notice(notices:, package_manager:) + return unless Dependabot::Experiments.enabled?( + :add_deprecation_warn_to_pr_message + ) + return unless package_manager + + return unless package_manager.is_a?(PackageManagerBase) + + deprecation_notice = Notice.generate_pm_deprecation_notice( + package_manager + ) + + return unless deprecation_notice + + notices << deprecation_notice + end + end end end diff --git a/updater/spec/dependabot/dependency_change_spec.rb b/updater/spec/dependabot/dependency_change_spec.rb index 44510f9dbc2..4b6acbc3dd1 100644 --- a/updater/spec/dependabot/dependency_change_spec.rb +++ b/updater/spec/dependabot/dependency_change_spec.rb @@ -115,7 +115,8 @@ dependency_group: nil, pr_message_encoding: nil, pr_message_max_length: 65_535, - ignore_conditions: [] + ignore_conditions: [], + notices: [] ) expect(dependency_change.pr_message.pr_message).to eql("Hello World!") @@ -142,7 +143,8 @@ dependency_group: group, pr_message_encoding: nil, pr_message_max_length: 65_535, - ignore_conditions: [] + ignore_conditions: [], + notices: [] ) expect(dependency_change.pr_message&.pr_message).to eql("Hello World!") diff --git a/updater/spec/dependabot/updater/operations/create_group_update_pull_request_spec.rb b/updater/spec/dependabot/updater/operations/create_group_update_pull_request_spec.rb new file mode 100644 index 00000000000..1264bbe9a18 --- /dev/null +++ b/updater/spec/dependabot/updater/operations/create_group_update_pull_request_spec.rb @@ -0,0 +1,176 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "support/dummy_pkg_helpers" +require "support/dependency_file_helpers" + +require "dependabot/dependency_change" +require "dependabot/dependency_snapshot" +require "dependabot/service" +require "dependabot/updater/error_handler" +require "dependabot/updater/operations/create_group_update_pull_request" +require "dependabot/dependency_change_builder" +require "dependabot/notices" + +require "dependabot/bundler" + +RSpec.describe Dependabot::Updater::Operations::CreateGroupUpdatePullRequest do + include DependencyFileHelpers + include DummyPkgHelpers + + subject(:perform) { create_group_update_pull_request.perform } + + let(:create_group_update_pull_request) do + described_class.new( + service: mock_service, + job: job, + dependency_snapshot: dependency_snapshot, + error_handler: mock_error_handler, + group: dependency_group + ) + end + + let(:mock_service) do + instance_double(Dependabot::Service, create_pull_request: nil, update_pull_request: nil, close_pull_request: nil) + end + let(:mock_error_handler) { instance_double(Dependabot::Updater::ErrorHandler) } + + let(:job_definition) do + job_definition_fixture("bundler/version_updates/pull_request_simple") + end + + let(:job) do + Dependabot::Job.new_update_job( + job_id: "1558782000", + job_definition: job_definition_with_fetched_files + ) + end + + let(:dependency_snapshot) do + Dependabot::DependencySnapshot.create_from_job_definition( + job: job, + job_definition: job_definition_with_fetched_files + ) + end + + let(:job_definition_with_fetched_files) do + job_definition.merge({ + "base_commit_sha" => "mock-sha", + "base64_dependency_files" => encode_dependency_files(dependency_files) + }) + end + + let(:dependency_files) do + original_bundler_files(fixture: "bundler_simple") + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "dummy-pkg-a", + version: "4.0.0", + requirements: [{ + file: "Gemfile", + requirement: "~> 4.0.0", + groups: ["default"], + source: nil + }], + package_manager: "bundler", + metadata: { all_versions: ["4.0.0"] } + ) + end + + let(:dependency_group) do + instance_double( + Dependabot::DependencyGroup, + name: "dummy-group", + dependencies: [dependency], + rules: { "update-types" => ["all"] } + ) + end + + let(:stub_update_checker) do + instance_double( + Dependabot::UpdateCheckers::Base, + vulnerable?: true, + latest_version: "2.3.0", + version_class: Gem::Version, + lowest_resolvable_security_fix_version: "2.3.0", + lowest_security_fix_version: "2.0.0", + conflicting_dependencies: [], + up_to_date?: false, + updated_dependencies: [dependency], + dependency: dependency, + requirements_unlocked_or_can_be?: true, + can_update?: true + ) + end + + let(:stub_update_checker_class) do + class_double(Dependabot::Bundler::UpdateChecker, new: stub_update_checker) + end + + let(:stub_dependency_change) do + instance_double( + Dependabot::DependencyChange, + updated_dependencies: [dependency], + should_replace_existing_pr?: false, + grouped_update?: false, + matches_existing_pr?: false, + notices: [ + Dependabot::Notice.new( + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + ).to_hash + ] + ) + end + + before do + allow(Dependabot::UpdateCheckers).to receive(:for_package_manager).and_return(stub_update_checker_class) + allow(Dependabot::DependencyChangeBuilder) + .to receive(:create_from) + .and_return(stub_dependency_change) + allow(Dependabot::Experiments).to receive(:enabled?).with(:add_deprecation_warn_to_pr_message).and_return(true) + end + + after do + Dependabot::Experiments.reset! + end + + describe "#dependency_change" do + before do + allow(dependency).to receive(:all_versions).and_return(["4.0.0", "4.1.0", "4.2.0"]) + allow(job).to receive(:package_manager).and_return("bundler") + end + + context "when the update is allowed" do + before do + allow(job).to receive(:allowed_update?).and_return(true) + end + + context "when pull request does not already exist" do + it "creates a pull request with deprecation notice" do + expect(create_group_update_pull_request).to receive(:perform) + expect(stub_dependency_change.notices).to include( + { + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + } + ) + create_group_update_pull_request.perform + end + end + end + end +end diff --git a/updater/spec/dependabot/updater/operations/create_security_update_pull_request_spec.rb b/updater/spec/dependabot/updater/operations/create_security_update_pull_request_spec.rb index 69e3e885832..0eddffe6c63 100644 --- a/updater/spec/dependabot/updater/operations/create_security_update_pull_request_spec.rb +++ b/updater/spec/dependabot/updater/operations/create_security_update_pull_request_spec.rb @@ -11,6 +11,7 @@ require "dependabot/updater/error_handler" require "dependabot/updater/operations/create_security_update_pull_request" require "dependabot/dependency_change_builder" +require "dependabot/notices" require "dependabot/bundler" @@ -171,6 +172,36 @@ end end + let(:concrete_package_manager_class) do + Class.new(Dependabot::PackageManagerBase) do + def name + "bundler" + end + + def version + Dependabot::Version.new("1.0.0") + end + + def deprecated_versions + [Dependabot::Version.new("1.0.0")] + end + + def unsupported_versions + [Dependabot::Version.new("0.9.0")] + end + + def supported_versions + [Dependabot::Version.new("1.1.0"), Dependabot::Version.new("2.0.0")] + end + + def support_later_versions? + true + end + end + end + + let(:mock_package_manager_instance) { concrete_package_manager_class.new } + before do # Allow for_package_manager to return the stub_update_checker_class allow(Dependabot::UpdateCheckers).to receive(:for_package_manager).and_return(stub_update_checker_class) @@ -179,9 +210,17 @@ allow(Dependabot::DependencyChangeBuilder) .to receive(:create_from) .and_return(instance_double(Dependabot::DependencyChange)) + # Mock the create_pull_request method allow(create_security_update_pull_request) .to receive(:create_pull_request) + + # Mock the package_manager method in dependency_snapshot + allow(dependency_snapshot) + .to receive(:package_manager) + .and_return(mock_package_manager_instance) + + allow(Dependabot::Experiments).to receive(:enabled?).with(:add_deprecation_warn_to_pr_message).and_return(true) end after do @@ -289,34 +328,59 @@ ) allow(job) .to receive_messages(security_fix?: true, allowed_update?: true) - end - - it "checks if a pull request already exists" do - allow(create_security_update_pull_request) - .to receive(:pr_exists_for_latest_version?).and_return(true) - expect(create_security_update_pull_request) - .to receive(:record_pull_request_exists_for_latest_version).with(stub_update_checker) - create_security_update_pull_request.send(:check_and_create_pull_request, dependency) - end - - it "creates a pull request if one does not already exist" do allow(job) .to receive(:existing_pull_requests).and_return( [ Dependabot::PullRequest.new([ Dependabot::PullRequest::Dependency.new( - name: "dummy-pkg-a", version: "4.1.0" + name: "dummy-pkg-a", version: "4.0.1" ) ]) ] ) - allow(create_security_update_pull_request) - .to receive(:check_and_create_pull_request).and_call_original - - expect(create_security_update_pull_request).to receive(:create_pull_request) + end + it "checks if a pull request already exists" do + expect(create_security_update_pull_request) + .to receive(:record_pull_request_exists_for_latest_version).with(stub_update_checker) create_security_update_pull_request.send(:check_and_create_pull_request, dependency) end + + context "when pull request doesn't exists" do + before do + allow(job) + .to receive(:existing_pull_requests).and_return( + [] + ) + end + + it "creates a pull request without pr notices" do + expect(create_security_update_pull_request).to receive(:create_pull_request) + + create_security_update_pull_request.send(:check_and_create_pull_request, dependency) + end + + it "creates a pull request with pr notices" do + allow(Dependabot::Notice) + .to receive(:generate_pm_deprecation_notice) + .with(mock_package_manager_instance) + .and_return( + Dependabot::Notice.new( + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + ) + ) + + expect(create_security_update_pull_request).to receive(:create_pull_request) + + create_security_update_pull_request.send(:check_and_create_pull_request, dependency) + end + end end context "when the update is not allowed" do diff --git a/updater/spec/dependabot/updater/operations/refresh_security_update_pull_request_spec.rb b/updater/spec/dependabot/updater/operations/refresh_security_update_pull_request_spec.rb new file mode 100644 index 00000000000..c2b3697a6ea --- /dev/null +++ b/updater/spec/dependabot/updater/operations/refresh_security_update_pull_request_spec.rb @@ -0,0 +1,223 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "support/dummy_pkg_helpers" +require "support/dependency_file_helpers" + +require "dependabot/dependency_change" +require "dependabot/dependency_snapshot" +require "dependabot/service" +require "dependabot/updater/error_handler" +require "dependabot/updater/operations/refresh_security_update_pull_request" +require "dependabot/dependency_change_builder" +require "dependabot/notices" + +require "dependabot/bundler" + +RSpec.describe Dependabot::Updater::Operations::RefreshSecurityUpdatePullRequest do + include DependencyFileHelpers + include DummyPkgHelpers + + subject(:perform) { refresh_security_update_pull_request.perform } + + let(:refresh_security_update_pull_request) do + described_class.new( + service: mock_service, + job: job, + dependency_snapshot: dependency_snapshot, + error_handler: mock_error_handler + ) + end + + let(:mock_service) do + instance_double(Dependabot::Service, create_pull_request: nil, update_pull_request: nil, close_pull_request: nil) + end + let(:mock_error_handler) { instance_double(Dependabot::Updater::ErrorHandler) } + + let(:job_definition) do + job_definition_fixture("bundler/version_updates/pull_request_simple") + end + + let(:job) do + Dependabot::Job.new_update_job( + job_id: "1558782000", + job_definition: job_definition_with_fetched_files + ) + end + + let(:dependency_snapshot) do + Dependabot::DependencySnapshot.create_from_job_definition( + job: job, + job_definition: job_definition_with_fetched_files + ) + end + + let(:job_definition_with_fetched_files) do + job_definition.merge({ + "base_commit_sha" => "mock-sha", + "base64_dependency_files" => encode_dependency_files(dependency_files) + }) + end + + let(:dependency_files) do + original_bundler_files(fixture: "bundler_simple") + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "dummy-pkg-a", + version: "4.0.0", + requirements: [{ + file: "Gemfile", + requirement: "~> 4.0.0", + groups: ["default"], + source: nil + }], + package_manager: "bundler", + metadata: { all_versions: ["4.0.0"] } + ) + end + + let(:stub_update_checker) do + instance_double( + Dependabot::UpdateCheckers::Base, + vulnerable?: true, + latest_version: "2.3.0", + version_class: Gem::Version, + lowest_resolvable_security_fix_version: "2.3.0", + lowest_security_fix_version: "2.0.0", + conflicting_dependencies: [], + up_to_date?: false, + updated_dependencies: [dependency], + dependency: dependency, + requirements_unlocked_or_can_be?: true, + can_update?: true + ) + end + + let(:stub_update_checker_class) do + class_double(Dependabot::Bundler::UpdateChecker, new: stub_update_checker) + end + + let(:stub_dependency_change) do + instance_double( + Dependabot::DependencyChange, + updated_dependencies: [dependency], + should_replace_existing_pr?: false, + grouped_update?: false, + matches_existing_pr?: false, + notices: [] + ) + end + + before do + allow(Dependabot::UpdateCheckers).to receive(:for_package_manager).and_return(stub_update_checker_class) + allow(Dependabot::DependencyChangeBuilder) + .to receive(:create_from) + .and_return(stub_dependency_change) + allow(Dependabot::Experiments).to receive(:enabled?).with(:add_deprecation_warn_to_pr_message).and_return(true) + end + + after do + Dependabot::Experiments.reset! + end + + describe "#perform" do + before do + allow(dependency_snapshot).to receive(:job_dependencies).and_return([dependency]) + allow(job).to receive(:package_manager).and_return("bundler") + end + + context "when an error occurs" do + let(:error) { StandardError.new("error") } + + before do + allow(refresh_security_update_pull_request).to receive(:check_and_update_pull_request).and_raise(error) + end + + it "handles the error with the error handler" do + expect(mock_error_handler).to receive(:handle_dependency_error).with(error: error, dependency: dependency) + perform + end + end + + context "when no error occurs" do + before do + allow(refresh_security_update_pull_request).to receive(:check_and_update_pull_request) + end + + it "does not handle any error" do + expect(mock_error_handler).not_to receive(:handle_dependency_error) + perform + end + end + end + + describe "#check_and_update_pull_request" do + before do + allow(dependency).to receive(:all_versions).and_return(["4.0.0", "4.1.0", "4.2.0"]) + allow(job).to receive(:package_manager).and_return("bundler") + end + + context "when the update is not allowed" do + before do + allow(stub_update_checker).to receive(:up_to_date?).and_return(true) + allow(job).to receive_messages(allowed_update?: false, dependencies: ["dummy-pkg-a"]) + end + + it "does not create a pull request" do + expect(refresh_security_update_pull_request).not_to receive(:create_pull_request) + refresh_security_update_pull_request.send(:check_and_update_pull_request, [dependency]) + end + end + + context "when the update is allowed" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + latest_version: Dependabot::Version.new("4.0.1"), + requirements_unlocked_or_can_be?: true + ) + allow(job).to receive_messages(allowed_update?: true, dependencies: ["dummy-pkg-a"]) + end + + it "checks if a pull request already exists" do + allow(refresh_security_update_pull_request).to receive(:existing_pull_request).and_return(true) + expect(refresh_security_update_pull_request).to receive(:update_pull_request) + refresh_security_update_pull_request.send(:check_and_update_pull_request, [dependency]) + end + + context "when pull request does not already exist" do + before do + allow(job).to receive(:existing_pull_requests).and_return([ + [ + { + "dependency-name" => "dummy-pkg-a", + "dependency-version" => "2.0.0" + } + ] + ]) + allow(refresh_security_update_pull_request).to receive(:check_and_update_pull_request).and_call_original + end + + it "creates a pull request with deprecation notice" do + allow(Dependabot::Notice).to receive(:generate_pm_deprecation_notice).and_return([{ + mode: "WARN", + type: "bundler_deprecated_warn", + package_manager_name: "bundler", + details: { + message: "Dependabot will stop supporting `bundler` `v1`!\n" \ + "Please upgrade to one of the following versions: v2, v3.\n", + current_version: "v1", + markdown: "> [!WARNING]\n> Dependabot will stop supporting `bundler` `v1`!\n>\n" \ + "> Please upgrade to one of the following versions: v2, v3.\n>\n" + } + }]) + expect(refresh_security_update_pull_request).to receive(:create_pull_request) + refresh_security_update_pull_request.send(:check_and_update_pull_request, [dependency]) + end + end + end + end +end diff --git a/updater/spec/dependabot/updater/operations/refresh_version_update_pull_request_spec.rb b/updater/spec/dependabot/updater/operations/refresh_version_update_pull_request_spec.rb new file mode 100644 index 00000000000..146dcf1fef9 --- /dev/null +++ b/updater/spec/dependabot/updater/operations/refresh_version_update_pull_request_spec.rb @@ -0,0 +1,357 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "support/dummy_pkg_helpers" +require "support/dependency_file_helpers" + +require "dependabot/dependency_change" +require "dependabot/dependency_snapshot" +require "dependabot/service" +require "dependabot/updater/error_handler" +require "dependabot/updater/operations/refresh_version_update_pull_request" +require "dependabot/dependency_change_builder" +require "dependabot/package_manager" +require "dependabot/notices" + +require "dependabot/bundler" + +# Stub PackageManagerBase +class StubPackageManager < Dependabot::PackageManagerBase + def initialize(name:, version:, deprecated_versions: [], unsupported_versions: [], supported_versions: []) + @name = name + @version = version + @deprecated_versions = deprecated_versions + @unsupported_versions = unsupported_versions + @supported_versions = supported_versions + end + + attr_reader :name + attr_reader :version + attr_reader :deprecated_versions + attr_reader :unsupported_versions + attr_reader :supported_versions +end + +RSpec.describe Dependabot::Updater::Operations::RefreshVersionUpdatePullRequest do + include DependencyFileHelpers + include DummyPkgHelpers + + subject(:perform) { refresh_version_update_pull_request.perform } + + let(:refresh_version_update_pull_request) do + described_class.new( + service: mock_service, + job: job, + dependency_snapshot: dependency_snapshot, + error_handler: mock_error_handler + ) + end + + let(:mock_service) do + instance_double(Dependabot::Service, create_pull_request: nil, update_pull_request: nil, close_pull_request: nil) + end + let(:mock_error_handler) { instance_double(Dependabot::Updater::ErrorHandler) } + + let(:job_definition) do + job_definition_fixture("bundler/version_updates/pull_request_simple") + end + + let(:job) do + Dependabot::Job.new_update_job( + job_id: "1558782000", + job_definition: job_definition_with_fetched_files + ) + end + + let(:dependency_snapshot) do + Dependabot::DependencySnapshot.create_from_job_definition( + job: job, + job_definition: job_definition_with_fetched_files + ) + end + + let(:package_manager) do + StubPackageManager.new( + name: "bundler", + version: package_manager_version, + deprecated_versions: deprecated_versions, + supported_versions: supported_versions + ) + end + + let(:package_manager_version) { "1" } + let(:supported_versions) { %w(2 3) } + let(:deprecated_versions) { %w(1) } + + let(:job_definition_with_fetched_files) do + job_definition.merge({ + "base_commit_sha" => "mock-sha", + "base64_dependency_files" => encode_dependency_files(dependency_files) + }) + end + + let(:dependency_files) do + original_bundler_files(fixture: "bundler_simple") + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "dummy-pkg-a", + version: "4.0.0", + requirements: [{ + file: "Gemfile", + requirement: "~> 4.0.0", + groups: ["default"], + source: nil + }], + package_manager: "bundler", + metadata: { all_versions: ["4.0.0"] } + ) + end + + let(:stub_update_checker) do + instance_double( + Dependabot::UpdateCheckers::Base, + vulnerable?: true, + latest_version: "2.3.0", + version_class: Gem::Version, + lowest_resolvable_security_fix_version: "2.3.0", + lowest_security_fix_version: "2.0.0", + conflicting_dependencies: [], + up_to_date?: false, + updated_dependencies: [dependency], + dependency: dependency, + requirements_unlocked_or_can_be?: true, + can_update?: true + ) + end + + let(:stub_update_checker_class) do + class_double(Dependabot::Bundler::UpdateChecker, new: stub_update_checker) + end + + let(:stub_dependency_change) do + instance_double( + Dependabot::DependencyChange, + updated_dependencies: [dependency], + should_replace_existing_pr?: false, + grouped_update?: false, + matches_existing_pr?: false, + notices: [] + ) + end + + before do + allow(Dependabot::UpdateCheckers).to receive(:for_package_manager).and_return(stub_update_checker_class) + allow(Dependabot::DependencyChangeBuilder) + .to receive(:create_from) + .and_return(stub_dependency_change) + allow(dependency_snapshot).to receive(:package_manager).and_return(package_manager) + allow(Dependabot::Experiments).to receive(:enabled?).with( + :add_deprecation_warn_to_pr_message + ).and_return(true) + end + + after do + Dependabot::Experiments.reset! + end + + describe "#perform" do + before do + allow(dependency_snapshot).to receive(:job_dependencies).and_return([dependency]) + allow(job).to receive(:package_manager).and_return("bundler") + end + + context "when an error occurs" do + let(:error) { StandardError.new("error") } + + before do + allow(refresh_version_update_pull_request).to receive(:check_and_update_pull_request).and_raise(error) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + end + + it "handles the error with the error handler" do + expect(mock_error_handler).to receive(:handle_dependency_error).with(error: error, dependency: dependency) + perform + end + end + + context "when no error occurs" do + before do + allow(refresh_version_update_pull_request).to receive(:check_and_update_pull_request) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + end + + it "does not handle any error" do + expect(mock_error_handler).not_to receive(:handle_dependency_error) + perform + end + + it "adds a deprecation notice" do + expect(Dependabot::Notice).to receive(:generate_pm_deprecation_notice).with(package_manager).and_call_original + perform + end + end + end + + describe "#check_and_update_pull_request" do + before do + allow(dependency) + .to receive(:all_versions).and_return(["4.0.0", "4.1.0", "4.2.0"]) + allow(job).to receive(:package_manager).and_return("bundler") + end + + context "when job dependencies are zero or do not match parsed dependencies" do + before do + allow(job).to receive(:dependencies).and_return([]) + end + + it "closes the pull request with reason :dependency_removed" do + expect(refresh_version_update_pull_request).to receive( + :close_pull_request + ).with(reason: :dependency_removed) + refresh_version_update_pull_request.send( + :check_and_update_pull_request, [dependency] + ) + end + end + + context "when all versions are ignored" do + before do + allow(stub_update_checker).to receive(:up_to_date?).and_return(false) + allow(refresh_version_update_pull_request).to receive( + :all_versions_ignored? + ).and_return(true) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + end + + it "does not create or update a pull request" do + expect(refresh_version_update_pull_request).not_to receive( + :create_pull_request + ) + expect(refresh_version_update_pull_request).not_to receive( + :update_pull_request + ) + refresh_version_update_pull_request.send( + :check_and_update_pull_request, [dependency] + ) + end + end + + context "when checker is up to date" do + before do + allow(stub_update_checker).to receive(:up_to_date?).and_return(true) + allow(refresh_version_update_pull_request).to receive( + :all_versions_ignored? + ).and_return(false) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + end + + it "closes the pull request with reason :up_to_date" do + expect(refresh_version_update_pull_request).to receive( + :close_pull_request + ).with(reason: :up_to_date) + refresh_version_update_pull_request.send( + :check_and_update_pull_request, [dependency] + ) + end + end + + context "when requirements update is not possible" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: false + ) + allow(stub_update_checker).to receive( + :can_update? + ).with(requirements_to_unlock: :none).and_return(false) + allow(refresh_version_update_pull_request).to receive( + :all_versions_ignored? + ).and_return(false) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + end + + it "closes the pull request with reason :update_no_longer_possible" do + expect(refresh_version_update_pull_request).to receive( + :close_pull_request + ).with(reason: :update_no_longer_possible) + refresh_version_update_pull_request.send( + :check_and_update_pull_request, [dependency] + ) + end + end + + context "when dependencies have changed" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: true, + updated_dependencies: [dependency] + ) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + allow(refresh_version_update_pull_request).to receive(:all_versions_ignored?).and_return(false) + allow(Dependabot::DependencyChangeBuilder).to receive(:create_from).and_return(stub_dependency_change) + end + + it "closes the pull request with reason :dependency_removed" do + allow(job).to receive(:dependencies).and_return(["dummy-pkg-b"]) + expect(refresh_version_update_pull_request).to receive(:close_pull_request).with(reason: :dependency_removed) + refresh_version_update_pull_request.send(:check_and_update_pull_request, [dependency]) + end + end + + context "when an existing pull request matches the dependencies" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: true, + updated_dependencies: [dependency] + ) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + allow(Dependabot::DependencyChangeBuilder).to receive( + :create_from + ).and_return(stub_dependency_change) + allow(refresh_version_update_pull_request).to receive_messages( + all_versions_ignored?: false, + existing_pull_request: true + ) + end + + it "updates the pull request" do + expect(refresh_version_update_pull_request).to receive( + :update_pull_request + ).with(stub_dependency_change) + refresh_version_update_pull_request.send( + :check_and_update_pull_request, [dependency] + ) + end + end + + context "when no existing pull request matches the dependencies" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: true + ) + allow(job).to receive(:dependencies).and_return(["dummy-pkg-a"]) + allow(Dependabot::DependencyChangeBuilder).to receive( + :create_from + ).and_return(stub_dependency_change) + allow(refresh_version_update_pull_request).to receive_messages( + all_versions_ignored?: false, existing_pull_request: false + ) + end + + it "creates a new pull request" do + expect(refresh_version_update_pull_request).to receive( + :create_pull_request + ).with(stub_dependency_change) + refresh_version_update_pull_request.send( + :check_and_update_pull_request, [dependency] + ) + end + end + end +end diff --git a/updater/spec/dependabot/updater/operations/update_all_versions_spec.rb b/updater/spec/dependabot/updater/operations/update_all_versions_spec.rb new file mode 100644 index 00000000000..e50fb27907b --- /dev/null +++ b/updater/spec/dependabot/updater/operations/update_all_versions_spec.rb @@ -0,0 +1,366 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "support/dummy_pkg_helpers" +require "support/dependency_file_helpers" + +require "dependabot/dependency_change" +require "dependabot/dependency_snapshot" +require "dependabot/service" +require "dependabot/updater/error_handler" +require "dependabot/updater/operations/update_all_versions" +require "dependabot/dependency_change_builder" +require "dependabot/environment" +require "dependabot/package_manager" +require "dependabot/notices" + +require "dependabot/bundler" + +# Stub PackageManagerBase +class StubPackageManager < Dependabot::PackageManagerBase + def initialize(name:, version:, deprecated_versions: [], unsupported_versions: [], supported_versions: []) + @name = name + @version = version + @deprecated_versions = deprecated_versions + @unsupported_versions = unsupported_versions + @supported_versions = supported_versions + end + + attr_reader :name + attr_reader :version + attr_reader :deprecated_versions + attr_reader :unsupported_versions + attr_reader :supported_versions +end + +RSpec.describe Dependabot::Updater::Operations::UpdateAllVersions do + include DependencyFileHelpers + include DummyPkgHelpers + + subject(:perform) { update_all_versions.perform } + + let(:update_all_versions) do + described_class.new( + service: mock_service, + job: job, + dependency_snapshot: dependency_snapshot, + error_handler: mock_error_handler + ) + end + + let(:mock_service) do + instance_double( + Dependabot::Service, + create_pull_request: nil, + close_pull_request: nil + ) + end + + let(:mock_error_handler) { instance_double(Dependabot::Updater::ErrorHandler) } + + let(:job_definition) do + job_definition_fixture("bundler/version_updates/pull_request_simple") + end + + let(:job) do + Dependabot::Job.new_update_job( + job_id: "1558782000", + job_definition: job_definition_with_fetched_files + ) + end + + let(:dependency_snapshot) do + Dependabot::DependencySnapshot.create_from_job_definition( + job: job, + job_definition: job_definition_with_fetched_files + ) + end + + let(:package_manager) do + StubPackageManager.new( + name: "bundler", + version: package_manager_version, + deprecated_versions: deprecated_versions, + supported_versions: supported_versions + ) + end + + let(:package_manager_version) { "2" } + let(:supported_versions) { %w(2 3) } + let(:deprecated_versions) { %w(1) } + + let(:job_definition_with_fetched_files) do + job_definition.merge({ + "base_commit_sha" => "mock-sha", + "base64_dependency_files" => encode_dependency_files(dependency_files) + }) + end + + let(:dependency_files) do + original_bundler_files(fixture: "bundler_simple") + end + + let(:dependency) do + Dependabot::Dependency.new( + name: "dummy-pkg-a", + version: "4.0.0", + requirements: [{ + file: "Gemfile", + requirement: "~> 4.0.0", + groups: ["default"], + source: nil + }], + package_manager: "bundler", + metadata: { all_versions: ["4.0.0"] } + ) + end + + let(:stub_update_checker) do + instance_double( + Dependabot::UpdateCheckers::Base, + vulnerable?: true, + latest_version: "2.3.0", + version_class: Gem::Version, + lowest_resolvable_security_fix_version: "2.3.0", + lowest_security_fix_version: "2.0.0", + conflicting_dependencies: [], + up_to_date?: false, + updated_dependencies: [dependency], + dependency: dependency, + requirements_unlocked_or_can_be?: true, + can_update?: true + ) + end + + let(:stub_update_checker_class) do + class_double( + Dependabot::Bundler::UpdateChecker, + new: stub_update_checker + ) + end + + let(:stub_dependency_change) do + instance_double( + Dependabot::DependencyChange, + updated_dependencies: [dependency], + updated_dependency_files: dependency_files, + should_replace_existing_pr?: false, + grouped_update?: false, + matches_existing_pr?: false + ) + end + + before do + allow(Dependabot::UpdateCheckers).to receive( + :for_package_manager + ).and_return(stub_update_checker_class) + allow(Dependabot::DependencyChangeBuilder).to receive( + :create_from + ).and_return(stub_dependency_change) + allow(dependency_snapshot).to receive( + :package_manager + ).and_return(package_manager) + allow(Dependabot::Experiments).to receive(:enabled?).with( + :add_deprecation_warn_to_pr_message + ).and_return(true) + end + + after do + Dependabot::Experiments.reset! + end + + describe "#perform" do + before do + allow(dependency_snapshot).to receive( + :dependencies + ).and_return([dependency]) + allow(job).to receive(:package_manager).and_return("bundler") + end + + context "when an error occurs" do + let(:error) { StandardError.new("error") } + + before do + allow(update_all_versions).to receive( + :check_and_create_pull_request + ).and_raise(error) + end + + it "handles the error with the error handler" do + expect(mock_error_handler).to receive( + :handle_dependency_error + ).with(error: error, dependency: dependency) + perform + end + end + + context "when no error occurs" do + before do + allow(update_all_versions).to receive( + :check_and_create_pull_request + ) + end + + it "does not handle any error" do + expect(mock_error_handler).not_to receive( + :handle_dependency_error + ) + perform + end + end + + context "when package manager version is 1" do + let(:package_manager_version) { "1" } + + it "creates a pull request" do + expect(update_all_versions).to receive(:check_and_create_pull_request).with(dependency).and_call_original + expect(update_all_versions).to receive(:create_pull_request).with(stub_dependency_change) + perform + end + + it "adds a deprecation notice" do + expect(Dependabot::Notice).to receive(:generate_pm_deprecation_notice).with(package_manager).and_call_original + perform + end + end + + context "when package manager version is 2" do + let(:package_manager_version) { "2" } + + it "creates a pull request" do + expect(update_all_versions).to receive(:check_and_create_pull_request).with(dependency).and_call_original + expect(update_all_versions).to receive(:create_pull_request).with(stub_dependency_change) + perform + end + end + end + + describe "#check_and_create_pull_request" do + before do + allow(dependency).to receive(:all_versions).and_return( + ["4.0.0", "4.1.0", "4.2.0"] + ) + allow(job).to receive(:package_manager).and_return("bundler") + end + + context "when checker is up to date" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: true + ) + allow(update_all_versions).to receive(:all_versions_ignored?).and_return(false) + end + + it "logs that no update is needed" do + expect(update_all_versions).to receive(:log_up_to_date).with(dependency) + update_all_versions.send(:check_and_create_pull_request, dependency) + end + end + + context "when a pull request already exists for the latest version" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + latest_version: Gem::Version.new("2.0.1") + ) + allow(update_all_versions).to receive_messages( + all_versions_ignored?: false, + pr_exists_for_latest_version?: true + ) + allow(job).to receive( + :existing_pull_requests + ).and_return([[{ + "dependency-name" => "dummy-pkg-a", + "dependency-version" => "2.0.1" + }]]) + end + + it "does not create a pull request" do + expect(update_all_versions).not_to receive(:create_pull_request) + update_all_versions.send(:check_and_create_pull_request, dependency) + end + end + + context "when requirements update is not possible" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: false + ) + allow(stub_update_checker).to receive( + :can_update? + ).with(requirements_to_unlock: :none).and_return(false) + allow(update_all_versions).to receive(:all_versions_ignored?).and_return(false) + end + + it "logs that no update is possible" do + allow(Dependabot.logger).to receive(:info).and_call_original + expect(Dependabot.logger).to receive(:info).with("Checking if dummy-pkg-a 4.0.0 needs updating").ordered + expect(Dependabot.logger).to receive(:info).with("No update possible for dummy-pkg-a 4.0.0").ordered + + update_all_versions.send(:check_and_create_pull_request, dependency) + end + end + + context "when no dependencies are updated" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: true, + updated_dependencies: [] + ) + allow(update_all_versions).to receive(:all_versions_ignored?).and_return(false) + end + + it "raises an error" do + expect do + update_all_versions.send(:check_and_create_pull_request, dependency) + end.to raise_error( + RuntimeError, + "Dependabot found some dependency requirements to unlock, yet it failed to update any dependencies" + ) + end + end + + context "when an existing pull request matches the updated dependencies" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: true, + updated_dependencies: [dependency], + latest_version: Gem::Version.new("2.0.0") + ) + end + + it "does not create a pull request" do + expect(update_all_versions).not_to receive(:create_pull_request) + update_all_versions.send(:check_and_create_pull_request, dependency) + end + end + + context "when no existing pull request matches the updated dependencies" do + before do + allow(stub_update_checker).to receive_messages( + up_to_date?: false, + requirements_unlocked_or_can_be?: true, + updated_dependencies: [dependency] + ) + allow(update_all_versions).to receive_messages( + all_versions_ignored?: false, + existing_pull_request: false + ) + allow(Dependabot::DependencyChangeBuilder).to receive( + :create_from + ).and_return(stub_dependency_change) + end + + it "creates a pull request" do + expect(update_all_versions).to receive(:create_pull_request).with( + stub_dependency_change + ) + update_all_versions.send(:check_and_create_pull_request, dependency) + end + end + end +end diff --git a/updater/spec/dependabot/updater/pull_request_helpers_spec.rb b/updater/spec/dependabot/updater/pull_request_helpers_spec.rb new file mode 100644 index 00000000000..977490748c6 --- /dev/null +++ b/updater/spec/dependabot/updater/pull_request_helpers_spec.rb @@ -0,0 +1,103 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/updater" +require "dependabot/package_manager" +require "dependabot/notices" + +RSpec.describe Dependabot::Updater::PullRequestHelpers do + let(:dummy_class) do + Class.new do + include Dependabot::Updater::PullRequestHelpers + + attr_accessor :notices + + def initialize + @notices = [] + end + end + end + + let(:dummy_instance) { dummy_class.new } + + before do + allow(Dependabot::Experiments).to receive(:enabled?).with(:add_deprecation_warn_to_pr_message).and_return(true) + end + + after do + Dependabot::Experiments.reset! + end + + describe "#add_deprecation_notice" do + let(:package_manager) do + Class.new(Dependabot::PackageManagerBase) do + def name + "bundler" + end + + def version + Dependabot::Version.new("1") + end + + def deprecated_versions + [Dependabot::Version.new("1")] + end + + def supported_versions + [Dependabot::Version.new("2"), Dependabot::Version.new("3")] + end + end.new + end + + context "when package manager is provided and is deprecated" do + it "adds a deprecation notice to the notices array" do + expect do + dummy_instance.add_deprecation_notice(notices: dummy_instance.notices, package_manager: package_manager) + end + .to change { dummy_instance.notices.size }.by(1) + + notice = dummy_instance.notices.first + expect(notice.mode).to eq("WARN") + expect(notice.type).to eq("bundler_deprecated_warn") + expect(notice.package_manager_name).to eq("bundler") + end + end + + context "when package manager is not provided" do + it "does not add a deprecation notice to the notices array" do + expect { dummy_instance.add_deprecation_notice(notices: dummy_instance.notices, package_manager: nil) } + .not_to(change { dummy_instance.notices.size }) + end + end + + context "when package manager is not deprecated" do + let(:package_manager) do + Class.new(Dependabot::PackageManagerBase) do + def name + "bundler" + end + + def version + Dependabot::Version.new("2") + end + + def deprecated_versions + [Dependabot::Version.new("1")] + end + + def supported_versions + [Dependabot::Version.new("2"), Dependabot::Version.new("3")] + end + end.new + end + + it "does not add a deprecation notice to the notices array" do + expect do + dummy_instance.add_deprecation_notice(notices: dummy_instance.notices, package_manager: package_manager) + end + .not_to(change { dummy_instance.notices.size }) + end + end + end +end