diff --git a/app/controllers/merge_requests_controller.rb b/app/controllers/merge_requests_controller.rb index 95be8d875..f19224a92 100644 --- a/app/controllers/merge_requests_controller.rb +++ b/app/controllers/merge_requests_controller.rb @@ -61,6 +61,15 @@ class MergeRequestsController < ApplicationController @new_merging_org_ids = [] end + def start_merge + if @merge_request.status == "ready_to_merge" + @merge_request.update!(status: :processing) + ProcessMergeRequestJob.perform_later(merge_request: @merge_request) + end + + redirect_to merge_request_path(@merge_request) + end + private def page diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 8318b533a..4d7408349 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -6,16 +6,16 @@ module MergeRequestsHelper def request_details(merge_request) [ { label: "Requester", value: display_value_or_placeholder(merge_request.requester&.name) }, - { label: "Helpdesk ticket", value: merge_request.helpdesk_ticket.present? ? link_to("#{merge_request.helpdesk_ticket} (opens in a new tab)", "https://dluhcdigital.atlassian.net/browse/#{merge_request.helpdesk_ticket}", target: "_blank", rel: "noopener noreferrer") : display_value_or_placeholder(nil), action: merge_request.status == "request_merged" ? nil : { text: "Change", href: helpdesk_ticket_merge_request_path(merge_request, referrer: "check_answers"), visually_hidden_text: "helpdesk ticket" } }, + { label: "Helpdesk ticket", value: merge_request.helpdesk_ticket.present? ? link_to("#{merge_request.helpdesk_ticket} (opens in a new tab)", "https://dluhcdigital.atlassian.net/browse/#{merge_request.helpdesk_ticket}", target: "_blank", rel: "noopener noreferrer") : display_value_or_placeholder(nil), action: merge_request_action(merge_request, "helpdesk_ticket") }, { label: "Status", value: status_tag(merge_request.status) }, ] end def merge_details(merge_request) [ - { label: "Absorbing organisation", value: display_value_or_placeholder(merge_request.absorbing_organisation_name), action: merge_request.status == "request_merged" ? nil : { text: "Change", href: absorbing_organisation_merge_request_path(merge_request, referrer: "check_answers"), visually_hidden_text: "absorbing organisation" } }, - { label: "Merging organisations", value: merge_request.merge_request_organisations.any? ? merge_request.merge_request_organisations.map(&:merging_organisation_name).join("
").html_safe : display_value_or_placeholder(nil), action: merge_request.status == "request_merged" ? nil : { text: "Change", href: merging_organisations_merge_request_path(merge_request, referrer: "check_answers"), visually_hidden_text: "merging organisations" } }, - { label: "Merge date", value: display_value_or_placeholder(merge_request.merge_date), action: merge_request.status == "request_merged" ? nil : { text: "Change", href: merge_date_merge_request_path(merge_request, referrer: "check_answers"), visually_hidden_text: "merge date" } }, + { label: "Absorbing organisation", value: display_value_or_placeholder(merge_request.absorbing_organisation_name), action: merge_request_action(merge_request, "absorbing_organisation") }, + { label: "Merging organisations", value: merge_request.merge_request_organisations.any? ? merge_request.merge_request_organisations.map(&:merging_organisation_name).join("
").html_safe : display_value_or_placeholder(nil), action: merge_request_action(merge_request, "merging_organisations") }, + { label: "Merge date", value: display_value_or_placeholder(merge_request.merge_date), action: merge_request_action(merge_request, "merge_date") }, ] end @@ -68,4 +68,10 @@ module MergeRequestsHelper merge_date_merge_request_path(merge_request) end end + + def merge_request_action(merge_request, page) + unless merge_request.status == "request_merged" || merge_request.status == "processing" + { text: "Change", href: send("#{page}_merge_request_path", merge_request, referrer: "check_answers"), visually_hidden_text: page.humanize } + end + end end diff --git a/app/jobs/process_merge_request_job.rb b/app/jobs/process_merge_request_job.rb new file mode 100644 index 000000000..b706d9a81 --- /dev/null +++ b/app/jobs/process_merge_request_job.rb @@ -0,0 +1,14 @@ +class ProcessMergeRequestJob < ApplicationJob + queue_as :default + + def perform(merge_request:) + absorbing_organisation_id = merge_request.absorbing_organisation_id + merging_organisation_ids = merge_request.merging_organisations.pluck(:id) + merge_date = merge_request.merge_date + + Merge::MergeOrganisationsService.new(absorbing_organisation_id:, merging_organisation_ids:, merge_date:).call + merge_request.update!(status: "request_merged", last_failed_attempt: nil) + rescue StandardError + merge_request.update!(last_failed_attempt: Time.zone.now) + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7c95e199c..078399c2a 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -4,6 +4,7 @@ class MergeRequest < ApplicationRecord belongs_to :absorbing_organisation, class_name: "Organisation", optional: true has_many :merging_organisations, through: :merge_request_organisations, source: :merging_organisation belongs_to :requester, class_name: "User", optional: true + before_save :update_status! STATUS = { "merge_issues" => 0, @@ -11,6 +12,7 @@ class MergeRequest < ApplicationRecord "ready_to_merge" => 2, "processing" => 3, "request_merged" => 4, + "deleted" => 5, }.freeze enum status: STATUS @@ -21,6 +23,8 @@ class MergeRequest < ApplicationRecord merged.where("merge_requests.merge_date >= ?", open_collection_period_start_date).or(not_merged).where(discarded_at: nil) } + attr_accessor :skip_update_status + def absorbing_organisation_name absorbing_organisation&.name || "" end @@ -32,4 +36,31 @@ class MergeRequest < ApplicationRecord def discard! update!(discarded_at: Time.zone.now) end + + def update_status! + return if skip_update_status + + self.status = calculate_status + end + + def calculate_status + return "deleted" if discarded_at.present? + return "request_merged" if status == "request_merged" + return "processing" if status == "processing" + return "incomplete" unless required_questions_answered? + return "ready_to_merge" if absorbing_organisation_signed_dsa? + + "merge_issues" + end + + def required_questions_answered? + absorbing_organisation_id.present? && + merge_date.present? && + merging_organisations.count.positive? && + errors.empty? + end + + def absorbing_organisation_signed_dsa? + absorbing_organisation&.data_protection_confirmed? + end end diff --git a/app/models/merge_request_organisation.rb b/app/models/merge_request_organisation.rb index e181ca304..18a77d93a 100644 --- a/app/models/merge_request_organisation.rb +++ b/app/models/merge_request_organisation.rb @@ -8,12 +8,19 @@ class MergeRequestOrganisation < ApplicationRecord scope :merged, -> { joins(:merge_request).where(merge_requests: { status: "request_merged" }) } scope :with_merging_organisation, ->(merging_organisation) { where(merging_organisation:) } + after_save :update_merge_request_status + has_paper_trail def merging_organisation_name merging_organisation.name || "" end + def update_merge_request_status + merge_request.update_status! + merge_request.save! + end + private def validate_merging_organisations diff --git a/app/views/merge_requests/_notification_banners.html.erb b/app/views/merge_requests/_notification_banners.html.erb new file mode 100644 index 000000000..7674773ab --- /dev/null +++ b/app/views/merge_requests/_notification_banners.html.erb @@ -0,0 +1,21 @@ +<% unless @merge_request.absorbing_organisation_signed_dsa? || @merge_request.absorbing_organisation_id.blank? %> + <%= govuk_notification_banner(title_text: "Important") do %> +

+ The absorbing organisation must accept the Data Sharing Agreement before merging. +

+ <% if @merge_request.dpo_user %> + Contact the Data Protection Officer: <%= link_to @merge_request.dpo_user.name, user_path(@merge_request.dpo_user.id) %> + <% else %> + <%= @merge_request.absorbing_organisation_name %> does not have a Data Protection Officer. You can assign one on the <%= link_to "users page", "#{organisation_path(@merge_request.absorbing_organisation_id)}/users" %>. + <% end %> + <% end %> +<% end %> + +<% if @merge_request.last_failed_attempt.present? %> + <%= govuk_notification_banner(title_text: "Important") do %> +

+ An error occurred while processing the merge. +

+ No changes have been made. Try beginning the merge again. + <% end %> +<% end %> diff --git a/app/views/merge_requests/show.html.erb b/app/views/merge_requests/show.html.erb index ce4fbb38d..c93e99738 100644 --- a/app/views/merge_requests/show.html.erb +++ b/app/views/merge_requests/show.html.erb @@ -3,31 +3,18 @@ <% content_for :title, title %> <%= govuk_back_link href: organisations_path(anchor: "merge-requests") %> <% end %> -<% unless @merge_request.signed_dsa || @merge_request.absorbing_organisation_id.blank? %> -

-
-

- Important -

-
-
- The absorbing organisation must accept the Data Sharing Agreement before merging. -
- <% if @merge_request.dpo_user %> - Contact the Data Protection Officer: <%= link_to @merge_request.dpo_user.name, user_path(@merge_request.dpo_user.id) %> - <% else %> - <%= @merge_request.absorbing_organisation_name %> does not have a Data Protection Officer. You can assign one on the <%= link_to "users page", "#{organisation_path(@merge_request.absorbing_organisation_id)}/users" %>. - <% end %> -
-
-<% end %> + +<%= render partial: "notification_banners" %> +

Merge details <%= display_value_or_placeholder(@merge_request.absorbing_organisation_name) %>

<% unless @merge_request.status == "request_merged" %>
- <%= govuk_button_link_to "Begin merge", "#", disabled: @merge_request.status != "ready_to_merge" %> + <%= form_with model: @merge_request, url: start_merge_merge_request_path(@merge_request) do |f| %> + <%= f.govuk_submit "Begin merge", disabled: @merge_request.status != "ready_to_merge" %> + <% end %> <%= govuk_button_link_to "Delete merge request", delete_confirmation_merge_request_path(@merge_request), warning: true %>
<% end %> diff --git a/config/routes.rb b/config/routes.rb index ecdb8f2c1..ebbb588ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -203,6 +203,7 @@ Rails.application.routes.draw do get "helpdesk-ticket" get "delete-confirmation", to: "merge_requests#delete_confirmation" delete "delete", to: "merge_requests#delete" + patch "start-merge", to: "merge_requests#start_merge" end end diff --git a/db/migrate/20240814083017_add_last_failed_attempt.rb b/db/migrate/20240814083017_add_last_failed_attempt.rb new file mode 100644 index 000000000..34e0e4046 --- /dev/null +++ b/db/migrate/20240814083017_add_last_failed_attempt.rb @@ -0,0 +1,5 @@ +class AddLastFailedAttempt < ActiveRecord::Migration[7.0] + def change + add_column :merge_requests, :last_failed_attempt, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d728a72d..9f46a7ae4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_08_13_112119) do +ActiveRecord::Schema[7.0].define(version: 2024_08_14_083017) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -432,6 +432,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_08_13_112119) do t.integer "total_managing_agents" t.boolean "signed_dsa", default: false t.datetime "discarded_at" + t.datetime "last_failed_attempt" end create_table "notifications", force: :cascade do |t| diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_request.rb similarity index 100% rename from spec/factories/merge_requests.rb rename to spec/factories/merge_request.rb diff --git a/spec/factories/merge_request_organisation.rb b/spec/factories/merge_request_organisation.rb new file mode 100644 index 000000000..178401fb0 --- /dev/null +++ b/spec/factories/merge_request_organisation.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :merge_request_organisation do + association :merging_organisation, factory: :organisation + association :merge_request, factory: :merge_request + end +end diff --git a/spec/jobs/process_merge_request_job_spec.rb b/spec/jobs/process_merge_request_job_spec.rb new file mode 100644 index 000000000..0c4ae30f1 --- /dev/null +++ b/spec/jobs/process_merge_request_job_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +describe ProcessMergeRequestJob do + let(:job) { described_class.new } + let(:merge_organisations_service) { instance_double(Merge::MergeOrganisationsService) } + + before do + allow(Merge::MergeOrganisationsService).to receive(:new).and_return(merge_organisations_service) + allow(merge_organisations_service).to receive(:call).and_return(nil) + end + + context "when processing a merge request" do + let(:organisation) { create(:organisation) } + let(:merging_organisation) { create(:organisation) } + let(:other_merging_organisation) { create(:organisation) } + let(:merge_request) { MergeRequest.create!(requesting_organisation: organisation, absorbing_organisation: organisation, merge_date: Time.zone.local(2022, 3, 3)) } + + before do + create(:merge_request_organisation, merge_request:, merging_organisation:) + create(:merge_request_organisation, merge_request:, merging_organisation: other_merging_organisation) + end + + it "calls the merge organisations service with correct arguments" do + expect(Merge::MergeOrganisationsService).to receive(:new).with(absorbing_organisation_id: organisation.id, merging_organisation_ids: [merging_organisation.id, other_merging_organisation.id], merge_date: Time.zone.local(2022, 3, 3)) + + job.perform(merge_request:) + expect(merge_request.reload.status).to eq("request_merged") + end + + it "clears last_failed_attempt value" do + merge_request.update!(last_failed_attempt: Time.zone.now) + job.perform(merge_request:) + + expect(merge_request.reload.last_failed_attempt).to be_nil + end + + it "sets last_failed_attempt value if there's an error" do + allow(merge_organisations_service).to receive(:call).and_raise(ActiveRecord::Rollback) + + expect(merge_request.last_failed_attempt).to be_nil + job.perform(merge_request:) + + merge_request.reload + expect(merge_request.last_failed_attempt).to be_within(10.seconds).of(Time.zone.now) + end + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index cf38d5033..6ada68985 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -42,4 +42,51 @@ RSpec.describe MergeRequest, type: :model do expect(described_class.visible).not_to include(merge_request) end end + + describe "#calculate_status" do + it "returns the correct status for deleted merge request" do + merge_request = build(:merge_request, id: 1, discarded_at: Time.zone.today) + expect(merge_request.calculate_status).to eq "deleted" + end + + it "returns the correct status for a merged request" do + merge_request = build(:merge_request, id: 1, status: "request_merged") + expect(merge_request.calculate_status).to eq "request_merged" + end + + it "returns the correct status for a ready to merge request" do + merge_request = build(:merge_request, id: 1, absorbing_organisation: create(:organisation), merge_date: Time.zone.today) + create(:merge_request_organisation, merge_request:) + expect(merge_request.calculate_status).to eq "ready_to_merge" + end + + it "returns the merge issues if dsa is not signed for absorbing organisation" do + merge_request = build(:merge_request, id: 1, absorbing_organisation: create(:organisation, with_dsa: false), merge_date: Time.zone.today) + create(:merge_request_organisation, merge_request:) + expect(merge_request.calculate_status).to eq "merge_issues" + end + + it "returns the incomplete if absorbing organisation is missing" do + merge_request = build(:merge_request, id: 1, absorbing_organisation: nil, merge_date: Time.zone.today) + create(:merge_request_organisation, merge_request:) + expect(merge_request.calculate_status).to eq "incomplete" + end + + it "returns the incomplete if merge requests organisation is missing" do + merge_request = build(:merge_request, id: 1, absorbing_organisation: create(:organisation), merge_date: Time.zone.today) + expect(merge_request.calculate_status).to eq "incomplete" + end + + it "returns the incomplete if merge date is missing" do + merge_request = build(:merge_request, id: 1, absorbing_organisation: create(:organisation)) + create(:merge_request_organisation, merge_request:) + expect(merge_request.calculate_status).to eq "incomplete" + end + + it "returns processing if merge is processing" do + merge_request = build(:merge_request, id: 1, absorbing_organisation: create(:organisation), status: "processing") + create(:merge_request_organisation, merge_request:) + expect(merge_request.calculate_status).to eq "processing" + end + end end diff --git a/spec/requests/merge_requests_controller_spec.rb b/spec/requests/merge_requests_controller_spec.rb index 865b75ba1..27cace08e 100644 --- a/spec/requests/merge_requests_controller_spec.rb +++ b/spec/requests/merge_requests_controller_spec.rb @@ -364,6 +364,62 @@ RSpec.describe MergeRequestsController, type: :request do end end end + + describe "#start_merge" do + let(:merge_request) { MergeRequest.create!(requesting_organisation: organisation, absorbing_organisation: organisation, merge_date: Time.zone.local(2022, 3, 3)) } + let(:merging_organisation) { create(:organisation, name: "Merging Test Org") } + + before do + allow(ProcessMergeRequestJob).to receive(:perform_later).and_return(nil) + end + + context "when merge request is ready to merge" do + before do + create(:merge_request_organisation, merge_request:, merging_organisation: other_organisation) + create(:merge_request_organisation, merge_request:, merging_organisation:) + end + + it "runs the job with correct merge request" do + expect(merge_request.reload.status).to eq("ready_to_merge") + expect(ProcessMergeRequestJob).to receive(:perform_later).with(merge_request:).once + patch "/merge-request/#{merge_request.id}/start-merge" + expect(merge_request.reload.status).to eq("processing") + end + end + + context "when merge request is not ready to merge" do + it "does not run the job" do + expect(merge_request.status).to eq("incomplete") + expect(ProcessMergeRequestJob).not_to receive(:perform_later).with(merge_request:) + patch "/merge-request/#{merge_request.id}/start-merge" + expect(merge_request.reload.status).to eq("incomplete") + end + end + end + + describe "#show" do + before do + get "/merge-request/#{merge_request.id}", headers: + end + + context "when request has previously failed" do + let(:merge_request) { create(:merge_request, last_failed_attempt: Time.zone.yesterday) } + + it "shows a banner" do + expect(page).to have_content("An error occurred while processing the merge.") + expect(page).to have_content("No changes have been made. Try beginning the merge again.") + end + end + + context "when request has not previously failed" do + let(:merge_request) { create(:merge_request, last_failed_attempt: nil) } + + it "does not show a banner" do + expect(page).not_to have_content("An error occurred while processing the merge.") + expect(page).not_to have_content("No changes have been made. Try beginning the merge again.") + end + end + end end context "when user is signed in with a data coordinator user" do diff --git a/spec/views/merge_requests/show.html.erb_spec.rb b/spec/views/merge_requests/show.html.erb_spec.rb index 72974fc9e..0e52e3f58 100644 --- a/spec/views/merge_requests/show.html.erb_spec.rb +++ b/spec/views/merge_requests/show.html.erb_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "merge_requests/show.html.erb", type: :view do - let(:absorbing_organisation) { create(:organisation, name: "Absorbing Org") } + let(:absorbing_organisation) { create(:organisation, name: "Absorbing Org", with_dsa: false) } let(:dpo_user) { create(:user, name: "DPO User", is_dpo: true, organisation: absorbing_organisation) } let(:merge_request) { create(:merge_request, absorbing_organisation_id: absorbing_organisation.id, signed_dsa: false, status: 1) }