From 0e35aed745501b6927737bf6f91813a972fb4011 Mon Sep 17 00:00:00 2001
From: charlie ablett <cablett@gitlab.com>
Date: Thu, 17 Jun 2021 22:16:04 +1200
Subject: [PATCH] Requirement migration: Sync title and description changes

Between Requirements and Requirement Issues

Wrap updates in pseudo-transaction
Add validation flags when mirrored object
not valid

Changelog: added
EE: true
---
 .../issue_widgets/acts_like_requirement.rb    |  18 +++
 .../requirements_management/requirement.rb    |  24 +++
 ee/app/services/ee/issues/update_service.rb   |  52 +++++++
 .../update_requirement_service.rb             |  34 ++++-
 .../services/ee/issues/update_service_spec.rb | 144 ++++++++++++++++++
 .../update_requirement_service_spec.rb        | 128 +++++++++++++++-
 6 files changed, 397 insertions(+), 3 deletions(-)

diff --git a/ee/app/models/concerns/issue_widgets/acts_like_requirement.rb b/ee/app/models/concerns/issue_widgets/acts_like_requirement.rb
index ef3adc2b2af9dd..78d1ed7567b82d 100644
--- a/ee/app/models/concerns/issue_widgets/acts_like_requirement.rb
+++ b/ee/app/models/concerns/issue_widgets/acts_like_requirement.rb
@@ -5,10 +5,28 @@ module ActsLikeRequirement
     extend ActiveSupport::Concern
 
     included do
+      attr_accessor :requirement_sync_error
+
+      after_validation :invalidate_if_sync_error, on: [:update]
+
       # This will mean that non-Requirement issues essentially ignore this relationship and always return []
       has_many :test_reports, -> { joins(:requirement_issue).where(issues: { issue_type: WorkItem::Type.base_types[:requirement] }) },
                foreign_key: :issue_id, inverse_of: :requirement_issue, class_name: 'RequirementsManagement::TestReport'
       has_one :requirement, class_name: 'RequirementsManagement::Requirement'
     end
+
+    def requirement_sync_error!
+      self.requirement_sync_error = true
+    end
+
+    def invalidate_if_sync_error
+      return unless requirement_sync_error
+      return unless requirement
+
+      # Mirror errors from requirement so that users can adjust accordingly
+      errors = requirement.errors.full_messages.to_sentence
+      errors = errors.presence || "Associated requirement was invalid and changes could not be applied."
+      self.errors.add(:base, errors)
+    end
   end
 end
diff --git a/ee/app/models/requirements_management/requirement.rb b/ee/app/models/requirements_management/requirement.rb
index c15040091b851c..3fef609de323f0 100644
--- a/ee/app/models/requirements_management/requirement.rb
+++ b/ee/app/models/requirements_management/requirement.rb
@@ -40,6 +40,8 @@ class Requirement < ApplicationRecord
 
     validate :only_requirement_type_issue
 
+    after_validation :invalidate_if_sync_error, on: [:update]
+
     enum state: { opened: 1, archived: 2 }
 
     scope :for_iid, -> (iid) { where(iid: iid) }
@@ -82,6 +84,10 @@ def search(query)
       def simple_sorts
         super.except('name_asc', 'name_desc')
       end
+
+      def sync_params
+        [:title, :description]
+      end
     end
 
     # In the next iteration we will support also group-level requirements
@@ -105,5 +111,23 @@ def last_test_report_manually_created?
     def only_requirement_type_issue
       errors.add(:requirement_issue, "must be a `requirement`. You cannot associate a Requirement with an issue of type #{requirement_issue.issue_type}.") if requirement_issue && !requirement_issue.requirement? && will_save_change_to_issue_id?
     end
