From ebffd39ec360b3baa1c7b0a49b950d8f52d7f8af Mon Sep 17 00:00:00 2001
From: Alexandru Croitor <acroitor@gitlab.com>
Date: Fri, 28 Jun 2024 14:58:53 +0000
Subject: [PATCH] Add description version unification for epic and epic work
 item

This allows handling epic and epic work item description_versions
transparently between epic and its corresponding epic work item.

This will also allow us to easier unify epic and epic work item
system notes related todescription version changes and later on
drop legacy epic support while keeping the data intergrity.

re https://gitlab.com/gitlab-org/gitlab/-/issues/443540
---
 app/models/concerns/versioned_description.rb  |   7 +-
 app/models/description_version.rb             |   2 +
 app/models/note.rb                            |   7 +-
 .../ee/types/description_version_type.rb      |   7 +-
 .../concerns/work_items/epic_as_work_item.rb  |   5 +
 .../description_versions.rb                   |  43 ++++++
 ee/app/models/ee/description_version.rb       |  12 +-
 ee/app/models/ee/group.rb                     |   4 +
 ee/app/models/ee/note.rb                      |   4 +
 ee/app/models/ee/work_item.rb                 |   5 -
 ee/app/policies/epic_policy.rb                |   1 +
 .../concerns/epics/sync_as_work_item.rb       |   4 +-
 .../concerns/work_items/sync_as_epic.rb       |   2 +-
 .../epic_and_work_item_notes_unification.yml  |   9 ++
 .../groups/epics_controller_spec.rb           |  14 ++
 .../groups/work_items_controller_spec.rb      |  12 ++
 .../projects/issues_controller_spec.rb        |   6 +
 .../merge_requests_controller_spec.rb         |   3 +
 ee/spec/models/ee/description_version_spec.rb |   3 +-
 ee/spec/models/epic_spec.rb                   |  89 +++++++++----
 ee/spec/models/work_item_spec.rb              |  11 +-
 ee/spec/policies/epic_policy_spec.rb          |  16 +--
 .../ee/issuable/destroy_service_spec.rb       | 124 +++++++++++++-----
 ee/spec/services/epics/update_service_spec.rb |   9 +-
 .../work_items/update_service_spec.rb         |  14 ++
 ...escription_diff_actions_shared_examples.rb |   4 -
 spec/lib/gitlab/import_export/all_models.yml  |   2 +
 27 files changed, 323 insertions(+), 96 deletions(-)
 create mode 100644 ee/app/models/concerns/work_items/unified_associations/description_versions.rb
 create mode 100644 ee/config/feature_flags/wip/epic_and_work_item_notes_unification.yml

diff --git a/app/models/concerns/versioned_description.rb b/app/models/concerns/versioned_description.rb
index dff7983477a2a..1270529fdfe56 100644
--- a/app/models/concerns/versioned_description.rb
+++ b/app/models/concerns/versioned_description.rb
@@ -5,10 +5,11 @@ module VersionedDescription
 
   included do
     attr_accessor :saved_description_version
+    attr_accessor :skip_description_version
 
     has_many :description_versions
 
-    after_update :save_description_version, unless: :skip_description_version?
+    after_update :save_description_version, unless: :skip_description_version
   end
 
   private
@@ -27,8 +28,4 @@ def save_description_version
 
     self.saved_description_version = description_versions.create!(description: description)
   end
-
-  def skip_description_version?
-    false
-  end
 end
diff --git a/app/models/description_version.rb b/app/models/description_version.rb
index 71c16621c9e24..54dea117a9c62 100644
--- a/app/models/description_version.rb
+++ b/app/models/description_version.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class DescriptionVersion < ApplicationRecord
+  include FromUnion
+
   belongs_to :issue
   belongs_to :merge_request
 
diff --git a/app/models/note.rb b/app/models/note.rb
index cb47449d29812..5d5a8e3071c77 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -133,8 +133,11 @@ class Note < ApplicationRecord
   scope :inc_note_diff_file, -> { includes(:note_diff_file) }
   scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
   scope :inc_relations_for_view, ->(noteable = nil) do
-    relations = [{ project: :group }, { author: :status }, :updated_by, :resolved_by,
-      :award_emoji, :note_metadata, { system_note_metadata: :description_version }, :suggestions]
+    relations = [
+      { project: :group }, { author: :status }, :updated_by, :resolved_by,
+      :award_emoji, :note_metadata, :suggestions,
+      { system_note_metadata: { description_version: [:issue, :merge_request] } }
+    ]
 
     if noteable.nil? || DiffNote.noteable_types.include?(noteable.class.name)
       relations += [:note_diff_file, :diff_note_positions]
