diff --git a/lib/pact_broker/api/decorators/notices_decorator.rb b/lib/pact_broker/api/decorators/notices_decorator.rb new file mode 100644 index 000000000..fe46659de --- /dev/null +++ b/lib/pact_broker/api/decorators/notices_decorator.rb @@ -0,0 +1,11 @@ +require_relative "base_decorator" + +module PactBroker + module Api + module Decorators + class NoticesDecorator < BaseDecorator + property :entries, as: :notices, getter: ->(represented:, **) { represented.collect(&:to_h) } + end + end + end +end diff --git a/lib/pact_broker/api/resources/after_reply.rb b/lib/pact_broker/api/resources/after_reply.rb new file mode 100644 index 000000000..cbf7da715 --- /dev/null +++ b/lib/pact_broker/api/resources/after_reply.rb @@ -0,0 +1,15 @@ +require "pact_broker/async/after_reply" + +module PactBroker + module Api + module Resources + module AfterReply + + # @param [Callable] block the block to execute after the response has been sent to the user. + def after_reply(&block) + PactBroker::Async::AfterReply.new(request.env).execute(&block) + end + end + end + end +end diff --git a/lib/pact_broker/api/resources/pacticipant_branches.rb b/lib/pact_broker/api/resources/pacticipant_branches.rb index f97da58a2..e30181b45 100644 --- a/lib/pact_broker/api/resources/pacticipant_branches.rb +++ b/lib/pact_broker/api/resources/pacticipant_branches.rb @@ -1,6 +1,8 @@ require "pact_broker/api/resources/base_resource" require "pact_broker/api/resources/pagination_methods" require "pact_broker/api/resources/filter_methods" +require "pact_broker/api/resources/after_reply" +require "rack/utils" module PactBroker module Api @@ -8,13 +10,14 @@ module Resources class PacticipantBranches < BaseResource include PaginationMethods include FilterMethods + include AfterReply def content_types_provided [["application/hal+json", :to_json]] end def allowed_methods - ["GET", "OPTIONS"] + ["GET", "DELETE", "OPTIONS"] end def resource_exists? @@ -29,6 +32,17 @@ def policy_name :'versions::branches' end + # Allows bulk deletion of pacticipant branches, keeping the specified branches and the main branch. + # Deletes the branches asyncronously, after the response has been sent, for performance reasons. + def delete_resource + after_reply do + branch_service.delete_branches_for_pacticipant(pacticipant, exclude: exclude) + end + notices = branch_service.branch_deletion_notices(pacticipant, exclude: exclude) + response.body = decorator_class(:notices_decorator).new(notices).to_json(**decorator_options) + 202 + end + private def branches @@ -40,6 +54,10 @@ def branches ) end + def exclude + Rack::Utils.parse_nested_query(request.uri.query)["exclude"] || [] + end + def eager_load_associations decorator_class(:pacticipant_branches_decorator).eager_load_associations end diff --git a/lib/pact_broker/async/after_reply.rb b/lib/pact_broker/async/after_reply.rb new file mode 100644 index 000000000..f25676fef --- /dev/null +++ b/lib/pact_broker/async/after_reply.rb @@ -0,0 +1,30 @@ +# Saves a block for execution after the HTTP response has been sent to the user. +# When the block is executed, it connects to the database before executing the code. +# This is good for doing things that might take a while and don't have to be done before +# the response is sent, and don't need retries (in which case, it might be better to use a SuckerPunch Job). +# +# This leverages a feature of Puma which I'm not sure is meant to be public or not. +# There are serveral mentions of it on the internet, so I assume it's ok to use it. +# Puma itself uses the rack.after_reply for http request logging. +# +# https://github.com/search?q=repo%3Apuma%2Fpuma%20rack.after_reply&type=code + +module PactBroker + module Async + class AfterReply + def initialize(rack_env) + @rack_env = rack_env + @database_connector = rack_env.fetch("pactbroker.database_connector") + end + + def execute(&block) + dc = @database_connector + @rack_env["rack.after_reply"] << lambda { + dc.call do + block.call + end + } + end + end + end +end diff --git a/lib/pact_broker/locale/en.yml b/lib/pact_broker/locale/en.yml index f3489fc79..d3b149aa2 100644 --- a/lib/pact_broker/locale/en.yml +++ b/lib/pact_broker/locale/en.yml @@ -59,6 +59,8 @@ en: verifications: Add Pact verification tests to the %{provider_name} build. See https://docs.pact.io/go/provider_verification webhooks: Configure separate %{provider_name} pact verification build and webhook to trigger it when the pact content changes. See https://docs.pact.io/go/webhooks version_branch: Configure the version branch to be the value of your repository branch. + branch: + bulk_delete: "Scheduled deletion of %{count} branches for pacticipant %{pacticipant_name}. Remaining branches are: %{remaining}" errors: runtime: with_error_reference: "An error has occurred. The details have been logged with the reference %{error_reference}" diff --git a/lib/pact_broker/versions/branch_repository.rb b/lib/pact_broker/versions/branch_repository.rb index 7e41b00f5..35f69e198 100644 --- a/lib/pact_broker/versions/branch_repository.rb +++ b/lib/pact_broker/versions/branch_repository.rb @@ -38,6 +38,39 @@ def find_branch(pacticipant_name:, branch_name:) def delete_branch(branch) branch.delete end + + # @param [PactBroker::Domain::Pacticipant] pacticipant + # @params [Array] exclude the names of the branches to NOT delete + # @param [Integer] the number of branches that will be deleted + def count_branches_to_delete(pacticipant, exclude: ) + build_query_for_pacticipant_branches(pacticipant, exclude: exclude).count + end + + # Returns the list of branches which will NOT be deleted (the bulk delete is executed async after the request has finished) + # @param [PactBroker::Domain::Pacticipant] pacticipant + # @params [Array] exclude the names of the branches to NOT delete + # @return [Array] + def remaining_branches_after_future_deletion(pacticipant, exclude: ) + exclude_dup = exclude.dup + if pacticipant.main_branch + exclude_dup << pacticipant.main_branch + end + Branch.where(pacticipant_id: pacticipant.id).where(name: exclude_dup) + end + + # @param [PactBroker::Domain::Pacticipant] pacticipant + # @params [Array] exclude the names of the branches to NOT delete + def delete_branches_for_pacticipant(pacticipant, exclude:) + build_query_for_pacticipant_branches(pacticipant, exclude: exclude).delete + end + + def build_query_for_pacticipant_branches(pacticipant, exclude: ) + exclude_dup = exclude.dup + if pacticipant.main_branch + exclude_dup << pacticipant.main_branch + end + Branch.where(pacticipant_id: pacticipant.id).exclude(name: exclude_dup) + end end end end diff --git a/lib/pact_broker/versions/branch_service.rb b/lib/pact_broker/versions/branch_service.rb index 85e616121..fd8ede8d2 100644 --- a/lib/pact_broker/versions/branch_service.rb +++ b/lib/pact_broker/versions/branch_service.rb @@ -1,17 +1,28 @@ +require "forwardable" require "pact_broker/logging" require "pact_broker/repositories" require "pact_broker/messages" -require "forwardable" module PactBroker module Versions class BranchService extend PactBroker::Repositories + extend PactBroker::Messages class << self extend Forwardable delegate [:find_branch_version, :find_or_create_branch_version, :delete_branch_version] => :branch_version_repository - delegate [:find_branch, :delete_branch, :find_all_branches_for_pacticipant] => :branch_repository + delegate [:find_branch, :delete_branch, :find_all_branches_for_pacticipant, :delete_branches_for_pacticipant] => :branch_repository + + # Returns a list of notices to display to the user in the terminal + # @param [PactBroker::Domain::Pacticipant] pacticipant + # @param [Array] exclude the list of branches to NOT delete + # @return [Array] + def branch_deletion_notices(pacticipant, exclude:) + count = branch_repository.count_branches_to_delete(pacticipant, exclude: exclude) + remaining = branch_repository.remaining_branches_after_future_deletion(pacticipant, exclude: exclude).sort_by(&:created_at).collect(&:name).join(", ") + [PactBroker::Contracts::Notice.success(message("messages.branch.bulk_delete", count: count, pacticipant_name: pacticipant.name, remaining: remaining))] + end end end end diff --git a/spec/features/delete_pacticipant_branches_spec.rb b/spec/features/delete_pacticipant_branches_spec.rb new file mode 100644 index 000000000..7930c6274 --- /dev/null +++ b/spec/features/delete_pacticipant_branches_spec.rb @@ -0,0 +1,35 @@ +describe "Delete pacticipant branches" do + before do + td.create_consumer("Bar") + .create_consumer_version("1", branch: "main") + .create_consumer("Foo", main_branch: "main") + .create_consumer_version("1", branch: "main") + .create_consumer_version("2", branch: "feat/bar") + .create_consumer_version("3", branch: "feat/foo") + end + let(:path) { PactBroker::Api::PactBrokerUrls.pacticipant_branches_url(td.and_return(:pacticipant)) } + let(:rack_env) do + { + "pactbroker.database_connector" => lambda { |&block| block.call } + } + end + + subject { delete(path + "?exclude[]=feat%2Fbar", nil, rack_env) } + + its(:status) { is_expected.to eq 202 } + + it "returns a list of notices to be displayed to the user" do + expect(JSON.parse(subject.body)["notices"]).to be_instance_of(Array) + expect(JSON.parse(subject.body)["notices"]).to include(hash_including("text")) + end + + it "after the request, it deletes all except the excluded branches for a pacticipant" do + expect { subject }.to change { + PactBroker::Versions::Branch + .where(pacticipant_id: td.and_return(:pacticipant).id) + .all + .collect(&:name) + .sort + }.from(["feat/bar", "feat/foo", "main"]).to(["feat/bar", "main"]) + end +end diff --git a/spec/lib/pact_broker/versions/branch_repository_spec.rb b/spec/lib/pact_broker/versions/branch_repository_spec.rb index 68ab71d95..e7a2fd44b 100644 --- a/spec/lib/pact_broker/versions/branch_repository_spec.rb +++ b/spec/lib/pact_broker/versions/branch_repository_spec.rb @@ -92,6 +92,70 @@ module Versions expect{ subject }.to_not change { PactBroker::Domain::Version.count } end end + + describe "count_branches_to_delete" do + before do + td.create_consumer("foo") + .create_consumer_version("1", branch: "main") + .create_consumer_version("3", branch: "not-main") + .create_consumer_version("4", branch: "foo") + .create_consumer_version("5", branch: "bar") + .create_consumer_version("6", branch: "not-bar") + .create_consumer("bar") + .create_consumer_version("1", branch: "main") + end + + let(:pacticipant) { td.find_pacticipant("foo") } + + subject { BranchRepository.new.count_branches_to_delete(pacticipant, exclude: ["foo"]) } + + it "returns a count of the number of branches that will be deleted" do + expect(subject).to eq 3 + end + end + + describe "delete_branches_for_pacticipant" do + before do + td.create_consumer("foo") + .create_consumer_version("1", branch: "main") + .create_consumer_version("3", branch: "not-main") + .create_consumer_version("4", branch: "foo") + .create_consumer_version("5", branch: "bar") + .create_consumer_version("6", branch: "not-bar") + .create_consumer("bar") + .create_consumer_version("1", branch: "main") + end + + let(:pacticipant) { td.find_pacticipant("foo") } + + subject { BranchRepository.new.delete_branches_for_pacticipant(pacticipant, exclude: ["foo"]) } + + it "deletes all the branches except for the excluded ones and the main branch" do + subject + expect(Branch.where(pacticipant: pacticipant).collect(&:name)).to contain_exactly("foo", "main") + end + end + + describe "remaining_branches_after_future_deletion" do + before do + td.create_consumer("foo") + .create_consumer_version("1", branch: "main") + .create_consumer_version("3", branch: "not-main") + .create_consumer_version("4", branch: "foo") + .create_consumer_version("5", branch: "bar") + .create_consumer_version("6", branch: "not-bar") + .create_consumer("bar") + .create_consumer_version("1", branch: "main") + end + + let(:pacticipant) { td.find_pacticipant("foo") } + + subject { BranchRepository.new.remaining_branches_after_future_deletion(pacticipant, exclude: ["foo"]) } + + it "returns the branches that will not be deleted" do + expect(subject).to contain_exactly(have_attributes(name: "main"), have_attributes(name: "foo")) + end + end end end end diff --git a/spec/lib/pact_broker/versions/branch_service_spec.rb b/spec/lib/pact_broker/versions/branch_service_spec.rb index e3176e822..3ba433114 100644 --- a/spec/lib/pact_broker/versions/branch_service_spec.rb +++ b/spec/lib/pact_broker/versions/branch_service_spec.rb @@ -91,6 +91,28 @@ module Versions end end end + + describe "#branch_deletion_notices" do + let(:pacticipant) { instance_double(PactBroker::Domain::Pacticipant, name: "some-service") } + let(:exclude) { ["foo", "bar" ] } + let(:branch_repository) { instance_double(PactBroker::Versions::BranchRepository, count_branches_to_delete: 3, remaining_branches_after_future_deletion: remaining_branches) } + let(:remaining_branches) do + [ + instance_double(PactBroker::Versions::Branch, name: "foo", created_at: DateTime.now - 10), + instance_double(PactBroker::Versions::Branch, name: "bar", created_at: DateTime.now - 20) + ] + end + + before do + allow(BranchService).to receive(:branch_repository).and_return(branch_repository) + end + + subject { BranchService.branch_deletion_notices(pacticipant, exclude: exclude) } + + it "returns a list of notices" do + expect(subject).to contain_exactly(have_attributes(text: "Scheduled deletion of 3 branches for pacticipant some-service. Remaining branches are: bar, foo")) + end + end end end end diff --git a/spec/support/pact_broker/middleware/mock_puma.rb b/spec/support/pact_broker/middleware/mock_puma.rb new file mode 100644 index 000000000..a94eadea0 --- /dev/null +++ b/spec/support/pact_broker/middleware/mock_puma.rb @@ -0,0 +1,26 @@ +# Mock out the rack.after_reply functionality provided by Puma +# I'm not sure if this is meant to be a public feature or not, but +# there are several mentions of it on the net, so I assume it's ok to use it. +# Puma itself uses the rack.after_reply for http request logging. +# +# See https://github.com/puma/puma/search?q=rack.after_reply +# This middleware executes the hooks that would normally run after the request +# *before* the request ends, for the purposes of testing. + +module PactBroker + module Middleware + class MockPuma + + def initialize(app) + @app = app + end + + def call(env) + after_reply = [] + response = @app.call({ "rack.after_reply" => after_reply }.merge(env)) + after_reply.each(&:call) + response + end + end + end +end diff --git a/spec/support/shared_context_for_app.rb b/spec/support/shared_context_for_app.rb index 1de3af135..975319e9a 100644 --- a/spec/support/shared_context_for_app.rb +++ b/spec/support/shared_context_for_app.rb @@ -43,6 +43,7 @@ builder.use OpenapiFirst::PactBrokerCoverage, endpoints_to_be_called end + builder.use(PactBroker::Middleware::MockPuma) builder.use(Rack::PactBroker::ApplicationContext, application_context) builder.run(PactBroker.build_api(application_context)) builder.to_app