+
+    def requirement_issue_sync_error!
+      self.requirement_issue_sync_error = true
+    end
+
+    private
+
+    attr_accessor :requirement_issue_sync_error
+
+    def invalidate_if_sync_error
+      return unless requirement_issue_sync_error
+      return unless requirement_issue
+
+      # Mirror errors from requirement issue so that users can adjust accordingly
+      errors = requirement_issue.errors.full_messages.to_sentence
+      errors = errors.presence || "Associated issue was invalid and changes could not be applied."
+      self.errors.add(:base, errors)
+    end
   end
 end
diff --git a/ee/app/services/ee/issues/update_service.rb b/ee/app/services/ee/issues/update_service.rb
index 586f6e3667b8e3..6350a0e0c3b7f6 100644
--- a/ee/app/services/ee/issues/update_service.rb
+++ b/ee/app/services/ee/issues/update_service.rb
@@ -8,6 +8,7 @@ module UpdateService
 
       override :filter_params
       def filter_params(issue)
+        params.delete(:skip_auth)
         params.delete(:sprint_id) unless can_admin_issuable?(issue)
 
         filter_epic(issue)
@@ -31,6 +32,11 @@ def execute(issue)
         result
       end
 
+      override :can_admin_issuable?
+      def can_admin_issuable?(issuable)
+        skip_auth || super
+      end
+
       override :handle_changes
       def handle_changes(issue, _options)
         super
@@ -38,8 +44,43 @@ def handle_changes(issue, _options)
         handle_iteration_change(issue)
       end
 
+      override :before_update
+      def before_update(issue, **args)
+        super
+
+        # This is part of the migration from Requirement (the first class object)
+        # to Issue/Work Item (of type Requirement).
+        # We can't wrap the change in a Transaction (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64929#note_647123684)
+        # so we'll check if both are valid before saving
+
+        # Don't bother syncing if it's possibly spam
+        return if issue.spam? || !issue.requirement
+
+        # Keep requirement objects in sync: gitlab-org/gitlab#323779
+        self.requirement_to_sync = prepare_requirement_for_sync(issue)
+        return unless requirement_to_sync
+
+        # This prevents the issue from being saveable
+        issue.requirement_sync_error! unless requirement_to_sync.valid?
+      end
+
+      override :after_update
+      def after_update(_issue)
+        super
+
+        return unless requirement_to_sync
+
+        unless requirement_to_sync.save
+          # We checked that it was valid earlier but it still did not save. Uh oh.
+          # This requires a manual re-sync and an investigation as to why this happened.
+          ::Gitlab::AppLogger.info(message: "Requirement-Issue Sync: Associated requirement could not be saved", project_id: project.id, user_id: current_user.id, params: params)
+        end
+      end
+
       private
 
+      attr_accessor :skip_auth, :requirement_to_sync
+
       def handle_iteration_change(issue)
         return unless issue.previous_changes.include?('sprint_id')
 
@@ -66,6 +107,17 @@ def handle_promotion(issue)
 
         Epics::IssuePromoteService.new(project: issue.project, current_user: current_user).execute(issue)
       end
+
+      def prepare_requirement_for_sync(issue)
+        sync_params = RequirementsManagement::Requirement.sync_params
+        sync_attrs = issue.attributes.with_indifferent_access.slice(*sync_params)
+
+        # Update the requirement manually rather than through RequirementsManagement::Requirement::UpdateService,
+        # so the sync happens even if the Requirements feature is no longer available via the license.
+        requirement = issue.requirement
+        requirement.assign_attributes(sync_attrs)
+        requirement if requirement.changed?
+      end
     end
   end
 end
diff --git a/ee/app/services/requirements_management/update_requirement_service.rb b/ee/app/services/requirements_management/update_requirement_service.rb
index 48d340927bacc5..6667e9b77e0c8d 100644
--- a/ee/app/services/requirements_management/update_requirement_service.rb
+++ b/ee/app/services/requirements_management/update_requirement_service.rb
@@ -6,8 +6,23 @@ def execute(requirement)
       raise Gitlab::Access::AccessDeniedError unless can?(current_user, :update_requirement, project)
 
       attrs = whitelisted_requirement_params