diff --git a/ee/app/graphql/ee/types/description_version_type.rb b/ee/app/graphql/ee/types/description_version_type.rb
index 34b6495634bfb..c551e1cae472a 100644
--- a/ee/app/graphql/ee/types/description_version_type.rb
+++ b/ee/app/graphql/ee/types/description_version_type.rb
@@ -33,7 +33,6 @@ module DescriptionVersionType
           deprecated: { reason: DEPRECATION_REASON, milestone: '15.7' }
         field :deleted, GraphQL::Types::Boolean,
           null: true,
-          method: :deleted?,
           description: 'Whether description version associated to the note metadata is deleted.',
           deprecated: { reason: DEPRECATION_REASON, milestone: '15.7' }
 
@@ -53,7 +52,11 @@ def can_delete
           return unless object.issuable.present? && description_diff_available?
 
           rule = "admin_#{object.issuable.class.to_ability_name}"
-          Ability.allowed?(current_user, rule, object.issuable.resource_parent)
+          Ability.allowed?(current_user, rule, object.issuable)
+        end
+
+        def deleted
+          object.deleted_at?
         end
       end
 
diff --git a/ee/app/models/concerns/work_items/epic_as_work_item.rb b/ee/app/models/concerns/work_items/epic_as_work_item.rb
index 08dc4c4376693..b24566710f835 100644
--- a/ee/app/models/concerns/work_items/epic_as_work_item.rb
+++ b/ee/app/models/concerns/work_items/epic_as_work_item.rb
@@ -8,6 +8,7 @@ module EpicAsWorkItem
       include Gitlab::Utils::StrongMemoize
       include ::WorkItems::UnifiedAssociations::Labels
       include ::WorkItems::UnifiedAssociations::AwardEmoji
+      include ::WorkItems::UnifiedAssociations::DescriptionVersions
 
       # this overrides the scope in Issuable by removing the labels association from it as labels are now preloaded
       # by loading labels for epic and for epic work item
@@ -37,6 +38,10 @@ def unified_associations?
       def labels_unification_enabled?
         unified_associations? && container&.epic_and_work_item_labels_unification_enabled?
       end
+
+      def notes_unification_enabled?
+        unified_associations? && container&.epic_and_work_item_notes_unification_enabled?
+      end
     end
   end
 end
diff --git a/ee/app/models/concerns/work_items/unified_associations/description_versions.rb b/ee/app/models/concerns/work_items/unified_associations/description_versions.rb
new file mode 100644
index 0000000000000..a35db912c6732
--- /dev/null
+++ b/ee/app/models/concerns/work_items/unified_associations/description_versions.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module WorkItems
+  module UnifiedAssociations
+    module DescriptionVersions
+      extend ActiveSupport::Concern
+
+      included do
+        # rubocop:disable Rails/InverseOf -- this is temporary and polymorphic so no inverse for now
+        has_many :own_description_versions, class_name: 'DescriptionVersion',
+          foreign_key: "#{base_class.name.underscore}_id"
+        has_many :description_versions, foreign_key: "#{base_class.name.underscore}_id" do
+          def load_target
+            return super unless proxy_association.owner.notes_unification_enabled?
+
+            proxy_association.target = scope.to_a unless proxy_association.loaded?
+
+            proxy_association.loaded!
+            proxy_association.target
+          end
+
+          def scope
+            DescriptionVersion.from_union(
+              [
+                proxy_association.owner.sync_object&.own_description_versions || DescriptionVersion.none,
+                proxy_association.owner.own_description_versions
+              ],
+              remove_duplicates: true
+            )
+          end
+
+          def find(*args)
+            return super unless proxy_association.owner.notes_unification_enabled?
+            return super if block_given?
+
+            scope.find(*args)
+          end
+        end
+        # rubocop:enable Rails/InverseOf
+      end
+    end
+  end
+end
diff --git a/ee/app/models/ee/description_version.rb b/ee/app/models/ee/description_version.rb
index 131d9fc7d1cee..fb4a723001d39 100644
--- a/ee/app/models/ee/description_version.rb
+++ b/ee/app/models/ee/description_version.rb
@@ -6,6 +6,7 @@ module DescriptionVersion
 
     prepended do
       belongs_to :epic
+      belongs_to :work_item, class_name: 'WorkItem', foreign_key: :issue_id # rubocop:disable Rails/InverseOf -- temporary
 
       # This scope is using `deleted_at` column which is not indexed.
       # Prevent using it in not scoped contexts.
@@ -19,7 +20,7 @@ def issuable_attrs
     end
 
     def issuable
-      epic || super
+      epic || work_item || super
     end
 
     def previous_version
@@ -38,9 +39,10 @@ def delete!(start_id: nil)
       description_versions =
         issuable_description_versions.where('id BETWEEN ? AND ?', start_id, self.id)
 
-      description_versions.update_all(deleted_at: Time.current)
+      ::DescriptionVersion.id_in(description_versions).update_all(deleted_at: Time.current)
 
       issuable&.broadcast_notes_changed
