Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bulk delete branches #652

Merged
merged 3 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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