-      requirement.update(attrs)
 
+      requirement.assign_attributes(attrs)
+
+      # This is part of the migration from Requirement (the first class object)
+      # to Issue/Work Item (of type Requirement).
+      # We can't wrap the change in a Transaction (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64929#note_647123684)
+      # so we'll check if both are valid before saving
+      if requirement.valid? && requirement.requirement_issue
+        updated_issue = sync_with_requirement_issue(requirement)
+
+        if updated_issue&.invalid?
+          requirement.requirement_issue_sync_error!
+          ::Gitlab::AppLogger.info(message: "Requirement-Issue Sync: Associated issue could not be saved", project_id: project.id, user_id: current_user.id, params: params)
+        end
+      end
+
+      requirement.save
       create_test_report_for(requirement) if manually_create_test_report?
 
       requirement
@@ -28,5 +43,22 @@ def create_test_report_for(requirement)
     def whitelisted_requirement_params
       params.slice(:title, :description, :state)
     end
+
+    def sync_with_requirement_issue(requirement)
+      sync_params = RequirementsManagement::Requirement.sync_params
+      changed_attrs = requirement.changed.map(&:to_sym) & sync_params
+
+      return unless changed_attrs.any?
+
+      sync_attrs = requirement.attributes.with_indifferent_access.slice(*changed_attrs)
+
+      requirement_issue = requirement.requirement_issue
+
+      # Skip authorisation so we don't risk a permissions mismatch while still getting the advantages
+      # of the service, such as system notes.
+      params = sync_attrs.merge(skip_auth: true)
+      ::Issues::UpdateService.new(project: project, current_user: current_user, params: params)
+        .execute(requirement_issue)
+    end
   end
 end
diff --git a/ee/spec/services/ee/issues/update_service_spec.rb b/ee/spec/services/ee/issues/update_service_spec.rb
index 72f90a835d68c7..852841437e06e1 100644
--- a/ee/spec/services/ee/issues/update_service_spec.rb
+++ b/ee/spec/services/ee/issues/update_service_spec.rb
@@ -523,5 +523,149 @@ def update_issue(opts)
         include_examples 'no trigger status page publish'
       end
     end
