Skip to content

Commit

Permalink
feat: bulk delete branches (#652)
Browse files Browse the repository at this point in the history
  • Loading branch information
bethesque authored Dec 11, 2023
1 parent 89271f1 commit 14ac33c
Show file tree
Hide file tree
Showing 12 changed files with 271 additions and 3 deletions.
11 changes: 11 additions & 0 deletions lib/pact_broker/api/decorators/notices_decorator.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions lib/pact_broker/api/resources/after_reply.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 19 additions & 1 deletion lib/pact_broker/api/resources/pacticipant_branches.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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
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?
Expand All @@ -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
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions lib/pact_broker/async/after_reply.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions lib/pact_broker/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
33 changes: 33 additions & 0 deletions lib/pact_broker/versions/branch_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>] 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<String>] exclude the names of the branches to NOT delete
# @return [Array<PactBroker::Versions::Branch>]
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<String>] 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
15 changes: 13 additions & 2 deletions lib/pact_broker/versions/branch_service.rb
Original file line number Diff line number Diff line change
@@ -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<String>] exclude the list of branches to NOT delete
# @return [Array<PactBroker::Contracts::Notice>]
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
Expand Down
35 changes: 35 additions & 0 deletions spec/features/delete_pacticipant_branches_spec.rb
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions spec/lib/pact_broker/versions/branch_repository_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions spec/lib/pact_broker/versions/branch_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions spec/support/pact_broker/middleware/mock_puma.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/support/shared_context_for_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 14ac33c

Please sign in to comment.