+      issuable.sync_object.broadcast_notes_changed if issuable&.try(:sync_object).present?
     end
 
     def deleted?
@@ -50,11 +52,7 @@ def deleted?
     private
 
     def issuable_description_versions
-      self.class.where(
-        issue_id: issue_id,
-        merge_request_id: merge_request_id,
-        epic_id: epic_id
-      )
+      issuable.description_versions
     end
   end
 end
diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb
index d1aa81e192e35..282b24b32c2b5 100644
--- a/ee/app/models/ee/group.rb
+++ b/ee/app/models/ee/group.rb
@@ -265,6 +265,10 @@ def epic_and_work_item_associations_unification_enabled?
       ::Feature.enabled?(:epic_and_work_item_associations_unification, self, type: :wip)
     end
 
+    def epic_and_work_item_notes_unification_enabled?
+      ::Feature.enabled?(:epic_and_work_item_notes_unification, self, type: :wip)
+    end
+
     def epic_and_work_item_labels_unification_enabled?
       ::Feature.enabled?(:epic_and_work_item_labels_unification, self, type: :wip)
     end
diff --git a/ee/app/models/ee/note.rb b/ee/app/models/ee/note.rb
index f21f2132b7403..3190ab17c24ce 100644
--- a/ee/app/models/ee/note.rb
+++ b/ee/app/models/ee/note.rb
@@ -31,6 +31,10 @@ def use_separate_indices?
       def with_web_entity_associations
         super.preload(project: [:group, { namespace: :route }])
       end
+
+      def inc_relations_for_view(noteable = nil)
+        super.preload({ system_note_metadata: { description_version: [:epic] } })
+      end
     end
 
     def search_index
diff --git a/ee/app/models/ee/work_item.rb b/ee/app/models/ee/work_item.rb
index 8693ce58f8a1f..2329c5b7b2126 100644
--- a/ee/app/models/ee/work_item.rb
+++ b/ee/app/models/ee/work_item.rb
@@ -60,11 +60,6 @@ def average_progress_of_children
       (::WorkItems::Progress.where(work_item: children).sum(:progress).to_i / child_count).to_i
     end
 
-    override :skip_description_version?
-    def skip_description_version?
-      super || epic_work_item?
-    end
-
     override :skip_metrics?
     def skip_metrics?
       super || epic_work_item?
diff --git a/ee/app/policies/epic_policy.rb b/ee/app/policies/epic_policy.rb
index cb88eb6ca29de..5272b22e4dea7 100644
--- a/ee/app/policies/epic_policy.rb
+++ b/ee/app/policies/epic_policy.rb
@@ -37,6 +37,7 @@ class EpicPolicy < BasePolicy
     enable :read_epic_iid
     enable :read_note
     enable :read_issuable_participables
+    enable :read_issuable
   end
 
   rule { can?(:read_epic) & ~anonymous }.policy do
diff --git a/ee/app/services/concerns/epics/sync_as_work_item.rb b/ee/app/services/concerns/epics/sync_as_work_item.rb
index 2cb27fad6e520..3104b9fc034f3 100644
--- a/ee/app/services/concerns/epics/sync_as_work_item.rb
+++ b/ee/app/services/concerns/epics/sync_as_work_item.rb
@@ -56,7 +56,9 @@ def update_params(epic)
         .intersection(ALLOWED_PARAMS + %i[title_html description_html])
         .index_with { |attr| epic[attr] }
 
-      filtered_attributes.merge({ updated_by: epic.updated_by, updated_at: epic.updated_at })
+      filtered_attributes.merge(
+        { updated_by: epic.updated_by, updated_at: epic.updated_at, skip_description_version: true }
+      )
     end
 
     def sync_color(epic, work_item)
diff --git a/ee/app/services/concerns/work_items/sync_as_epic.rb b/ee/app/services/concerns/work_items/sync_as_epic.rb
index a01de234f4ff8..112b48774dcb4 100644
--- a/ee/app/services/concerns/work_items/sync_as_epic.rb
+++ b/ee/app/services/concerns/work_items/sync_as_epic.rb
@@ -28,7 +28,7 @@ def update_epic_for!(work_item)
       return true unless epic
       return true unless epic.group.work_item_sync_to_epic_enabled?
 
-      epic.assign_attributes(update_params(work_item))
+      epic.assign_attributes(update_params(work_item).merge(skip_description_version: true))
       epic.save!(touch: false)
     rescue StandardError => error
       handle_error!(:update, error, work_item)