+
+    describe 'sync Requirement work item with Requirement object' do
+      let_it_be(:title) { 'title' }
+      let_it_be(:description) { 'description' }
+      let_it_be(:new_title) { 'new title' }
+      let_it_be(:new_description) { 'new description' }
+
+      let_it_be_with_reload(:issue) { create(:issue, issue_type: :requirement, project: project, title: title, description: description) }
+
+      let(:params) do
+        { state: :closed, title: new_title, description: new_description }
+      end
+
+      subject { update_issue(params) }
+
+      shared_examples 'keeps issue and its requirement in sync' do
+        it 'keeps title and description in sync' do
+          subject
+
+          requirement.reload
+          issue.requirement.reload
+
+          expect(requirement).to have_attributes(
+            title: issue.requirement.title,
+            description: issue.requirement.description)
+        end
+      end
+
+      shared_examples 'does not persist any changes' do
+        it 'does not update the issue' do
+          expect { subject }.not_to change { issue.reload.attributes }
+        end
+
+        it 'does not update the requirement' do
+          expect { subject }.not_to change { requirement.reload.attributes }
+        end
+      end
+
+      before do
+        issue.requirement_sync_error = false
+        issue.clear_spam_flags!
+      end
+
+      context 'if there is an associated requirement' do
+        let_it_be_with_reload(:requirement) { create(:requirement, title: title, description: description, requirement_issue: issue, project: project) }
+
+        it 'does not update the unrelated field' do
+          expect { subject }.not_to change { requirement.reload.state }
+        end
+
+        it 'updates the synced requirement with title and/or description' do
+          subject
+
+          expect(requirement.reload).to have_attributes(
+            title: new_title,
+            description: new_description)
+        end
+
+        context 'when the issue title is very long' do
+          # The Requirement title limit is 255 in the application as well as the DB.
+          # The Issue limit is 255 in the application not the DB, so we should be OK.
+          # See https://gitlab.com/gitlab-org/gitlab/blob/7a42426/app/models/concerns/issuable.rb#L30
+          let_it_be(:new_title) { 'a' * 300 }
+
+          it_behaves_like 'does not persist any changes'
+        end
+
+        it_behaves_like 'keeps issue and its requirement in sync'
+
+        context 'if update of issue fails' do
+          let(:params) do
+            { title: nil }
+          end
+
+          it_behaves_like 'keeps issue and its requirement in sync'
+          it_behaves_like 'does not persist any changes'
+        end
+
+        context 'if update of issue succeeds but update of requirement fails' do
+          let(:params) do
+            { title: 'some magically valid title for issue but not requirement' }
+          end
+
+          before do
+            allow(issue).to receive(:requirement).and_return(requirement)
+          end
+
+          context 'when requirement is not valid' do
+            before do
+              expect(requirement).to receive(:valid?).and_return(false).at_least(:once)
+            end
+
+            it_behaves_like 'keeps issue and its requirement in sync'
+            it_behaves_like 'does not persist any changes'
+
+            it 'adds an informative sync error to issue' do
+              subject
+
+              expect(issue.errors[:base]).to include(/Associated requirement/)
+            end
+          end
+
+          context 'if requirement is valid but still does not save' do
+            before do
+              allow(issue.requirement).to receive(:valid?).and_return(true).at_least(:once)
+              allow(requirement).to receive(:save).and_return(false)
+            end
+
+            it 'adds a helpful log' do
+              expect(::Gitlab::AppLogger).to receive(:info).with(a_hash_including(message: /Associated requirement/))
+
+              subject
+            end
+          end
+        end
+
+        # spam checking also uses a flag on Issue so we want to ensure they play nicely together
+        # If an issue is marked spam, we do not want to try syncing
+        # since the issue will not be saved this time (maybe next time if a successful recaptcha has been included)
+        context 'if the issue is also marked as spam' do
+          before do
+            issue.spam!
+          end
+
+          it_behaves_like 'keeps issue and its requirement in sync'
+          it_behaves_like 'does not persist any changes'
+
+          it 'only shows the spam error' do
+            subject
+
+            expect(issue.errors[:base]).to include(/spam/)
+            expect(issue.errors[:base]).not_to include(/sync/)
+          end
+        end
+      end
+
+      context 'if there is no associated requirement' do
+        it 'does not call the RequirementsManagement::UpdateRequirementService' do
+          expect(RequirementsManagement::UpdateRequirementService).not_to receive(:new)
+
+          update_issue(params)
+        end
+      end
+    end
   end
 end
diff --git a/ee/spec/services/requirements_management/update_requirement_service_spec.rb b/ee/spec/services/requirements_management/update_requirement_service_spec.rb
index d7bd439a21d23d..10ff487c4f312d 100644
--- a/ee/spec/services/requirements_management/update_requirement_service_spec.rb
+++ b/ee/spec/services/requirements_management/update_requirement_service_spec.rb
@@ -3,13 +3,20 @@
 require 'spec_helper'
 
 RSpec.describe RequirementsManagement::UpdateRequirementService do
+  let_it_be(:title) { 'title' }
+  let_it_be(:description) { 'description' }
+
+  let(:new_title) { 'new title' }
+  let(:new_description) { 'new description' }
+
   let_it_be(:project) { create(:project)}
   let_it_be(:user) { create(:user) }
