diff --git a/updater/lib/dependabot/api_client.rb b/updater/lib/dependabot/api_client.rb index 10624f2dcbd..44c0c15c90f 100644 --- a/updater/lib/dependabot/api_client.rb +++ b/updater/lib/dependabot/api_client.rb @@ -39,10 +39,17 @@ def get_job(job_id) Job.new(job_data.merge(token: token)) end + # rubocop:disable Metrics:ParameterLists def create_pull_request(job_id, dependencies, updated_dependency_files, - base_commit_sha, pr_message) + base_commit_sha, pr_message, grouped_update = false) api_url = "#{base_url}/update_jobs/#{job_id}/create_pull_request" - data = create_pull_request_data(dependencies, updated_dependency_files, base_commit_sha, pr_message) + data = create_pull_request_data( + dependencies, + updated_dependency_files, + base_commit_sha, + pr_message, + grouped_update + ) response = http_client.post(api_url, json: { data: data }) raise ApiError, response.body if response.code >= 400 rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError @@ -52,6 +59,7 @@ def create_pull_request(job_id, dependencies, updated_dependency_files, sleep(rand(3.0..10.0)) && retry end + # rubocop:enable Metrics:ParameterLists def update_pull_request(job_id, dependencies, updated_dependency_files, base_commit_sha) @@ -179,7 +187,7 @@ def fetch_job_details_from_backend(job_id) sleep(rand(3.0..10.0)) && retry end - def create_pull_request_data(dependencies, updated_dependency_files, base_commit_sha, pr_message) + def create_pull_request_data(dependencies, updated_dependency_files, base_commit_sha, pr_message, grouped_update) data = { dependencies: dependencies.map do |dep| { @@ -194,7 +202,9 @@ def create_pull_request_data(dependencies, updated_dependency_files, base_commit end, "updated-dependency-files": updated_dependency_files, "base-commit-sha": base_commit_sha - } + }.merge({ + "grouped-update": grouped_update ? true : nil + }.compact) return data unless pr_message data["commit-message"] = pr_message.commit_message diff --git a/updater/lib/dependabot/experimental_grouped_updater.rb b/updater/lib/dependabot/experimental_grouped_updater.rb new file mode 100644 index 00000000000..aa229b6c8f6 --- /dev/null +++ b/updater/lib/dependabot/experimental_grouped_updater.rb @@ -0,0 +1,341 @@ +# frozen_string_literal: true + +# This class is a variation on the Strangler Pattern which the Updater#run +# method delegates to when the `prototype_grouped_updates` experiment is +# enabled. +# +# The goal of this is to allow us to use the existing Updater code that is +# shared but re-implement the methods that need to change _without_ jeopardising +# the Updater implementation for current users. +# +# This class is not expected to be long-lived once we have a better idea of how +# to pull apart the existing Updater into Single- and Grouped-strategy classes. +module Dependabot + # Let's use SimpleDelegator so this class behaves like Dependabot::Updater + # unless we override it. + class ExperimentalGroupedUpdater < SimpleDelegator + def run_grouped # rubocop:disable Metrics/PerceivedComplexity + if job.updating_a_pull_request? + raise Dependabot::NotImplemented, + "Grouped updates do not currently support rebasing." + end + + logger_info("[Experimental] Starting grouped update job for #{job.source.repo}") + # We should log the rule being executed, let's just hard-code wildcard for now + # since the prototype makes best-effort to do everything in one pass. + logger_info("Starting batch for Group Rule: '*'") + + all_updated_dependencies = [] + updated_files = dependencies.inject(dependency_files) do |files, dependency| + updated_dependencies = compile_updates_for(dependency) + + if updated_dependencies.any? + lead_dependency = updated_dependencies.find do |dep| + dep.name.casecmp(dependency.name).zero? + end + + # FIXME: This needs to be de-duped, but it isn't clear which dupe should + # 'win' right now in terms of version. + # + # To start out with, using a variant on the 'existing_pull_request' + # logic might make sense -or- we could employ a one-and-done rule + # where the first update to a dependency blocks subsequent changes. + # + # In a follow-up iteration, a 'shared workspace' could provide the + # filtering for us assuming we iteratively make file changes for + # each Array of dependencies in the batch and the FileUpdater tells + # us which cannot be applied. + all_updated_dependencies.concat(updated_dependencies) + generate_dependency_files_for(lead_dependency, updated_dependencies, files) + else + files # pass on the existing if there are no updated dependencies for this lead dependency + end + end + + if all_updated_dependencies.any? + logger_info("Creating a PR for Group Rule: '*'") + begin + create_pull_request(all_updated_dependencies, updated_files, + pr_message(all_updated_dependencies, updated_files)) + rescue StandardError => e + # FIXME: This is a workround for not having a single Dependency to report against + # + # We could use all_updated_deps.first, but that could be misleading. It may + # make more sense to handle the group rule as a Dependancy-ish object + group_dependency = OpenStruct.new(name: "group-all") + raise if Dependabot::Updater::RUN_HALTING_ERRORS.keys.any? { |err| e.is_a?(err) } + + __getobj__.handle_dependabot_error(error: e, dependency: group_dependency) + end + else + logger_info("Nothing to update for Group Rule: '*'") + end + end + alias run run_grouped # Override the base run implementation + + private + + # We should allow the rescue in Dependabot::Updater#run to handle errors and avoid trapping them ourselves in case + # it results in deviating from shared behaviour. This is a safety-catch to stop that happening by accident and fail + # tests if we override something without thinking carefully about how it should raise. + def handle_dependabot_error(_error:, _dependency:) + raise NoMethodError, "#{__method__} is not implemented by the delegator, call __getobj__.#{__method__} instead." + end + + # This method decomposes Updater#check_and_create_pull_request to just compile the updated dependencies for a + # given top-level dependency. This method **must** must return an Array. + # + # rubocop:disable Metrics/MethodLength + def compile_updates_for(dependency) + checker = update_checker_for(dependency, raise_on_ignored: raise_on_ignored?(dependency)) + + log_checking_for_update(dependency) + + # FIXME: Prototype grouped updates do not interact with the ignore list + # return if all_versions_ignored?(dependency, checker) + + # FIXME: Security-only updates are not supported for grouped updates yet + # + # BEGIN: Security-only updates checks + # # If the dependency isn't vulnerable or we can't know for sure we won't be + # # able to know if the updated dependency fixes any advisories + # if job.security_updates_only? + # unless checker.vulnerable? + # # The current dependency isn't vulnerable if the version is correct and + # # can be matched against the advisories affected versions + # if checker.version_class.correct?(checker.dependency.version) + # return record_security_update_not_needed_error(checker) + # end + + # return record_dependency_file_not_supported_error(checker) + # end + # return record_security_update_ignored(checker) unless job.allowed_update?(dependency) + # end + # if checker.up_to_date? + # # The current version is still vulnerable and Dependabot can't find a + # # published or compatible non-vulnerable version, this can happen if the + # # fixed version hasn't been published yet or the published version isn't + # # compatible with the current enviroment (e.g. python version) or + # # version (uses a different version suffix for gradle/maven) + # return record_security_update_not_found(checker) if job.security_updates_only? + + # return log_up_to_date(dependency) + # end + # END: Security-only updates checks + if checker.up_to_date? # retained from above block + log_up_to_date(dependency) + return [] + end + + # FIXME: Prototype grouped updates do not need to check for existing PRs + # at this stage as we haven't defined their mutual exclusivity + # requirements yet. + # if pr_exists_for_latest_version?(checker) + # # FIXME: Security-only updates are not supported for grouped updates yet + # # record_pull_request_exists_for_latest_version(checker) if job.security_updates_only? + # return logger_info( + # "Pull request already exists for #{checker.dependency.name} " \ + # "with latest version #{checker.latest_version}" + # ) + # end + + requirements_to_unlock = requirements_to_unlock(checker) + log_requirements_for_update(requirements_to_unlock, checker) + + if requirements_to_unlock == :update_not_possible + # FIXME: Security-only updates are not supported for grouped updates yet + # return record_security_update_not_possible_error(checker) if job.security_updates_only? && job.dependencies + + logger_info( + "No update possible for #{dependency.name} #{dependency.version}" + ) + return [] + end + + updated_deps = checker.updated_dependencies( + requirements_to_unlock: requirements_to_unlock + ) + + # FIXME: Security-only updates are not supported for grouped updates yet + # # Prevent updates that don't end up fixing any security advisories, + # # blocking any updates where dependabot-core updates to a vulnerable + # # version. This happens for npm/yarn subdendencies where Dependabot has no + # # control over the target version. Related issue: + # # https://github.com/github/dependabot-api/issues/905 + # if job.security_updates_only? && + # updated_deps.none? { |d| job.security_fix?(d) } + # return record_security_update_not_possible_error(checker) + # end + + # FIXME: Prototype grouped updates do not need to check for existing PRs + # at this stage as we haven't defined their mutual exclusivity + # requirements yet. + # + # The caveat is that `existing_pull_request` does two things: + # - Check `job.existing_pull_requests`` for PRs created by + # Dependabot outside the current job at some point in the past + # - Check the `created_pull_request` set for PRs created earlier + # in the previous job process + # + # For grouped updates, this first should be trivial but distinct + # from existing behaviour; we should prefer to update an existing, + # unmerged PR for the given group. + # + # The second initially seems like it does not apply as a process + # should only PR each group once but it is possible we could update + # a dependency that falls within a group rule _individually_ or as + # part of another group. + # + # Solving the overlap/exclusivity strateg(y|ies) we want to support + # is out of scope at this stage, so let's bypass for now. + # + # BEGIN: Existing Pull Request Checks + # if (existing_pr = existing_pull_request(updated_deps)) + # # Create a update job error to prevent dependabot-api from creating a + # # update_not_possible error, this is likely caused by a update job retry + # # so should be invisible to users (as the first job completed with a pull + # # request) + # record_pull_request_exists_for_security_update(existing_pr) if job.security_updates_only? + + # deps = existing_pr.map do |dep| + # if dep.fetch("dependency-removed", false) + # "#{dep.fetch('dependency-name')}@removed" + # else + # "#{dep.fetch('dependency-name')}@#{dep.fetch('dependency-version')}" + # end + # end + + # return logger_info( + # "Pull request already exists for #{deps.join(', ')}" + # ) + # end + # END: Existing Pull Request Checks + + if peer_dependency_should_update_instead?(checker.dependency.name, updated_deps) + logger_info( + "No update possible for #{dependency.name} #{dependency.version} " \ + "(peer dependency can be updated)" + ) + return [] + end + filter_unrelated_and_unchanged(updated_deps, checker) + # FIXME: This rescue logic is pulled in from Updater#check_and_create_pr_with_error_handling + # and duplicated in update_files_and_create_pull_request. + # + # It isn't completely intuitive which parts of this logic belong to the + # "check" and which parts belong to the "create", but it should be tried + # out in a way that the "dependency" it reports is less of a guess for + # groups -or- we need to implement new error types for Grouped Pull Requests + rescue Dependabot::InconsistentRegistryResponse => e + log_error( + dependency: dependency, + error: e, + error_type: "inconsistent_registry_response", + error_detail: e.message + ) + [] # Return an empty set + rescue StandardError => e + raise if Dependabot::Updater::RUN_HALTING_ERRORS.keys.any? { |err| e.is_a?(err) } + + __getobj__.handle_dependabot_error(error: e, dependency: dependency) + [] # Return an empty set + end + # rubocop:enable Metrics/MethodLength + + def filter_unrelated_and_unchanged(updated_dependencies, checker) + updated_dependencies.reject do |d| + next false if d.name == checker.dependency.name + next true if d.top_level? && d.requirements == d.previous_requirements + + d.version == d.previous_version + end + end + + # Override the checker initialisation to skip configuration we don't use right now + # + # FIXME: Prototype grouped updates do not interact with the ignore list + # FIXME: Prototype grouped updates to not interact with advisory data + def update_checker_for(dependency, raise_on_ignored:) + Dependabot::UpdateCheckers.for_package_manager(job.package_manager).new( + dependency: dependency, + dependency_files: dependency_files, + repo_contents_path: repo_contents_path, + credentials: credentials, + ignored_versions: [], + security_advisories: [], + raise_on_ignored: raise_on_ignored, + requirements_update_strategy: job.requirements_update_strategy, + options: job.experiments + ) + end + + # Override the updated file generation so we can pass through the files from + # the previous call instead of using the `dependency_files` instance variable. + def generate_dependency_files_for(lead_dependency, updated_dependencies, current_dependency_files) + # FIXME: We probably should use lead_dependency here, but I'm err'ing on the side of existing + # behaviour. + if updated_dependencies.count == 1 + updated_dependency = updated_dependencies.first + logger_info("Updating #{updated_dependency.name} from " \ + "#{updated_dependency.previous_version} to " \ + "#{updated_dependency.version}") + else + dependency_names = updated_dependencies.map(&:name) + logger_info("Updating #{dependency_names.join(', ')}") + end + + # Ignore dependencies that are tagged as information_only. These will be + # updated indirectly as a result of a parent dependency update and are + # only included here to be included in the PR info. + deps_to_update = updated_dependencies.reject(&:informational_only?) + updater = file_updater_for(deps_to_update, current_dependency_files) + updater.updated_dependency_files + rescue Dependabot::InconsistentRegistryResponse => e + log_error( + dependency: lead_dependency, + error: e, + error_type: "inconsistent_registry_response", + error_detail: e.message + ) + current_dependency_files # return the files unchanged + rescue StandardError => e + raise if Dependabot::Updater::RUN_HALTING_ERRORS.keys.any? { |err| e.is_a?(err) } + + __getobj__.handle_dependabot_error(error: e, dependency: lead_dependency) + current_dependency_files # return the files unchanged + end + + def file_updater_for(dependencies, current_dependency_files) + Dependabot::FileUpdaters.for_package_manager(job.package_manager).new( + dependencies: dependencies, + dependency_files: current_dependency_files, + repo_contents_path: repo_contents_path, + credentials: credentials, + options: job.experiments + ) + end + + # Override the PR creation to add the grouped_update flag + def create_pull_request(dependencies, updated_dependency_files, pr_message) + logger_info("Submitting #{dependencies.map(&:name).join(', ')} " \ + "pull request for creation") + + service.create_pull_request( + job_id, + dependencies, + updated_dependency_files.map(&:to_h), + base_commit_sha, + pr_message, + true # grouped_update is true + ) + + created_pull_requests << dependencies.map do |dep| + { + "dependency-name" => dep.name, + "dependency-version" => dep.version, + "dependency-removed" => dep.removed? ? true : nil + }.compact + end + end + end +end diff --git a/updater/lib/dependabot/service.rb b/updater/lib/dependabot/service.rb index e4e7b550477..69229f16f47 100644 --- a/updater/lib/dependabot/service.rb +++ b/updater/lib/dependabot/service.rb @@ -19,10 +19,20 @@ def initialize(client:) def_delegators :client, :get_job, :mark_job_as_processed, :update_dependency_list, :record_package_manager_version - def create_pull_request(job_id, dependencies, updated_dependency_files, base_commit_sha, pr_message) - client.create_pull_request(job_id, dependencies, updated_dependency_files, base_commit_sha, pr_message) + # rubocop:disable Metrics:ParameterLists + def create_pull_request(job_id, dependencies, updated_dependency_files, base_commit_sha, pr_message, + grouped_update = false) + client.create_pull_request( + job_id, + dependencies, + updated_dependency_files, + base_commit_sha, + pr_message, + grouped_update + ) @pull_requests << [humanize(dependencies), :created] end + # rubocop:enable Metrics:ParameterLists def update_pull_request(job_id, dependencies, updated_dependency_files, base_commit_sha) client.update_pull_request(job_id, dependencies, updated_dependency_files, base_commit_sha) diff --git a/updater/lib/dependabot/updater.rb b/updater/lib/dependabot/updater.rb index 230404d0333..cb0a7ce1ec8 100644 --- a/updater/lib/dependabot/updater.rb +++ b/updater/lib/dependabot/updater.rb @@ -30,6 +30,10 @@ require "dependabot/update_checkers" require "wildcard_matcher" +# FIXME: This provides an isolated codepath to hack into the updater without +# disrupting current users. It shouldn't be long-lived beyond March 2023 +require "dependabot/experimental_grouped_updater" + # rubocop:disable Metrics/ClassLength module Dependabot class Updater @@ -68,14 +72,10 @@ def initialize(service:, job_id:, job:, dependency_files:, def run return unless job + return ExperimentalGroupedUpdater.new(self).run if Dependabot::Experiments.enabled?(:grouped_updates_prototype) + return run_update_existing if job.updating_a_pull_request? - if job.updating_a_pull_request? - logger_info("Starting PR update job for #{job.source.repo}") - check_and_update_existing_pr_with_error_handling(dependencies) - else - logger_info("Starting update job for #{job.source.repo}") - dependencies.each { |dep| check_and_create_pr_with_error_handling(dep) } - end + run_fresh rescue *RUN_HALTING_ERRORS.keys => e if e.is_a?(Dependabot::AllVersionsIgnored) && !job.security_updates_only? error = StandardError.new( @@ -91,12 +91,25 @@ def run record_error(error) end - private + # FIXME: Private methods are currently made public to allow the + # ExperimentalGroupedUpdater to access them as a delegater. + # + # They should be made private again once we complete the refactor + # on this class to make Single vs Grouped strategies more coherant. + # private attr_accessor :errors, :created_pull_requests attr_reader :service, :job_id, :job, :dependency_files, :base_commit_sha, :repo_contents_path + # In this case, the dependency list may be either: + # - The full list from the manifest files for version updates + # - A specific dependency for security updates + def run_fresh + logger_info("Starting update job for #{job.source.repo}") + dependencies.each { |dep| check_and_create_pr_with_error_handling(dep) } + end + def check_and_create_pr_with_error_handling(dependency) check_and_create_pull_request(dependency) rescue Dependabot::InconsistentRegistryResponse => e @@ -112,7 +125,15 @@ def check_and_create_pr_with_error_handling(dependency) handle_dependabot_error(error: e, dependency: dependency) end - def check_and_update_existing_pr_with_error_handling(dependencies) + # In this case, the full dependency list is filtered by job.dependencies to + # only those involved in the existing Pull Request. + def run_update_existing + logger_info("Starting PR update job for #{job.source.repo}") + # TODO: Determine why this isn't the first dependency + # + # In `check_and_update_pull_request`, we consider the first dependency + # to be the 'lead_dependency'. It is unclear why we don't report errors + # against this instead of the last one. dependency = dependencies.last check_and_update_pull_request(dependencies) rescue StandardError => e @@ -625,6 +646,9 @@ def dependencies # the same dependencies allowed_deps = allowed_deps.shuffle unless ENV["UPDATER_DETERMINISTIC"] + # TODO: Determine if we should early return if all_deps.any? is false + # with a different logged message as that seems like a distinct + # problem. if all_deps.any? && allowed_deps.none? logger_info("Found no dependencies to update after filtering allowed " \ "updates") @@ -632,6 +656,13 @@ def dependencies # Consider updating vulnerable deps first. Only consider the first 10, # though, to ensure they don't take up the entire update run + # + # TODO: Skip this for version updates? + # + # We currently do not feed advisory information into version updates + # but we may in future. It might make sense to have a flag detect + # whether we have any vulnerable data vs iterating the whole + # dependency list. deps = allowed_deps.select { |d| job.vulnerable?(d) }.sample(10) + allowed_deps.reject { |d| job.vulnerable?(d) } diff --git a/updater/spec/dependabot/experimental_grouped_updater_spec.rb b/updater/spec/dependabot/experimental_grouped_updater_spec.rb new file mode 100644 index 00000000000..5f1b75906cc --- /dev/null +++ b/updater/spec/dependabot/experimental_grouped_updater_spec.rb @@ -0,0 +1,1986 @@ +# frozen_string_literal: true + +# FIXME: This file is a copy-paste of `spec/dependabot/updater_spec.rb` +# +# The intent is to run the tests for existing behaviour without the churn +# of refactoring the existing tests into shared examples until we know +# how the code is going to decompose +# +# Tests that don't apply or work properly for grouped updates are skipped +# rather than deleted, and new group-specific tests are added under the +# `run_grouped` block. +require "spec_helper" +require "bundler/compact_index_client" +require "bundler/compact_index_client/updater" +require "dependabot/dependency" +require "dependabot/dependency_file" +require "dependabot/file_fetchers" +require "dependabot/updater" +require "dependabot/service" + +require "dependabot/experimental_grouped_updater" + +RSpec.describe Dependabot::ExperimentalGroupedUpdater do + let(:updater_delegate) do + Dependabot::Updater.new( + service: service, + job_id: 1, + job: job, + dependency_files: dependency_files, + base_commit_sha: "sha", + repo_contents_path: repo_contents_path + ) + end + + subject(:updater) do + described_class.new(updater_delegate) + end + + let(:logger) { double(Logger) } + let(:service) { double(Dependabot::Service) } + + before do + allow(service).to receive(:get_job).and_return(job) + allow(service).to receive(:create_pull_request) + allow(service).to receive(:update_pull_request) + allow(service).to receive(:close_pull_request) + allow(service).to receive(:mark_job_as_processed) + allow(service).to receive(:update_dependency_list) + allow(service).to receive(:record_update_job_error) + allow_any_instance_of(Dependabot::ApiClient).to receive(:record_package_manager_version) + allow(Dependabot).to receive(:logger).and_return(logger) + allow(logger).to receive(:info) + allow(logger).to receive(:error) + + allow(Dependabot::Environment).to receive(:token).and_return("some_token") + allow(Dependabot::Environment).to receive(:job_id).and_return(1) + end + + let(:job) do + Dependabot::Job.new( + token: "token", + dependencies: requested_dependencies, + allowed_updates: allowed_updates, + existing_pull_requests: existing_pull_requests, + ignore_conditions: ignore_conditions, + security_advisories: security_advisories, + package_manager: "bundler", + source: { + "provider" => "github", + "repo" => "dependabot-fixtures/dependabot-test-ruby-package", + "directory" => "/", + "branch" => nil, + "api-endpoint" => "https://api.github.com/", + "hostname" => "github.com" + }, + credentials: credentials, + lockfile_only: false, + requirements_update_strategy: nil, + update_subdependencies: false, + updating_a_pull_request: updating_a_pull_request, + vendor_dependencies: false, + experiments: experiments, + commit_message_options: { + "prefix" => commit_message_prefix, + "prefix-development" => commit_message_prefix_development, + "include-scope" => commit_message_include_scope + }, + security_updates_only: security_updates_only + ) + end + let(:requested_dependencies) { nil } + let(:updating_a_pull_request) { false } + let(:existing_pull_requests) { [] } + let(:security_advisories) { [] } + let(:ignore_conditions) { [] } + let(:security_updates_only) { false } + let(:ignore_conditions) { [] } + let(:allowed_updates) do + [ + { + "dependency-type" => "direct", + "update-type" => "all" + }, + { + "dependency-type" => "indirect", + "update-type" => "security" + } + ] + end + let(:credentials) do + [ + { + "type" => "git_source", + "host" => "github.com", + "username" => "x-access-token", + "password" => "github-token" + }, + { "type" => "random", "secret" => "codes" } + ] + end + let(:experiments) { {} } + let(:repo_contents_path) { nil } + let(:commit_message_prefix) { "[bump]" } + let(:commit_message_prefix_development) { "[bump-dev]" } + let(:commit_message_include_scope) { true } + + let(:checker) { double(Dependabot::Bundler::UpdateChecker) } + before do + allow(checker).to receive(:up_to_date?).and_return(false, false) + allow(checker).to receive(:vulnerable?).and_return(false) + allow(checker).to receive(:version_class). + and_return(Dependabot::Bundler::Version) + allow(checker).to receive(:requirements_unlocked_or_can_be?). + and_return(true) + allow(checker). + to receive(:can_update?).with(requirements_to_unlock: :own). + and_return(true, false) + allow(checker). + to receive(:can_update?).with(requirements_to_unlock: :all). + and_return(false) + allow(checker).to receive(:updated_dependencies).and_return([dependency]) + allow(checker).to receive(:dependency).and_return(original_dependency) + allow(checker). + to receive(:latest_version). + and_return(Gem::Version.new("1.2.0")) + allow(Dependabot::Bundler::UpdateChecker).to receive(:new).and_return(checker) + end + let(:dependency) do + Dependabot::Dependency.new( + name: "dummy-pkg-b", + package_manager: "bundler", + version: "1.2.0", + previous_version: "1.1.0", + requirements: [ + { file: "Gemfile", requirement: "~> 1.2.0", groups: [], source: nil } + ], + previous_requirements: [ + { file: "Gemfile", requirement: "~> 1.1.0", groups: [], source: nil } + ] + ) + end + let(:multiple_dependencies) do + [ + Dependabot::Dependency.new( + name: "dummy-pkg-b", + package_manager: "bundler", + version: "1.2.0", + previous_version: "1.1.0", + requirements: [ + { file: "Gemfile", requirement: "~> 1.2.0", groups: [], source: nil } + ], + previous_requirements: [ + { file: "Gemfile", requirement: "~> 1.1.0", groups: [], source: nil } + ] + ), + Dependabot::Dependency.new( + name: "dummy-pkg-a", + package_manager: "bundler", + version: "2.0.0", + previous_version: "1.0.1", + requirements: [ + { file: "Gemfile", requirement: "~> 2.0.0", groups: [], source: nil } + ], + previous_requirements: [ + { file: "Gemfile", requirement: "~> 1.0.0", groups: [], source: nil } + ] + ) + ] + end + let(:original_dependency) do + Dependabot::Dependency.new( + name: "dummy-pkg-b", + package_manager: "bundler", + version: "1.1.0", + requirements: [ + { file: "Gemfile", requirement: "~> 1.1.0", groups: [], source: nil } + ] + ) + end + + describe "#run" do + before do + allow_any_instance_of(Bundler::CompactIndexClient::Updater). + to receive(:etag_for). + and_return("") + + stub_request(:get, "https://index.rubygems.org/versions"). + to_return(status: 200, body: fixture("rubygems-index")) + + stub_request(:get, "https://index.rubygems.org/info/dummy-pkg-a"). + to_return(status: 200, body: fixture("rubygems-info-a")) + stub_request(:get, "https://index.rubygems.org/info/dummy-pkg-b"). + to_return(status: 200, body: fixture("rubygems-info-b")) + + message_builder = double(Dependabot::PullRequestCreator::MessageBuilder) + allow(Dependabot::PullRequestCreator::MessageBuilder).to receive(:new).and_return(message_builder) + allow(message_builder).to receive(:message).and_return(nil) + end + + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "Gemfile", + content: fixture("bundler/original/Gemfile"), + directory: "/" + ), + Dependabot::DependencyFile.new( + name: "Gemfile.lock", + content: fixture("bundler/original/Gemfile.lock"), + directory: "/" + ) + ] + end + + context "when the host is out of disk space", skip: "Error is handled for us by Dependabot::Updater#run" do + before do + allow(service).to receive(:record_update_job_error).and_return(nil) + allow(job).to receive(:updating_a_pull_request?).and_raise(Errno::ENOSPC) + end + + it "records an 'out_of_disk' error" do + updater.run + + expect(service).to have_received(:record_update_job_error). + with(anything, { error_type: "out_of_disk", error_details: nil }) + end + end + + context "when github pr creation is rate limiting" do + let(:experiments) { { "build-pull-request-message" => true } } + + before do + allow(service).to receive(:record_update_job_error).and_return(nil) + + error = Octokit::TooManyRequests.new({ + status: 403, + response_headers: { "X-RateLimit-Reset" => 42 } + }) + message_builder = double(Dependabot::PullRequestCreator::MessageBuilder) + allow(Dependabot::PullRequestCreator::MessageBuilder).to receive(:new).and_return(message_builder) + allow(message_builder).to receive(:message).and_raise(error) + end + + it "records an 'octokit_rate_limited' error" do + updater.run + + expect(service).to have_received(:record_update_job_error). + with(anything, { error_type: "octokit_rate_limited", error_details: { "rate-limit-reset": 42 } }) + end + end + + context "when the job has already been processed", skip: "handled for us by Dependabot::Updater#run" do + let(:job) { nil } + + it "no-ops" do + expect(updater_delegate).to_not receive(:dependencies) + updater.run + end + end + + it "logs the current and latest versions", skip: "this secretly tests ignore functionality we've skipped" do + expect(logger). + to receive(:info). + with(" Checking if dummy-pkg-b 1.1.0 needs updating") + expect(logger). + to receive(:info). + with(" Latest version is 1.2.0") + updater.run + end + + context "when the checker has an requirements update strategy" do + before do + allow(checker). + to receive(:requirements_update_strategy). + and_return(:bump_versions) + end + + it "logs the update requirements and strategy" do + expect(logger). + to receive(:info). + with(" Requirements to unlock own") + expect(logger). + to receive(:info). + with(" Requirements update strategy bump_versions") + updater.run + end + end + + context "when no dependencies are allowed" do + let(:allowed_updates) { [{ "dependency-name" => "typoed-dep-name" }] } + + it "logs the current and latest versions" do + expect(logger). + to receive(:info). + with(" Found no dependencies to update after filtering " \ + "allowed updates") + updater.run + end + end + + context "for security only updates" do + let(:security_updates_only) { true } + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"], + "patched-versions" => ["1.2.0"] }] + end + + before do + allow(checker).to receive(:vulnerable?).and_return(true) + end + + it "creates the pull request" do + expect(service).to receive(:create_pull_request).once + updater.run + end + + context "when the dep has no version so we can't check vulnerability", + skip: "Security-only updates are out of scope" do + let(:original_dependency) do + Dependabot::Dependency.new( + name: "dummy-pkg-b", + package_manager: "bundler", + version: nil, + requirements: [ + { + file: "Gemfile", + requirement: "~> 1.1.0", + groups: [], + source: nil + } + ] + ) + end + + before do + allow(checker).to receive(:vulnerable?).and_return(false) + end + + it "does not create pull request" do + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error).with( + 1, + { + error_type: "dependency_file_not_supported", + error_details: { + "dependency-name": "dummy-pkg-b" + } + } + ) + expect(logger). + to receive(:info).with( + " Dependabot can't update vulnerable dependencies for " \ + "projects without a lockfile or pinned version requirement as " \ + "the currently installed version of dummy-pkg-b isn't known." + ) + + updater.run + end + end + + context "when the dependency is no longer vulnerable" do + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.0.0"], + "patched-versions" => ["1.1.0"] }] + end + + before do + allow(checker).to receive(:vulnerable?).and_return(false) + end + + it "does not create pull request" do + expect(service).to_not receive(:create_pull_request) + updater.run + end + end + + context "when the update is still vulnerable", skip: "Security-only updates are out of scope" do + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0", "1.2.0"] }] + end + + before do + allow(checker).to receive(:vulnerable?).and_return(true) + end + + it "does not create pull request" do + expect(checker).to receive(:lowest_resolvable_security_fix_version). + and_return(dependency.version) + expect(checker).to receive(:lowest_security_fix_version). + and_return(Dependabot::Bundler::Version.new("1.3.0")) + expect(checker).to receive(:conflicting_dependencies).and_return( + [ + { + "explanation" => + "dummy-pkg-a (1.0.0) requires dummy-pkg-b (= 1.2.0)", + "name" => "dummy-pkg-a", + "version" => "1.0.0", + "requirement" => "= 1.2.0" + } + ] + ) + + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error).with( + 1, + { + error_type: "security_update_not_possible", + error_details: { + "dependency-name": "dummy-pkg-b", + "latest-resolvable-version": "1.2.0", + "lowest-non-vulnerable-version": "1.3.0", + "conflicting-dependencies": [ + { + "explanation" => + "dummy-pkg-a (1.0.0) requires dummy-pkg-b (= 1.2.0)", + "name" => "dummy-pkg-a", + "version" => "1.0.0", + "requirement" => "= 1.2.0" + } + ] + } + } + ) + expect(logger). + to receive(:info).with( + " The latest possible version that can be installed is " \ + "1.2.0 because of the following conflicting dependency:\n" \ + " \n" \ + " dummy-pkg-a (1.0.0) requires dummy-pkg-b (= 1.2.0)" + ) + + updater.run + end + + it "reports the correct error when there is no fixed version" do + expect(checker).to receive(:lowest_resolvable_security_fix_version). + and_return(nil) + expect(checker).to receive(:lowest_security_fix_version). + and_return(nil) + expect(checker).to receive(:conflicting_dependencies).and_return([]) + + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error).with( + 1, + { + error_type: "security_update_not_possible", + error_details: { + "dependency-name": "dummy-pkg-b", + "latest-resolvable-version": "1.1.0", + "lowest-non-vulnerable-version": nil, + "conflicting-dependencies": [] + } + } + ) + expect(logger). + to receive(:info).with( + " The latest possible version of dummy-pkg-b that can be " \ + "installed is 1.1.0" + ) + updater.run + end + end + + context "when the dependency is deemed up-to-date but still vulnerable", + skip: "Security-only updates are out of scope" do + it "doesn't update the dependency" do + expect(checker).to receive(:up_to_date?).and_return(true) + expect(updater).to_not receive(:generate_dependency_files_for) + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error). + with( + 1, + error_type: "security_update_not_found", + error_details: { + "dependency-name": "dummy-pkg-b", + "dependency-version": "1.1.0" + } + ) + expect(logger). + to receive(:info). + with( + " Dependabot can't find a published or compatible " \ + "non-vulnerable version for dummy-pkg-b. " \ + "The latest available version is 1.1.0" + ) + updater.run + end + end + end + + context "when ignore conditions are set", skip: "Ignore rules are out of scope for the prototype" do + def expect_update_checker_with_ignored_versions(versions) + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: versions, + security_advisories: anything, + raise_on_ignored: anything, + requirements_update_strategy: anything, + options: anything + ).once + end + + describe "when ignores match the dependency name" do + let(:requested_dependencies) { ["dummy-pkg-b"] } + let(:ignore_conditions) { [{ "dependency-name" => "dummy-pkg-b", "version-requirement" => ">= 0" }] } + + it "passes ignored_versions to the update checker" do + updater.run + expect_update_checker_with_ignored_versions([">= 0"]) + end + end + + describe "when all versions are ignored", skip: "Ignore rules are out of scope for the prototype" do + let(:ignore_conditions) do + [ + { "dependency-name" => "dummy-pkg-a", "version-requirement" => "~> 2.0.0" }, + { "dependency-name" => "dummy-pkg-b", "version-requirement" => "~> 1.0.0" } + ] + end + + before do + allow(checker). + to receive(:latest_version). + and_raise(Dependabot::AllVersionsIgnored) + allow(checker). + to receive(:up_to_date?). + and_raise(Dependabot::AllVersionsIgnored) + end + + it "logs the errors" do + expect(logger). + to receive(:info). + with( + " All updates for dummy-pkg-a were ignored" + ) + expect(logger). + to receive(:info). + with( + " All updates for dummy-pkg-b were ignored" + ) + updater.run + end + + it "doesn't report a job error" do + updater.run + expect(service).to_not have_received(:record_update_job_error) + end + end + + describe "without an ignore condition" do + let(:requested_dependencies) { ["dummy-pkg-b"] } + + it "doesn't enable raised_on_ignore for ignore logging" do + updater.run + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: anything, + security_advisories: anything, + raise_on_ignored: false, + requirements_update_strategy: anything, + options: anything + ) + end + end + + describe "with an ignored version", skip: "Ignore rules are out of scope for the prototype" do + let(:requested_dependencies) { ["dummy-pkg-b"] } + let(:ignore_conditions) { [{ "dependency-name" => "dummy-pkg-b", "version-requirement" => "~> 1.0.0" }] } + + it "enables raised_on_ignore for ignore logging" do + updater.run + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: anything, + security_advisories: anything, + raise_on_ignored: true, + requirements_update_strategy: anything, + options: anything + ) + end + end + + describe "with an ignored update-type", skip: "Ignore rules are out of scope for the prototype" do + let(:requested_dependencies) { ["dummy-pkg-b"] } + let(:ignore_conditions) do + [{ "dependency-name" => "dummy-pkg-b", "update-types" => ["version-update:semver-patch"] }] + end + + it "enables raised_on_ignore for ignore logging" do + updater.run + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: anything, + security_advisories: anything, + raise_on_ignored: true, + requirements_update_strategy: anything, + options: anything + ) + end + end + + describe "when ignores don't match the name", skip: "Ignore rules are out of scope for the prototype" do + let(:requested_dependencies) { ["dummy-pkg-a"] } + let(:ignore_conditions) { [{ "dependency-name" => "dummy-pkg-b", "version-requirement" => ">= 0" }] } + + it "passes ignored_versions to the update checker" do + updater.run + expect_update_checker_with_ignored_versions([]) + end + end + + describe "when ignores match a wildcard name", skip: "Ignore rules are out of scope for the prototype" do + let(:requested_dependencies) { ["dummy-pkg-a"] } + let(:ignore_conditions) { [{ "dependency-name" => "dummy-pkg-*", "version-requirement" => ">= 0" }] } + + it "passes ignored_versions to the update checker" do + updater.run + expect_update_checker_with_ignored_versions([">= 0"]) + end + end + + describe "when ignores define update-types with feature enabled", + skip: "Ignore rules are out of scope for the prototype" do + let(:requested_dependencies) { ["dummy-pkg-b"] } + let(:ignore_conditions) do + [ + { + "dependency-name" => "dummy-pkg-a", + "version-requirement" => ">= 3.0.0, < 5" + }, + { + "dependency-name" => "dummy-pkg-*", + "version-requirement" => ">= 2.0.0, < 3" + }, + { + "dependency-name" => "dummy-pkg-b", + "update-types" => ["version-update:semver-patch", "version-update:semver-minor"] + } + ] + end + + it "passes ignored_versions to the update checker" do + updater.run + expect_update_checker_with_ignored_versions([">= 2.0.0, < 3", "> 1.1.0, < 1.2", ">= 1.2.a, < 2"]) + end + end + end + + context "when cloning experiment is enabled" do + let(:experiments) { { "cloning" => true } } + + it "passes the experiment to the FileUpdater" do + expect(Dependabot::Bundler::FileUpdater).to receive(:new).with( + dependencies: [dependency], + dependency_files: dependency_files, + repo_contents_path: repo_contents_path, + credentials: credentials, + options: { cloning: true } + ).and_call_original + expect(service).to receive(:create_pull_request).once + updater.run + end + end + + it "updates the update config's dependency list" do + job_id = 1 + dependencies = [ + { + name: "dummy-pkg-a", + version: "2.0.0", + requirements: [ + { + file: "Gemfile", + requirement: "~> 2.0.0", + groups: [:default], + source: nil + } + ] + }, + { + name: "dummy-pkg-b", + version: "1.1.0", + requirements: [ + { + file: "Gemfile", + requirement: "~> 1.1.0", + groups: [:default], + source: nil + } + ] + } + ] + dependency_files = ["/Gemfile", "/Gemfile.lock"] + + expect(service). + to receive(:update_dependency_list).with(job_id, dependencies, dependency_files) + updater.run + end + + it "updates dependencies correctly" do + job_id = 1 + dependencies = [have_attributes(name: "dummy-pkg-b")] + updated_dependency_files = [ + { + "name" => "Gemfile", + "content" => fixture("bundler/updated/Gemfile"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + }, + { + "name" => "Gemfile.lock", + "content" => fixture("bundler/updated/Gemfile.lock"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + } + ] + base_commit_sha = "sha" + pr_message = nil + expect(service). + to receive(:create_pull_request). + with(job_id, dependencies, updated_dependency_files, base_commit_sha, pr_message, true) + + updater.run + end + + it "builds pull request message" do + expect(Dependabot::PullRequestCreator::MessageBuilder). + to receive(:new).with( + source: job.source, + files: an_instance_of(Array), + dependencies: an_instance_of(Array), + credentials: credentials, + commit_message_options: { + include_scope: commit_message_include_scope, + prefix: commit_message_prefix, + prefix_development: commit_message_prefix_development + }, + github_redirection_service: "github-redirect.dependabot.com" + ) + updater.run + end + + it "updates only the dependencies that need updating" do + expect(service).to receive(:create_pull_request).once + updater.run + end + + context "when an update requires multiple dependencies to be updated" do + before do + allow(checker). + to receive(:can_update?).with(requirements_to_unlock: :own). + and_return(false, false) + allow(checker). + to receive(:can_update?).with(requirements_to_unlock: :all). + and_return(false, true) + allow(checker).to receive(:updated_dependencies). + with(requirements_to_unlock: :all). + and_return(multiple_dependencies) + end + + let(:peer_checker) { double(Dependabot::Bundler::UpdateChecker) } + before do + allow(peer_checker).to receive(:can_update?).and_return(false) + allow(Dependabot::Bundler::UpdateChecker).to receive(:new). + and_return(checker, checker, peer_checker) + end + + it "updates the dependency" do + expect(service).to receive(:create_pull_request).once + updater.run + end + + context "when the peer dependency could update on its own" do + before { allow(peer_checker).to receive(:can_update?).and_return(true) } + + it "doesn't update the dependency" do + expect(updater).to_not receive(:generate_dependency_files_for) + expect(service).to_not receive(:create_pull_request) + updater.run + end + end + + context "with ignore conditions" do + let(:ignore_conditions) do + [ + { "dependency-name" => "dummy-pkg-a", "version-requirement" => "~> 2.0.0" }, + { "dependency-name" => "dummy-pkg-b", "version-requirement" => "~> 1.0.0" } + ] + end + + it "doesn't set raise_on_ignore for the peer_checker" do + updater.run + + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: anything, + options: anything, + security_advisories: anything, + raise_on_ignored: true, + requirements_update_strategy: anything + ).twice.ordered + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: anything, + options: anything, + security_advisories: anything, + raise_on_ignored: false, + requirements_update_strategy: anything + ).ordered + end + end + end + + context "when a PR already exists", skip: "Managing existing PRs is out of scope for the prototype" do + let(:existing_pull_requests) do + [ + [ + { + "dependency-name" => "dummy-pkg-b", + "dependency-version" => "1.2.0" + } + ] + ] + end + + context "for the latest version" do + before do + allow(checker). + to receive(:latest_version). + and_return(Gem::Version.new("1.2.0")) + end + + it "doesn't call can_update? (so short-circuits resolution)" do + expect(checker).to_not receive(:can_update?) + expect(updater).to_not receive(:generate_dependency_files_for) + expect(service).to_not receive(:create_pull_request) + expect(service).to_not receive(:record_update_job_error) + expect(logger). + to receive(:info). + with(" Pull request already exists for dummy-pkg-b " \ + "with latest version 1.2.0") + updater.run + end + end + + context "for the resolved version" do + before do + allow(checker). + to receive(:latest_version). + and_return(Gem::Version.new("1.3.0")) + end + + it "doesn't update the dependency" do + expect(checker).to receive(:up_to_date?).and_return(false, false) + expect(checker).to receive(:can_update?).and_return(true, false) + expect(updater).to_not receive(:generate_dependency_files_for) + expect(service).to_not receive(:create_pull_request) + expect(service).to_not receive(:record_update_job_error) + expect(logger). + to receive(:info). + with(" Pull request already exists for dummy-pkg-b@1.2.0") + updater.run + end + end + + context "when security only updates for the resolved version" do + let(:security_updates_only) { true } + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"] }] + end + + before do + allow(checker). + to receive(:latest_version). + and_return(Gem::Version.new("1.3.0")) + allow(checker).to receive(:vulnerable?).and_return(true) + end + + it "creates an update job error and short-circuits" do + expect(checker).to receive(:up_to_date?).and_return(false) + expect(checker).to receive(:can_update?).and_return(true) + expect(updater).to_not receive(:generate_dependency_files_for) + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error). + with( + 1, + error_type: "pull_request_exists_for_security_update", + error_details: { + "updated-dependencies": [ + "dependency-name": "dummy-pkg-b", + "dependency-version": "1.2.0" + ] + } + ) + expect(logger). + to receive(:info). + with(" Pull request already exists for dummy-pkg-b@1.2.0") + updater.run + end + end + + context "when security only updates for the latest version", skip: "Security-only updates are out of scope" do + let(:security_updates_only) { true } + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"] }] + end + + before do + allow(checker). + to receive(:latest_version). + and_return(Gem::Version.new("1.2.0")) + allow(checker).to receive(:vulnerable?).and_return(true) + end + + it "doesn't call can_update? (so short-circuits resolution)" do + expect(checker).to_not receive(:can_update?) + expect(updater).to_not receive(:generate_dependency_files_for) + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error). + with( + 1, + error_type: "pull_request_exists_for_latest_version", + error_details: { + "dependency-name": "dummy-pkg-b", + "dependency-version": "1.2.0" + } + ) + expect(logger). + to receive(:info). + with(" Pull request already exists for dummy-pkg-b " \ + "with latest version 1.2.0") + updater.run + end + end + + context "for a different version" do + let(:existing_pull_requests) do + [ + { + "dependency-name" => "dummy-pkg-b", + "dependency-version" => "1.1.1" + } + ] + end + + it "updates the dependency" do + expect(service).to receive(:create_pull_request).once + updater.run + end + end + end + + context "when a PR already exists for a removed dependency", + skip: "Managing existing PRs is out of scope for the prototype" do + let(:existing_pull_requests) do + [ + [ + { + "dependency-name" => "dummy-pkg-c", + "dependency-version" => "1.4.0" + }, + { + "dependency-name" => "dummy-pkg-b", + "dependency-removed" => true + } + ] + ] + end + + let(:security_updates_only) { true } + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"] }] + end + + before do + allow(checker). + to receive(:latest_version). + and_return(Gem::Version.new("1.3.0")) + allow(checker).to receive(:vulnerable?).and_return(true) + allow(checker).to receive(:updated_dependencies).and_return([ + Dependabot::Dependency.new( + name: "dummy-pkg-b", + package_manager: "bundler", + previous_version: "1.1.0", + requirements: [], + previous_requirements: [], + removed: true + ), + Dependabot::Dependency.new( + name: "dummy-pkg-c", + package_manager: "bundler", + version: "1.4.0", + previous_version: "1.3.0", + requirements: [ + { file: "Gemfile", requirement: "~> 1.4.0", groups: [], source: nil } + ], + previous_requirements: [ + { file: "Gemfile", requirement: "~> 1.3.0", groups: [], source: nil } + ] + ) + ]) + end + + it "creates an update job error and short-circuits" do + expect(checker).to receive(:up_to_date?).and_return(false) + expect(checker).to receive(:can_update?).and_return(true) + expect(updater).to_not receive(:generate_dependency_files_for) + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error). + with( + 1, + error_type: "pull_request_exists_for_security_update", + error_details: { + "updated-dependencies": [ + { + "dependency-name": "dummy-pkg-c", + "dependency-version": "1.4.0" + }, + { + "dependency-name": "dummy-pkg-b", + "dependency-removed": true + } + ] + } + ) + expect(logger). + to receive(:info). + with(" Pull request already exists for dummy-pkg-c@1.4.0, dummy-pkg-b@removed") + updater.run + end + end + + context "when a list of dependencies is specified" do + let(:requested_dependencies) { ["dummy-pkg-b"] } + + context "and the job is to update a PR", skip: "Updating existing PRs is out of scope for the prototype" do + let(:updating_a_pull_request) { true } + + it "only attempts to update dependencies on the specified list" do + expect(updater_delegate). + to receive(:run_update_existing). + and_call_original + expect(updater). + to_not receive(:compile_updates_for) + expect(service).to receive(:create_pull_request).once + + updater.run + end + + context "when security only updates" do + let(:security_updates_only) { true } + + before do + allow(checker).to receive(:vulnerable?).and_return(true) + end + + context "the dependency isn't vulnerable" do + it "closes the pull request" do + expect(service).to receive(:close_pull_request).once + updater.run + end + end + + context "the dependency is vulnerable" do + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"] }] + end + + it "creates the pull request" do + expect(service).to receive(:create_pull_request) + updater.run + end + end + + context "the dependency is vulnerable but updates aren't allowed" do + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"] }] + end + let(:allowed_updates) do + [ + { + "dependency-type" => "development" + } + ] + end + + it "closes the pull request" do + expect(service).to receive(:close_pull_request).once + expect(logger). + to receive(:info).with( + " Dependency no longer allowed to update dummy-pkg-b 1.1.0" + ) + updater.run + end + end + end + + context "when the dependency doesn't appear in the parsed file" do + let(:requested_dependencies) { ["removed_dependency"] } + + it "closes the pull request" do + expect(service).to receive(:close_pull_request).once + updater.run + end + + context "because an error was raised parsing the dependencies" do + before do + allow(updater).to receive(:dependency_files). + and_raise( + Dependabot::DependencyFileNotParseable.new("path/to/file") + ) + end + + it "does not close the pull request" do + expect(service).to_not receive(:close_pull_request) + updater.run + end + end + end + + context "when the dependency name case doesn't match what's parsed" do + let(:requested_dependencies) { ["Dummy-pkg-b"] } + + it "only attempts to update dependencies on the specified list" do + expect(updater_delegate). + to receive(:run_update_existing). + and_call_original + expect(updater). + to_not receive(:compile_updates_for) + expect(service).to receive(:create_pull_request).once + expect(service).not_to receive(:close_pull_request) + + updater.run + end + end + + context "when a PR already exists" do + let(:existing_pull_requests) do + [ + [ + { + "dependency-name" => "dummy-pkg-b", + "dependency-version" => "1.2.0" + } + ] + ] + end + + it "updates the dependency" do + expect(service).to receive(:update_pull_request).once + updater.run + end + + context "for a different version" do + let(:existing_pull_requests) do + [ + [ + { + "dependency-name" => "dummy-pkg-b", + "dependency-version" => "1.1.1" + } + ] + ] + end + + it "updates the dependency" do + expect(service).to receive(:create_pull_request).once + updater.run + end + end + end + + context "when the dependency no-longer needs updating" do + before { allow(checker).to receive(:can_update?).and_return(false) } + + it "closes the pull request" do + expect(service).to receive(:close_pull_request).once + updater.run + end + end + end + + context "and the job is not to update a PR" do + let(:updating_a_pull_request) { false } + + it "only attempts to update dependencies on the specified list" do + expect(updater). + to receive(:compile_updates_for). + and_call_original + expect(updater_delegate). + to_not receive(:run_update_existing) + expect(service).to receive(:create_pull_request).once + + updater.run + end + + context "when the dependency doesn't appear in the parsed file" do + let(:requested_dependencies) { ["removed_dependency"] } + + it "does not try to close any pull request" do + expect(service).to_not receive(:close_pull_request) + updater.run + end + end + + context "when the dependency name case doesn't match what's parsed" do + let(:requested_dependencies) { ["Dummy-pkg-b"] } + + it "only attempts to update dependencies on the specified list" do + expect(updater). + to receive(:compile_updates_for). + and_call_original + expect(updater_delegate). + to_not receive(:run_update_existing) + expect(service).to receive(:create_pull_request).once + + updater.run + end + end + + context "when the dependency is a sub-dependency" do + let(:requested_dependencies) { ["dummy-pkg-a"] } + + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "Gemfile", + content: fixture("bundler/original/sub_dep"), + directory: "/" + ), + Dependabot::DependencyFile.new( + name: "Gemfile.lock", + content: fixture("bundler/original/sub_dep.lock"), + directory: "/" + ) + ] + end + + it "still attempts to update the dependency" do + expect(updater). + to receive(:compile_updates_for). + and_call_original + expect(updater_delegate). + to_not receive(:run_update_existing) + expect(service).to receive(:create_pull_request).once + + updater.run + end + end + + context "for security only updates", skip: "Security-only updates are out of scope" do + let(:security_updates_only) { true } + + before do + allow(checker).to receive(:vulnerable?).and_return(true) + end + + context "when the dependency is vulnerable" do + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"] }] + end + + it "creates the pull request" do + expect(service).to receive(:create_pull_request) + updater.run + end + end + + context "when the dependency is not allowed to update" do + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.1.0"] }] + end + let(:allowed_updates) do + [ + { + "dependency-type" => "development" + } + ] + end + + it "does not create the pull request" do + expect(service).not_to receive(:create_pull_request) + expect(service).to receive(:record_update_job_error).with( + 1, + { + error_type: "all_versions_ignored", + error_details: { + "dependency-name": "dummy-pkg-b" + } + } + ) + expect(logger). + to receive(:info).with( + " Dependabot cannot update to the required version as all " \ + "versions were ignored for dummy-pkg-b" + ) + updater.run + end + end + + context "when the dependency is no longer vulnerable" do + let(:security_advisories) do + [{ "dependency-name" => "dummy-pkg-b", + "affected-versions" => ["1.0.0"], + "patched-versions" => ["1.1.0"] }] + end + + before do + allow(checker).to receive(:vulnerable?).and_return(false) + end + + it "does not create pull request" do + expect(service).to_not receive(:create_pull_request) + expect(service).to receive(:record_update_job_error).with( + 1, + { + error_type: "security_update_not_needed", + error_details: { + "dependency-name": "dummy-pkg-b" + } + } + ) + expect(logger). + to receive(:info).with( + " no security update needed as dummy-pkg-b " \ + "is no longer vulnerable" + ) + + updater.run + end + end + end + end + end + + context "when an error is raised" do + let(:error) { StandardError } + + before do + values = [-> { raise error }, -> { true }, -> { true }, -> { true }] + allow(checker).to receive(:can_update?) { values.shift.call } + end + + context "during parsing", skip: "Handled for us in Dependabot::Updater#run, Inadvertantly tests ignoring" do + before { allow(updater).to receive(:dependency_files).and_raise(error) } + + context "and it's an unknown error" do + let(:error) { StandardError.new("hell") } + + it "tells Sentry" do + expect(Raven).to receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "unknown_error", + error_details: nil + ) + updater.run + end + end + + context "but it's a Dependabot::DependencyFileNotFound", skip: "Handled for us in Dependabot::Updater#run" do + let(:error) { Dependabot::DependencyFileNotFound.new("path/to/file") } + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "dependency_file_not_found", + error_details: { "file-path": "path/to/file" } + ) + updater.run + end + end + + context "but it's a Dependabot::BranchNotFound", skip: "Handled for us in Dependabot::Updater#run" do + let(:error) { Dependabot::BranchNotFound.new("my_branch") } + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "branch_not_found", + error_details: { "branch-name": "my_branch" } + ) + updater.run + end + end + + context "but it's a Dependabot::DependencyFileNotParseable", + skip: "Handled for us in Dependabot::Updater#run" do + let(:error) do + Dependabot::DependencyFileNotParseable.new("path/to/file", "a") + end + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "dependency_file_not_parseable", + error_details: { "file-path": "path/to/file", message: "a" } + ) + updater.run + end + end + + context "but it's a Dependabot::PathDependenciesNotReachable", + skip: "Handled for us in Dependabot::Updater#run" do + let(:error) do + Dependabot::PathDependenciesNotReachable.new(["bad_gem"]) + end + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "path_dependencies_not_reachable", + error_details: { dependencies: ["bad_gem"] } + ) + updater.run + end + end + end + + context "but it's a Dependabot::DependencyFileNotResolvable" do + let(:error) { Dependabot::DependencyFileNotResolvable.new("message") } + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "dependency_file_not_resolvable", + error_details: { message: "message" } + ) + updater.run + end + end + + context "but it's a Dependabot::DependencyFileNotEvaluatable" do + let(:error) { Dependabot::DependencyFileNotEvaluatable.new("message") } + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "dependency_file_not_evaluatable", + error_details: { message: "message" } + ) + updater.run + end + end + + context "but it's a Dependabot::InconsistentRegistryResponse" do + let(:error) { Dependabot::InconsistentRegistryResponse.new("message") } + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "doesn't tell the main backend" do + expect(service).to_not receive(:record_update_job_error) + updater.run + end + end + + context "but it's a Dependabot::GitDependenciesNotReachable" do + let(:error) do + Dependabot::GitDependenciesNotReachable.new("https://example.com") + end + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "git_dependencies_not_reachable", + error_details: { "dependency-urls": ["https://example.com"] } + ) + updater.run + end + end + + context "but it's a Dependabot::GitDependencyReferenceNotFound" do + let(:error) do + Dependabot::GitDependencyReferenceNotFound.new("some_dep") + end + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "git_dependency_reference_not_found", + error_details: { dependency: "some_dep" } + ) + updater.run + end + end + + context "but it's a Dependabot::GoModulePathMismatch" do + let(:error) do + Dependabot::GoModulePathMismatch.new("/go.mod", "foo", "bar") + end + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "go_module_path_mismatch", + error_details: { + "declared-path": "foo", + "discovered-path": "bar", + "go-mod": "/go.mod" + } + ) + updater.run + end + end + + context "but it's a Dependabot::PrivateSourceAuthenticationFailure" do + let(:error) do + Dependabot::PrivateSourceAuthenticationFailure.new("some.example.com") + end + + it "doesn't tell Sentry" do + expect(Raven).to_not receive(:capture_exception) + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "private_source_authentication_failure", + error_details: { source: "some.example.com" } + ) + updater.run + end + end + + context "but it's a Dependabot::SharedHelpers::HelperSubprocessFailed" do + let(:error) do + Dependabot::SharedHelpers::HelperSubprocessFailed.new( + message: "Potentially sensitive log content goes here", + error_context: {} + ) + end + + it "tells the main backend there has been an unknown error" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "unknown_error", + error_details: nil + ) + updater.run + end + + it "notifies Sentry with a breadcrumb to check the logs" do + expect(Raven). + to receive(:capture_exception). + with(instance_of(Dependabot::Updater::SubprocessFailed), anything) + updater.run + end + end + + it "tells Sentry" do + expect(Raven).to receive(:capture_exception).once + updater.run + end + + it "tells the main backend" do + expect(service). + to receive(:record_update_job_error). + with( + 1, + error_type: "unknown_error", + error_details: nil + ) + updater.run + end + + it "still processes the other jobs", skip: "Grouped updates are one-and-done for now, nothing else runs after" do + expect(service).to receive(:create_pull_request).once + updater.run + end + end + + describe "experiments" do + let(:experiments) do + { "large-hadron-collider" => true } + end + + it "passes the experiments to the FileParser as options" do + expect(Dependabot::Bundler::FileParser).to receive(:new).with( + dependency_files: dependency_files, + repo_contents_path: repo_contents_path, + source: job.source, + credentials: credentials, + reject_external_code: job.reject_external_code?, + options: { large_hadron_collider: true } + ).and_call_original + + updater.run + end + + it "passes the experiments to the FileUpdater as options" do + expect(Dependabot::Bundler::FileUpdater).to receive(:new).with( + dependencies: [dependency], + dependency_files: dependency_files, + repo_contents_path: repo_contents_path, + credentials: credentials, + options: { large_hadron_collider: true } + ).and_call_original + + updater.run + end + + it "passes the experiments to the UpdateChecker as options" do + updater.run + + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: anything, + security_advisories: anything, + raise_on_ignored: anything, + requirements_update_strategy: anything, + options: { large_hadron_collider: true } + ).twice + end + + context "with a bundler 2 project" do + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "Gemfile", + content: fixture("bundler2/original/Gemfile"), + directory: "/" + ), + Dependabot::DependencyFile.new( + name: "Gemfile.lock", + content: fixture("bundler2/original/Gemfile.lock"), + directory: "/" + ) + ] + end + + it "updates dependencies correctly" do + job_id = 1 + dependencies = [have_attributes(name: "dummy-pkg-b")] + updated_dependency_files = [ + { + "name" => "Gemfile", + "content" => fixture("bundler2/updated/Gemfile"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + }, + { + "name" => "Gemfile.lock", + "content" => fixture("bundler2/updated/Gemfile.lock"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + } + ] + base_commit_sha = "sha" + pr_message = nil + expect(service). + to receive(:create_pull_request). + with(job_id, dependencies, updated_dependency_files, base_commit_sha, pr_message, true) + + updater.run + end + end + end + + it "does not log empty ignore conditions" do + expect(logger). + not_to receive(:info). + with(/Ignored versions:/) + updater.run + end + + context "with ignore conditions" do + let(:config_ignore_condition) do + { + "dependency-name" => "*-pkg-b", + "update-types" => ["version-update:semver-patch", "version-update:semver-minor"], + "source" => ".github/dependabot.yaml" + } + end + let(:comment_ignore_condition) do + { + "dependency-name" => dependency.name, + "version-requirement" => ">= 1.a, < 2.0.0", + "source" => "@dependabot ignore command" + } + end + let(:ignore_conditions) { [config_ignore_condition, comment_ignore_condition] } + + it "logs ignored versions" do + updater.run + expect(logger). + to have_received(:info). + with(/Ignored versions:/) + end + + it "logs ignore conditions" do + updater.run + expect(logger). + to have_received(:info). + with(" >= 1.a, < 2.0.0 - from @dependabot ignore command") + end + + it "logs ignored update types" do + updater.run + expect(logger). + to have_received(:info). + with(" version-update:semver-patch - from .github/dependabot.yaml") + expect(logger). + to have_received(:info). + with(" version-update:semver-minor - from .github/dependabot.yaml") + end + end + + context "with ignored versions that don't apply during a security update" do + let(:security_updates_only) { true } + let(:requested_dependencies) { ["dummy-pkg-b"] } + let(:ignore_conditions) do + [ + { + "dependency-name" => "dummy-pkg-b", + "update-types" => ["version-update:semver-patch"], + "source" => ".github/dependabot.yaml" + } + ] + end + + it "logs ignored versions" do + updater.run + expect(logger). + to have_received(:info). + with(/Ignored versions:/) + end + + it "logs ignored update types" do + updater.run + expect(logger). + to have_received(:info). + with( + " version-update:semver-patch - from .github/dependabot.yaml (doesn't apply to security update)" + ) + end + end + + # FIXME: Consider removing this test in the next phase of grouped update support + # + # The `dependencies` method is _not_ memoized and we generally do a good job + # of passing it to other private methods, but we are about to refactor this + # class heavily and it would be easy to treat this as an instance variable + # unwittingly as it is a ubiqious business object for the Updater. + # + context "dependency file parsing" do + before do + allow(Dependabot::FileParsers).to receive(:for_package_manager).and_call_original + end + + it "is only performed once when creating an update" do + job_id = 1 + dependencies = [have_attributes(name: "dummy-pkg-b")] + updated_dependency_files = [ + { + "name" => "Gemfile", + "content" => fixture("bundler/updated/Gemfile"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + }, + { + "name" => "Gemfile.lock", + "content" => fixture("bundler/updated/Gemfile.lock"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + } + ] + base_commit_sha = "sha" + pr_message = nil + expect(service). + to receive(:create_pull_request). + with(job_id, dependencies, updated_dependency_files, base_commit_sha, pr_message, true) + + expect(Dependabot::FileParsers).to receive(:for_package_manager).once + + updater.run + end + end + end + + describe "#run_grouped" do + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "Gemfile", + content: fixture("bundler/original/Gemfile"), + directory: "/" + ), + Dependabot::DependencyFile.new( + name: "Gemfile.lock", + content: fixture("bundler/original/Gemfile.lock"), + directory: "/" + ) + ] + end + + context "the job wants to update an existing PR" do + before do + allow(service).to receive(:record_update_job_error).and_return(nil) + allow(job).to receive(:updating_a_pull_request?).and_return(true) + end + + it "raises a not implemented error" do + expect { updater.run }. + to raise_error(Dependabot::NotImplemented, "Grouped updates do not currently support rebasing.") + end + end + + describe "when ignores match the dependency name" do + let(:requested_dependencies) { ["dummy-pkg-b"] } + let(:ignore_conditions) { [{ "dependency-name" => "dummy-pkg-b", "version-requirement" => ">= 0" }] } + + it "does not pass any ignore rules to the checker as they aren't supported" do + updater.run + expect(Dependabot::Bundler::UpdateChecker).to have_received(:new).with( + dependency: anything, + dependency_files: anything, + repo_contents_path: anything, + credentials: anything, + ignored_versions: [], + security_advisories: anything, + raise_on_ignored: anything, + requirements_update_strategy: anything, + options: anything + ).once + end + end + end +end diff --git a/updater/spec/dependabot/job_spec.rb b/updater/spec/dependabot/job_spec.rb index f87af0fb232..6ca0cdbfa68 100644 --- a/updater/spec/dependabot/job_spec.rb +++ b/updater/spec/dependabot/job_spec.rb @@ -328,7 +328,7 @@ it "registers the experiments with Dependabot::Experiments" do job expect(Dependabot::Experiments.enabled?(:kebab_case)).to be_truthy - expect(Dependabot::Experiments.enabled?(:simpe)).to be_falsey + expect(Dependabot::Experiments.enabled?(:simple)).to be_falsey end end diff --git a/updater/spec/dependabot/service_spec.rb b/updater/spec/dependabot/service_spec.rb index fa4fda2700e..c8e23df41cb 100644 --- a/updater/spec/dependabot/service_spec.rb +++ b/updater/spec/dependabot/service_spec.rb @@ -132,7 +132,7 @@ it "delegates to @client" do expect(mock_client). - to have_received(:create_pull_request).with(job_id, dependencies, dependency_files, base_sha, pr_message) + to have_received(:create_pull_request).with(job_id, dependencies, dependency_files, base_sha, pr_message, false) end it "memoizes a shorthand summary of the PR" do diff --git a/updater/spec/dependabot/updater_spec.rb b/updater/spec/dependabot/updater_spec.rb index b1007608750..f835030f0f1 100644 --- a/updater/spec/dependabot/updater_spec.rb +++ b/updater/spec/dependabot/updater_spec.rb @@ -1073,7 +1073,7 @@ def expect_update_checker_with_ignored_versions(versions) it "only attempts to update dependencies on the specified list" do expect(updater). - to receive(:check_and_update_existing_pr_with_error_handling). + to receive(:run_update_existing). and_call_original expect(updater). to_not receive(:check_and_create_pr_with_error_handling) @@ -1160,7 +1160,7 @@ def expect_update_checker_with_ignored_versions(versions) it "only attempts to update dependencies on the specified list" do expect(updater). - to receive(:check_and_update_existing_pr_with_error_handling). + to receive(:run_update_existing). and_call_original expect(updater). to_not receive(:check_and_create_pr_with_error_handling) @@ -1225,7 +1225,7 @@ def expect_update_checker_with_ignored_versions(versions) to receive(:check_and_create_pr_with_error_handling). and_call_original expect(updater). - to_not receive(:check_and_update_existing_pr_with_error_handling) + to_not receive(:run_update_existing) expect(service).to receive(:create_pull_request).once updater.run @@ -1248,7 +1248,7 @@ def expect_update_checker_with_ignored_versions(versions) to receive(:check_and_create_pr_with_error_handling). and_call_original expect(updater). - to_not receive(:check_and_update_existing_pr_with_error_handling) + to_not receive(:run_update_existing) expect(service).to receive(:create_pull_request).once updater.run @@ -1278,7 +1278,7 @@ def expect_update_checker_with_ignored_versions(versions) to receive(:check_and_create_pr_with_error_handling). and_call_original expect(updater). - to_not receive(:check_and_update_existing_pr_with_error_handling) + to_not receive(:run_update_existing) expect(service).to receive(:create_pull_request).once updater.run @@ -1861,5 +1861,105 @@ def expect_update_checker_with_ignored_versions(versions) ) end end + + # FIXME: Consider removing this test in the next phase of grouped update support + # + # The `dependencies` method is _not_ memoized and we generally do a good job + # of passing it to other private methods, but we are about to refactor this + # class heavily and it would be easy to treat this as an instance variable + # unwittingly as it is a ubiqious business object for the Updater. + # + context "dependency file parsing" do + before do + allow(Dependabot::FileParsers).to receive(:for_package_manager).and_call_original + end + + it "is only performed once when creating an update" do + job_id = 1 + dependencies = [have_attributes(name: "dummy-pkg-b")] + updated_dependency_files = [ + { + "name" => "Gemfile", + "content" => fixture("bundler/updated/Gemfile"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + }, + { + "name" => "Gemfile.lock", + "content" => fixture("bundler/updated/Gemfile.lock"), + "directory" => "/", + "type" => "file", + "mode" => "100644", + "support_file" => false, + "content_encoding" => "utf-8", + "deleted" => false, + "operation" => "update" + } + ] + base_commit_sha = "sha" + pr_message = nil + expect(service). + to receive(:create_pull_request). + with(job_id, dependencies, updated_dependency_files, base_commit_sha, pr_message) + + expect(Dependabot::FileParsers).to receive(:for_package_manager).once + + updater.run + end + end + + context "the prototype_grouped_update experiment" do + let(:mock_grouped_updater) do + instance_double(Dependabot::ExperimentalGroupedUpdater, run: true) + end + + before do + allow(Dependabot::ExperimentalGroupedUpdater).to receive(:new).and_return(mock_grouped_updater) + end + + context "the experiment is not present" do + it "runs normally and creates a pull request" do + expect(service).to receive(:create_pull_request) + expect(mock_grouped_updater).not_to receive(:run) + + updater.run + end + end + + context "the experiment is disabled" do + let(:experiments) { { "grouped-updates-prototype" => false } } + + after do + Dependabot::Experiments.reset! + end + + it "runs normally and creates a pull request" do + expect(service).to receive(:create_pull_request) + expect(mock_grouped_updater).not_to receive(:run) + + updater.run + end + end + + context "the experiment is enabled" do + let(:experiments) { { "grouped-updates-prototype" => true } } + + after do + Dependabot::Experiments.reset! + end + + it "delegates to the experimental updater shim" do + expect(mock_grouped_updater).to receive(:run) + expect(service).not_to receive(:create_pull_request) + + updater.run + end + end + end end end