diff --git a/ee/config/feature_flags/wip/epic_and_work_item_notes_unification.yml b/ee/config/feature_flags/wip/epic_and_work_item_notes_unification.yml
new file mode 100644
index 0000000000000..edf24eebbd306
--- /dev/null
+++ b/ee/config/feature_flags/wip/epic_and_work_item_notes_unification.yml
@@ -0,0 +1,9 @@
+---
+name: epic_and_work_item_notes_unification
+feature_issue_url:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/157161
+rollout_issue_url:
+milestone: '17.2'
+group: group::product planning
+type: wip
+default_enabled: false
diff --git a/ee/spec/controllers/groups/epics_controller_spec.rb b/ee/spec/controllers/groups/epics_controller_spec.rb
index 6ab635446277e..a716f308faa48 100644
--- a/ee/spec/controllers/groups/epics_controller_spec.rb
+++ b/ee/spec/controllers/groups/epics_controller_spec.rb
@@ -472,6 +472,20 @@ def update_epic(epic, params)
   it_behaves_like DescriptionDiffActions do
     let_it_be(:group)    { create(:group, :public) }
     let_it_be(:issuable) { create(:epic, group: group) }
+    let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+    let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+    let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+
+    let(:base_params) { { group_id: group, id: issuable } }
+  end
+
+  it_behaves_like DescriptionDiffActions do
+    let_it_be(:group) { create(:group, :public) }
+    let_it_be(:epic) { create(:epic, group: group) }
+    let_it_be(:issuable) { epic.work_item }
+    let_it_be(:version_1) { create(:description_version, epic.base_class_name.underscore.to_sym => epic) }
+    let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+    let_it_be(:version_3) { create(:description_version, epic.base_class_name.underscore.to_sym => epic) }
 
     let(:base_params) { { group_id: group, id: issuable } }
   end
diff --git a/ee/spec/controllers/groups/work_items_controller_spec.rb b/ee/spec/controllers/groups/work_items_controller_spec.rb
index 23caa6bab04ea..abec43b90b844 100644
--- a/ee/spec/controllers/groups/work_items_controller_spec.rb
+++ b/ee/spec/controllers/groups/work_items_controller_spec.rb
@@ -10,6 +10,10 @@
     context 'when issuable is an issue type issue' do
       it_behaves_like DescriptionDiffActions do
         let_it_be(:issuable) { create(:issue, :group_level, namespace: group) }
+        let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+
         let(:base_params) { group_params }
       end
     end
@@ -17,6 +21,10 @@
     context 'when work item is an issue type issue' do
       it_behaves_like DescriptionDiffActions do
         let_it_be(:issuable) { create(:work_item, :group_level, namespace: group) }
+        let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+
         let(:base_params) { group_params }
       end
     end
@@ -24,6 +32,10 @@
     context 'when issuable is a task/work_item' do
       it_behaves_like DescriptionDiffActions do
         let_it_be(:issuable) { create(:work_item, :group_level, :task, namespace: group) }
+        let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+
         let(:base_params) { group_params }
       end
     end
diff --git a/ee/spec/controllers/projects/issues_controller_spec.rb b/ee/spec/controllers/projects/issues_controller_spec.rb
index 0c3a7920d633a..d374481797341 100644
--- a/ee/spec/controllers/projects/issues_controller_spec.rb
+++ b/ee/spec/controllers/projects/issues_controller_spec.rb
@@ -415,12 +415,18 @@ def update_issue(issue_params: {}, additional_params: {}, id: nil)
     context 'when issuable is an issue type issue' do
       it_behaves_like DescriptionDiffActions do
         let_it_be(:issuable) { create(:issue, project: project) }
+        let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
       end
     end
 
     context 'when issuable is a task/work_item' do
       it_behaves_like DescriptionDiffActions do
         let_it_be(:issuable) { create(:issue, :task, project: project) }
+        let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+        let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
       end
     end
   end
diff --git a/ee/spec/controllers/projects/merge_requests_controller_spec.rb b/ee/spec/controllers/projects/merge_requests_controller_spec.rb
index b7f2b99fda7e6..29374b9e492e7 100644
--- a/ee/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/ee/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -869,5 +869,8 @@ def expect_rebase_worker_for(user)
   it_behaves_like DescriptionDiffActions do
     let_it_be(:project)  { create(:project, :repository, :public) }
     let_it_be(:issuable) { create(:merge_request, source_project: project) }
+    let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+    let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
+    let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
   end
 end
diff --git a/ee/spec/models/ee/description_version_spec.rb b/ee/spec/models/ee/description_version_spec.rb
index 521203e31d9fe..7050d40255ea2 100644
--- a/ee/spec/models/ee/description_version_spec.rb
+++ b/ee/spec/models/ee/description_version_spec.rb
@@ -56,7 +56,8 @@ def deleted_count
     it 'broadcasts notes update' do
       version = epic.description_versions.last
 