-  let_it_be(:requirement) { create(:requirement, project: project) }
+  let_it_be_with_reload(:requirement) { create(:requirement, project: project, title: title, description: description) }
 
   let(:params) do
     {
-      title: 'foo',
+      title: new_title,
+      description: new_description,
       state: 'archived',
       created_at: 2.days.ago,
       author_id: create(:user).id
@@ -40,6 +47,123 @@
         )
       end
 
+      context 'when updating title or description' do
+        shared_examples 'keeps requirement and its requirement_issue in sync' do
+          it 'keeps title and description in sync' do
+            subject
+
+            requirement.reload
+            requirement.requirement_issue.reload
+
+            expect(requirement).to have_attributes(
+              title: requirement.requirement_issue.title,
+              description: requirement.requirement_issue.description)
+          end
+        end
+
+        shared_examples 'does not persist any changes' do
+          it 'does not update the requirement' do
+            expect { subject }.not_to change { requirement.reload.attributes }
+          end
+
+          it 'does not update the requirement issue' do
+            expect { subject }.not_to change { requirement_issue.reload.attributes }
+          end
+        end
+
+        context 'if there is an associated requirement_issue' do
+          let_it_be_with_reload(:requirement_issue) { create(:requirement_issue, requirement: requirement, title: title, description: description) }
+
+          let(:params) do
+            { title: new_title, description: new_description }
+          end
+
+          it 'updates the synced requirement_issue with title and description' do
+            expect { subject }
+              .to change { requirement.requirement_issue.description }.from(description).to(new_description)
+              .and change { requirement.requirement_issue.title }.from(title).to(new_title)
+          end
+
+          context 'when updating only title' do
+            let(:params) do
+              { title: new_title }
+            end
+
+            it "updates requirement's title" do
+              expect { subject }.to change { requirement.requirement_issue.reload.title }.from(title).to(new_title)
+            end
+
+            it_behaves_like 'keeps requirement and its requirement_issue in sync'
+          end
+
+          context "updates requirement's description" do
+            let(:params) do
+              { description: new_description }
+            end
+
+            it 'updates description' do
+              expect { subject }.to change { requirement.requirement_issue.reload.description }.from(description).to(new_description)
+            end
+
+            it_behaves_like 'keeps requirement and its requirement_issue in sync'
+          end
+
+          context 'if update fails' do
+            let(:params) do
+              { title: nil }
+            end
+
+            it_behaves_like 'does not persist any changes'
+            it_behaves_like 'keeps requirement and its requirement_issue in sync'
+
+            context 'if update of requirement succeeds but update of issue fails' do
+              let(:params) do
+                { title: 'some magically valid title for requirement but not issue' }
+              end
+
+              before do
+                allow_next_instance_of(::Issues::UpdateService) do |service|
+                  allow(service).to receive(:execute).and_return(requirement_issue)
+                end
+
+                allow(requirement_issue).to receive(:invalid?).and_return(true).at_least(:once)
+              end
+
+              it_behaves_like 'keeps requirement and its requirement_issue in sync'
+              it_behaves_like 'does not persist any changes'
+
+              it 'adds an informative sync error to issue' do
+                expect(::Gitlab::AppLogger).to receive(:info).with(a_hash_including(message: /Associated issue/))
+
+                subject
+
+                expect(requirement.errors[:base]).to include(/Associated issue/)
+              end
+            end
+          end
+
+          context 'when updating some unrelated field' do
+            let(:params) do
+              { state: :archived }
+            end
+
+            it 'does not update the associated requirement issue' do
+              expect { subject }.not_to change { requirement.requirement_issue.state }
+            end
+
+            it_behaves_like 'keeps requirement and its requirement_issue in sync'
+          end
+        end
+
+        context 'if there is no requirement_issue' do
+          it 'does not call the Issues::UpdateService' do
+            expect(Issues::UpdateService).not_to receive(:new)
+
+            subject
+          end
+        end
+      end
+
       context 'when updating last test report state' do
         context 'as passing' do
           it 'creates passing test report with null build_id' do
-- 
GitLab