-      expect(epic).to receive(:broadcast_notes_changed)
+      expect(version.issuable).to receive(:broadcast_notes_changed)
+      expect(version.issuable.sync_object).to receive(:broadcast_notes_changed)
 
       version.delete!
     end
diff --git a/ee/spec/models/epic_spec.rb b/ee/spec/models/epic_spec.rb
index 1e62e075ceebe..5352decf67c1f 100644
--- a/ee/spec/models/epic_spec.rb
+++ b/ee/spec/models/epic_spec.rb
@@ -1723,40 +1723,81 @@ def as_item(item)
     end
   end
 
-  context 'with labels' do
-    let_it_be(:label1) { create(:group_label, group: group, title: 'epic-label-1') }
-    let_it_be(:label2) { create(:group_label, group: group, title: 'epic-label-2') }
-    let_it_be(:epic) { create(:epic, group: group, labels: [label1]) }
-    let_it_be(:work_item) { epic.work_item }
+  context 'with unified associations' do
+    context 'with labels' do
+      let_it_be(:label1) { create(:group_label, group: group, title: 'epic-label-1') }
+      let_it_be(:label2) { create(:group_label, group: group, title: 'epic-label-2') }
+      let_it_be(:epic) { create(:epic, group: group, labels: [label1]) }
+      let_it_be(:work_item) { epic.work_item }
 
-    before do
-      work_item.labels << label2
-    end
-
-    context 'when labels are fetched just from the epic itself' do
       before do
-        stub_feature_flags(epic_and_work_item_labels_unification: false)
+        work_item.labels << label2
       end
 
-      it 'returns only epic labels' do
-        expect(epic.reload.labels).to match_array([label1])
-        expect(work_item.reload.labels).to match_array([label2])
+      context 'when labels are fetched just from the epic itself' do
+        before do
+          stub_feature_flags(epic_and_work_item_labels_unification: false)
+        end
+
+        it 'returns only epic labels' do
+          expect(epic.reload.labels).to match_array([label1])
+          expect(work_item.reload.labels).to match_array([label2])
+        end
       end
-    end
 
-    context 'when labels are fetched from the epic and epic work item' do
-      before do
-        stub_feature_flags(epic_and_work_item_labels_unification: true)
+      context 'when labels are fetched from the epic and epic work item' do
+        before do
+          stub_feature_flags(epic_and_work_item_labels_unification: true)
+        end
+
+        it 'returns epic and epic work item labels' do
+          expect(epic.reload.labels).to match_array([label1, label2])
+          expect(work_item.reload.labels).to match_array([label1, label2])
+        end
+
+        it 'returns epic and epic work item labels queried by id' do
+          expect(epic.reload.labels.find(label1.id)).to eq(label1)
+          expect(work_item.reload.labels.find(label1.id)).to eq(label1)
+        end
       end
+    end
 
-      it 'returns only epic labels' do
-        expect(epic.reload.labels).to match_array([label1, label2])
-        expect(work_item.reload.labels).to match_array([label1, label2])
+    context 'with description versions' do
+      let_it_be(:epic) { create(:epic, group: group) }
+      let_it_be(:work_item) { epic.work_item }
+      let(:version1) { create(:description_version, epic: epic) }
+      let(:version2) { create(:description_version, issue: work_item) }
+
+      context 'when description versions are fetched just from the epic itself' do
+        before do
+          stub_feature_flags(epic_and_work_item_notes_unification: false)
+        end
+
+        it 'returns only epic notes' do
+          expect(epic.reload.description_versions).to match_array([version1])
+          expect(work_item.reload.description_versions).to match_array([version2])
+          expect(epic.reload.own_description_versions).to match_array([version1])
+          expect(work_item.reload.own_description_versions).to match_array([version2])
+        end
       end
 
-      it 'returns only epic labels queried by id' do
-        expect(epic.reload.labels.find(label1.id)).to eq(label1)
-        expect(work_item.reload.labels.find(label1.id)).to eq(label1)
+      context 'when notes are fetched from the epic and epic work item' do
+        before do
+          stub_feature_flags(epic_and_work_item_notes_unification: true)
+        end
+
+        it 'returns epic and epic work item notes' do
+          expect(epic.reload.description_versions).to match_array([version1, version2])
+          expect(work_item.reload.description_versions).to match_array([version1, version2])
+
+          expect(epic.reload.own_description_versions).to match_array([version1])
+          expect(work_item.reload.own_description_versions).to match_array([version2])
+        end
+
+        it 'returns epic and epic work item notes queried by id' do
+          expect(epic.reload.description_versions.find(version1.id)).to eq(version1)
+          expect(work_item.reload.description_versions.find(version1.id)).to eq(version1)
+        end
       end
     end
   end
diff --git a/ee/spec/models/work_item_spec.rb b/ee/spec/models/work_item_spec.rb
index 53ecbfae8dca1..2dfc3dc5d6690 100644
--- a/ee/spec/models/work_item_spec.rb
+++ b/ee/spec/models/work_item_spec.rb
@@ -464,9 +464,16 @@
         create(:work_item, description: 'Original description', work_item_type: type, project: reusable_project)
       end
 
-      it 'does not create a versioned description' do
+      it 'creates a versioned description on epic work item' do
         expect { work_item.update!(description: 'Another description') }
-          .not_to change { work_item.description_versions.count }
+          .to change { work_item.own_description_versions.count }
+      end
+
+      context 'when set to skip description version' do
+        it 'does not create a versioned description on epic work item' do
+          expect { work_item.update!(description: 'Another description', skip_description_version: true) }
+            .not_to change { work_item.own_description_versions.count }
+        end
       end
     end
   end
diff --git a/ee/spec/policies/epic_policy_spec.rb b/ee/spec/policies/epic_policy_spec.rb
index 639052e7ab964..a47133e0f1612 100644
--- a/ee/spec/policies/epic_policy_spec.rb
+++ b/ee/spec/policies/epic_policy_spec.rb
@@ -50,7 +50,7 @@
   shared_examples 'can only read epics' do
     it 'matches expected permissions' do
       is_expected.to be_allowed(
-        :read_epic, :read_epic_iid, :read_note,
+        :read_epic, :read_issuable, :read_epic_iid, :read_note,
         :create_todo, :read_issuable_participables
       )
       is_expected.to be_disallowed(
@@ -64,7 +64,7 @@
   shared_examples 'can manage epics' do
     it 'matches expected permissions' do
       is_expected.to be_allowed(
-        :read_epic, :read_epic_iid, :read_note,
+        :read_epic, :read_issuable, :read_epic_iid, :read_note,
         :read_issuable_participables, :read_internal_note,
         :update_epic, :admin_epic, :create_epic, :admin_epic_relation,
         :create_todo, :admin_epic_link_relation, :set_epic_metadata,
@@ -77,7 +77,7 @@
   shared_examples 'all epic permissions disabled' do
     it 'matches expected permissions' do
       is_expected.to be_disallowed(
-        :read_epic, :read_epic_iid, :update_epic,
+        :read_epic, :read_issuable, :read_epic_iid, :update_epic,
         :destroy_epic, :admin_epic, :create_epic,
         :create_note, :award_emoji, :read_note,
         :read_issuable_participables,
@@ -93,7 +93,7 @@
   shared_examples 'all reporter epic permissions enabled' do
     it 'matches expected permissions' do
       is_expected.to be_allowed(
-        :read_epic, :read_epic_iid, :update_epic,
+        :read_epic, :read_issuable, :read_epic_iid, :update_epic,
         :admin_epic, :create_epic, :create_note,
         :award_emoji, :read_note, :create_todo,
         :read_issuable_participables, :read_internal_note,
@@ -230,7 +230,7 @@
 
         it 'matches expected permissions' do
           is_expected.to be_allowed(
-            :read_epic, :read_epic_iid, :read_note, :read_issuable_participables
+            :read_epic, :read_issuable, :read_epic_iid, :read_note, :read_issuable_participables
           )
 
           is_expected.to be_disallowed(
@@ -339,7 +339,7 @@
           end
 
           it 'matches expected permissions' do
-            is_expected.to be_allowed(:read_epic, :read_epic_iid)
+            is_expected.to be_allowed(:read_epic, :read_issuable, :read_epic_iid)
             is_expected.to be_disallowed(
               :update_epic, :destroy_epic, :admin_epic,
               :create_epic, :set_epic_metadata, :set_confidentiality,
@@ -369,7 +369,7 @@
 
       it 'matches expected permissions' do
         is_expected.to be_allowed(
-          :read_epic, :read_epic_iid, :update_epic,
+          :read_epic, :read_issuable, :read_epic_iid, :update_epic,
           :admin_epic, :create_epic, :create_note,
           :award_emoji, :read_note, :create_todo,
           :read_issuable_participables, :admin_epic_relation,
@@ -389,7 +389,7 @@
 
       it 'matches expected permissions' do
         is_expected.to be_allowed(
-          :read_epic, :read_epic_iid, :update_epic,
+          :read_epic, :read_issuable, :read_epic_iid, :update_epic,
           :admin_epic, :create_epic, :create_note,
           :award_emoji, :read_note, :create_todo,
           :read_issuable_participables, :admin_epic_relation,
diff --git a/ee/spec/services/ee/issuable/destroy_service_spec.rb b/ee/spec/services/ee/issuable/destroy_service_spec.rb
index 7ac1eb79ce2d3..0cd5c6599ce68 100644
--- a/ee/spec/services/ee/issuable/destroy_service_spec.rb
+++ b/ee/spec/services/ee/issuable/destroy_service_spec.rb
@@ -10,57 +10,115 @@
   describe '#execute' do
     context 'when destroying an epic' do
       let_it_be(:group) { create(:group) }
-      let_it_be(:label1) { create(:group_label, group: group, title: 'epic-label-1') }
-      let_it_be(:label2) { create(:group_label, group: group, title: 'epic-label-2') }
-      let_it_be(:epic) { create(:epic, group: group, labels: [label1]) }
-      let(:work_item) { epic.sync_object }
 
-      let(:issuable) { epic }
-      let(:sync_object) { work_item }
+      context 'when deleting the epic' do
+        let_it_be(:label1) { create(:group_label, group: group) }
+        let_it_be(:label2) { create(:group_label, group: group) }
+        let_it_be(:epic) { create(:epic, group: group, labels: [label1]) }
+        let_it_be(:work_item) { epic.sync_object }
 
-      before do
-        sync_object.labels << label2
-      end
+        let_it_be(:issuable) { epic }
+        let_it_be(:sync_object) { work_item }
 
-      it 'deletes the epic and the epic work item' do
-        # making sure we have this work item
-        expect(WorkItem.find_by_id(sync_object.id)).to be_present
+        before do
+          sync_object.labels << label2
+        end
 
-        expect { subject.execute(issuable) }.to change { Epic.count }.by(-1).and(
-          change { WorkItem.count }.by(-1)
-        )
+        it 'deletes the epic and the epic work item' do
+          epic_id = epic.id
+          epic_work_item_id = epic.issue_id
 
-        expect(sync_object.id).not_to be_nil
-        expect(WorkItem.find_by_id(sync_object.id)).to be_nil
-      end
+          expect { subject.execute(issuable) }.to change { Epic.count }.by(-1).and(change { WorkItem.count }.by(-1))
 
-      it 'records usage ping epic destroy event' do
-        expect(Gitlab::UsageDataCounters::EpicActivityUniqueCounter).to receive(
-          :track_epic_destroyed).with(author: user, namespace: group)
+          expect(Epic.find_by_id(epic_id)).to be_nil
+          expect(WorkItem.find_by_id(epic_work_item_id)).to be_nil
+        end
 
-        subject.execute(issuable)
+        it 'records usage ping epic destroy event' do
+          expect(Gitlab::UsageDataCounters::EpicActivityUniqueCounter).to receive(
+            :track_epic_destroyed).with(author: user, namespace: group)
+
+          subject.execute(issuable)
+        end
+
+        it_behaves_like 'service deleting todos'
+        it_behaves_like 'service deleting label links'
       end
 
-      it_behaves_like 'service deleting todos'
-      it_behaves_like 'service deleting label links'
+      context 'when deleting the epic work item' do
+        let_it_be(:label1) { create(:group_label, group: group) }
+        let_it_be(:label2) { create(:group_label, group: group) }
+        let_it_be(:epic) { create(:epic, group: group, labels: [label1]) }
+        let_it_be(:work_item) { epic.sync_object }
 
-      context 'when destroying an epic work item' do
-        let(:issuable) { work_item }
-        let(:sync_object) { epic }
+        let_it_be(:issuable) { work_item.reload }
+        let_it_be(:sync_object) { epic.reload }
+
+        before do
+          sync_object.labels << label2
+        end
 
         it 'deletes the epic and the epic work item' do
-          # making sure we have this work item
-          expect { subject.execute(issuable) }.to change { Epic.count }.by(-1).and(
-            change { WorkItem.count }.by(-1)
-          )
+          epic_id = epic.id
+          epic_work_item_id = epic.issue_id
+
+          expect { subject.execute(issuable) }.to change { Epic.count }.by(-1).and(change { WorkItem.count }.by(-1))
 
-          expect(sync_object.id).not_to be_nil
-          expect(Epic.find_by_id(sync_object.id)).to be_nil
+          expect(Epic.find_by_id(epic_id)).to be_nil
+          expect(WorkItem.find_by_id(epic_work_item_id)).to be_nil
         end
 
         it_behaves_like 'service deleting todos'
         it_behaves_like 'service deleting label links'
       end
+
+      context 'with unified description_versions' do
+        shared_examples 'deletes description versions on both epic and epic work item' do
+          it 'deletes the epic, epic work item and all notes' do
+            epic_id = epic.id
+            epic_work_item_id = epic.issue_id
+
+            expect(DescriptionVersion.where(epic_id: epic_id).count).to eq(1)
+            expect(DescriptionVersion.where(issue_id: epic_work_item_id).count).to eq(1)
+
+            expect { subject.execute(issuable) }.to change { Epic.count }.by(-1).and(
+              change { WorkItem.count }.by(-1)).and(change { DescriptionVersion.count }.by(-2))
+
+            expect(Epic.find_by_id(epic_id)).to be_nil
+            expect(WorkItem.find_by_id(epic_work_item_id)).to be_nil
+            expect(DescriptionVersion.where(epic_id: epic_id).count).to eq(0)
+            expect(DescriptionVersion.where(issue_id: epic_work_item_id).count).to eq(0)
+          end
+        end
+
+        context 'when deleting the epic' do
+          let_it_be(:epic) { create(:epic, group: group) }
+          let_it_be(:work_item) { epic.sync_object }
+          let_it_be(:label1) { create(:group_label, group: group) }
+          let_it_be(:label2) { create(:group_label, group: group) }
+          let_it_be(:version1) { create(:description_version, epic: epic) }
+          let_it_be(:version2) { create(:description_version, issue: work_item) }
+
+          let_it_be(:issuable) { epic }
+          let_it_be(:sync_object) { work_item }
+
+          it_behaves_like 'deletes description versions on both epic and epic work item'
+        end
+
+        context 'when deleting the epic work item' do
+          let_it_be(:epic) { create(:epic, group: group) }
+          let_it_be(:work_item) { epic.sync_object }
+          let_it_be(:label1) { create(:group_label, group: group) }
+          let_it_be(:label2) { create(:group_label, group: group) }
+          let_it_be(:version1) { create(:description_version, epic: epic) }
+          let_it_be(:version2) { create(:description_version, issue: work_item) }
+
+          let_it_be(:issuable) { work_item }
+          let_it_be(:sync_object) { epic }
+
+          it_behaves_like 'deletes description versions on both epic and epic work item'
+        end
+      end
     end
 
     context 'when destroying other issuable type' do
diff --git a/ee/spec/services/epics/update_service_spec.rb b/ee/spec/services/epics/update_service_spec.rb
index dc6307fdf17eb..6268e1ab9854b 100644
--- a/ee/spec/services/epics/update_service_spec.rb
+++ b/ee/spec/services/epics/update_service_spec.rb
@@ -107,9 +107,16 @@ def update_epic(opts)
 
         update_epic(description: 'updated description')
       end
+
+      it 'creates description version for epic only' do
+        update_epic(description: 'New description')
+
+        expect(epic.reload.own_description_versions.count).to eq(2)
+        expect(epic.sync_object.reload.own_description_versions.count).to eq(0)
+      end
     end
 
-    context 'when decription is not changed' do
+    context 'when description is not changed' do
       it 'does not trigger GraphQL description updated subscription' do
         expect(GraphqlTriggers).not_to receive(:issuable_description_updated)
 
diff --git a/ee/spec/services/work_items/update_service_spec.rb b/ee/spec/services/work_items/update_service_spec.rb
index 67b18f513d8bb..6272ea6f36eef 100644
--- a/ee/spec/services/work_items/update_service_spec.rb
+++ b/ee/spec/services/work_items/update_service_spec.rb
@@ -379,6 +379,20 @@
         end
 
         it_behaves_like 'syncs all data from a work_item to an epic'
+
+        context 'and description version created for work item only' do
+          before do
+            work_item.update_columns(description: "some older description")
+            work_item.sync_object.update_columns(description: "some older description")
+          end
+
+          it 'creates description version on epic work item only' do
+            subject
+
+            expect(work_item.reload.own_description_versions.count).to eq(2)
+            expect(work_item.sync_object.reload.own_description_versions.count).to eq(0)
+          end
+        end
       end
 
       it 'syncs the data to the epic', :aggregate_failures do
diff --git a/ee/spec/support/shared_examples/controllers/concerns/description_diff_actions_shared_examples.rb b/ee/spec/support/shared_examples/controllers/concerns/description_diff_actions_shared_examples.rb
index 0d273406f310e..6e6ab1381fee0 100644
--- a/ee/spec/support/shared_examples/controllers/concerns/description_diff_actions_shared_examples.rb
+++ b/ee/spec/support/shared_examples/controllers/concerns/description_diff_actions_shared_examples.rb
@@ -4,10 +4,6 @@
   let(:base_params) { { namespace_id: project.namespace, project_id: project, id: issuable } }
 
   describe do
-    let_it_be(:version_1) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
-    let_it_be(:version_2) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
-    let_it_be(:version_3) { create(:description_version, issuable.base_class_name.underscore.to_sym => issuable) }
-
     def get_description_diff(extra_params = {})
       get :description_diff, params: base_params.merge(extra_params)
     end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 31484dd1a279f..b993f933b4873 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -23,6 +23,7 @@ issues:
 - own_label_links
 - labels
 - own_labels
+- own_description_versions
 - sync_object
 - last_edited_by
 - todos
@@ -1025,6 +1026,7 @@ epic:
 - own_label_links
 - labels
 - own_labels
+- own_description_versions
 - sync_object
 - todos
 - metrics
-- 
GitLab