diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index b5abfe51beef04f6552669638ae490274dd589bf..4253d76e737d3b8835c2282505f181cd28f6208b 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -1973,6 +1973,7 @@ Input type: `AiActionInput`
 | <a id="mutationaiactionclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
 | <a id="mutationaiactionclientsubscriptionid"></a>`clientSubscriptionId` | [`String`](#string) | Client generated ID that can be subscribed to, to receive a response for the mutation. |
 | <a id="mutationaiactionconversationtype"></a>`conversationType` | [`AiConversationsThreadsConversationType`](#aiconversationsthreadsconversationtype) | Conversation type of the thread. |
+| <a id="mutationaiactiondescriptioncomposer"></a>`descriptionComposer` | [`AiDescriptionComposerInput`](#aidescriptioncomposerinput) | Input for description_composer AI action. |
 | <a id="mutationaiactionexplainvulnerability"></a>`explainVulnerability` | [`AiExplainVulnerabilityInput`](#aiexplainvulnerabilityinput) | Input for explain_vulnerability AI action. |
 | <a id="mutationaiactiongeneratecommitmessage"></a>`generateCommitMessage` | [`AiGenerateCommitMessageInput`](#aigeneratecommitmessageinput) | Input for generate_commit_message AI action. |
 | <a id="mutationaiactiongeneratecubequery"></a>`generateCubeQuery` | [`AiGenerateCubeQueryInput`](#aigeneratecubequeryinput) | Input for generate_cube_query AI action. |
@@ -47068,6 +47069,16 @@ see the associated mutation type above.
 | <a id="aicurrentfileinputfilename"></a>`fileName` | [`String!`](#string) | File name. |
 | <a id="aicurrentfileinputselectedtext"></a>`selectedText` | [`String!`](#string) | Selected text. |
 
+### `AiDescriptionComposerInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="aidescriptioncomposerinputdescription"></a>`description` | [`String!`](#string) | Current description. |
+| <a id="aidescriptioncomposerinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
+| <a id="aidescriptioncomposerinputuserprompt"></a>`userPrompt` | [`String!`](#string) | Prompt from user. |
+
 ### `AiExplainVulnerabilityInput`
 
 #### Arguments
diff --git a/ee/app/graphql/types/ai/description_composer_input_type.rb b/ee/app/graphql/types/ai/description_composer_input_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0dde805dce2cc56a8cd2ad5673eb8be74d5a350b
--- /dev/null
+++ b/ee/app/graphql/types/ai/description_composer_input_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+  module Ai
+    class DescriptionComposerInputType < BaseMethodInputType
+      graphql_name 'AiDescriptionComposerInput'
+
+      argument :description, GraphQL::Types::String,
+        required: true,
+        description: 'Current description.'
+
+      argument :user_prompt, GraphQL::Types::String,
+        required: true,
+        description: 'Prompt from user.'
+    end
+  end
+end
diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb
index fab5525d951721c86b6284086ecfd58fae5fb87d..4eb938dbb384cf8712dcad70dc707648d795e9c0 100644
--- a/ee/app/models/gitlab_subscriptions/features.rb
+++ b/ee/app/models/gitlab_subscriptions/features.rb
@@ -213,6 +213,7 @@ class Features
       dast
       dependency_scanning
       dora4_analytics
+      description_composer
       enterprise_templates
       environment_alerts
       evaluate_group_level_compliance_pipeline
diff --git a/ee/app/policies/ee/merge_request_policy.rb b/ee/app/policies/ee/merge_request_policy.rb
index 928ec785054dd09698be3ba7ce73e3f6f9010fdf..4029840b18bffdd2da84d4bd478a1b352b10b3e0 100644
--- a/ee/app/policies/ee/merge_request_policy.rb
+++ b/ee/app/policies/ee/merge_request_policy.rb
@@ -72,6 +72,20 @@ module MergeRequestPolicy
         @user.allowed_to_use?(:summarize_review, licensed_feature: :summarize_review)
       end
 
+      condition(:description_composer_enabled) do
+        subject.project.project_setting.duo_features_enabled? &&
+          ::Feature.enabled?(:mr_description_composer, @user) &&
+          ::Gitlab::Llm::FeatureAuthorizer.new(
+            container: @subject.project,
+            feature_name: :description_composer,
+            user: @user
+          ).allowed?
+      end
+
+      condition(:user_allowed_to_use_description_composer) do
+        @user.allowed_to_use?(:description_composer, licensed_feature: :description_composer)
+      end
+
       def read_only?
         @subject.target_project&.namespace&.read_only?
       end
@@ -119,6 +133,12 @@ def group_access?(protected_branch)
           user_allowed_to_use_summarize_review &
           can?(:read_merge_request)
       end.enable :access_summarize_review
+
+      rule do
+        description_composer_enabled &
+          user_allowed_to_use_description_composer &
+          can?(:read_merge_request)
+      end.enable :access_description_composer
     end
 
     private
diff --git a/ee/app/services/llm/description_composer_service.rb b/ee/app/services/llm/description_composer_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1d72dc9441cf681b2048fd5133ebd7590157ff45
--- /dev/null
+++ b/ee/app/services/llm/description_composer_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Llm # rubocop:disable Gitlab/BoundedContexts -- Existing LLM module
+  class DescriptionComposerService < BaseService
+    def valid?
+      super &&
+        resource.is_a?(MergeRequest) &&
+        user.can?(:access_description_composer, resource)
+    end
+
+    private
+
+    def ai_action
+      :description_composer
+    end
+
+    def perform
+      schedule_completion_worker
+    end
+  end
+end
diff --git a/ee/config/cloud_connector/access_data.yml b/ee/config/cloud_connector/access_data.yml
index 5f7a6ad3c0988bd8a99ae1016f07795d1c6d141d..8132bdd418b55bea9e49a163c838c850def22d39 100644
--- a/ee/config/cloud_connector/access_data.yml
+++ b/ee/config/cloud_connector/access_data.yml
@@ -23,6 +23,7 @@ services: # Cloud connector features (i.e. code_suggestions, duo_chat...)
           - review_merge_request
           - summarize_issue_discussions
           - summarize_review
+          - description_composer
   code_suggestions:
     # The name of the backend who is serving this service. The name is used as a token audience claim.
     backend: 'gitlab-ai-gateway'
@@ -275,3 +276,10 @@ services: # Cloud connector features (i.e. code_suggestions, duo_chat...)
           - amazon_q_integration
     license_types:
       - ultimate
+  description_composer:
+    backend: 'gitlab-ai-gateway'
+    cut_off_date: 2024-10-17 00:00:00 UTC
+    bundled_with:
+      duo_enterprise:
+        unit_primitives:
+          - description_composer
diff --git a/ee/config/feature_flags/wip/mr_description_composer.yml b/ee/config/feature_flags/wip/mr_description_composer.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d84bc4a9ad82f131e15dc7a00d5e307826ce259b
--- /dev/null
+++ b/ee/config/feature_flags/wip/mr_description_composer.yml
@@ -0,0 +1,9 @@
+---
+name: mr_description_composer
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/513132
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/183512
+rollout_issue_url:
+milestone: '17.10'
+group: group::code review
+type: wip
+default_enabled: false
diff --git a/ee/lib/gitlab/llm/anthropic/completions/description_composer.rb b/ee/lib/gitlab/llm/anthropic/completions/description_composer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9a7548a504738e5262a1e3dfa28cd409a96d02a5
--- /dev/null
+++ b/ee/lib/gitlab/llm/anthropic/completions/description_composer.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Llm
+    module Anthropic
+      module Completions
+        class DescriptionComposer < Gitlab::Llm::Completions::Base
+          DEFAULT_ERROR = 'An unexpected error has occurred.'
+
+          def execute
+            response = response_for(user, merge_request)
+            response_modifier = modify_response(response)
+
+            ::Gitlab::Llm::GraphqlSubscriptionResponseService.new(
+              user, merge_request, response_modifier, options: response_options
+            ).execute
+          rescue StandardError => error
+            Gitlab::ErrorTracking.track_exception(error)
+
+            response_modifier = modify_response(
+              { error: { message: DEFAULT_ERROR } }.to_json
+            )
+
+            ::Gitlab::Llm::GraphqlSubscriptionResponseService.new(
+              user, merge_request, response_modifier, options: response_options
+            ).execute
+
+            response_modifier
+          end
+
+          private
+
+          def merge_request
+            resource
+          end
+
+          def modify_response(response)
+            ::Gitlab::Llm::Anthropic::ResponseModifiers::DescriptionComposer.new(response)
+          end
+
+          def response_for(user, merge_request)
+            template = ai_prompt_class.new(merge_request, options)
+
+            Gitlab::Llm::Anthropic::Client
+              .new(user, unit_primitive: 'description_composer', tracking_context: tracking_context)
+              .messages_complete(**template.to_prompt)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/llm/anthropic/response_modifiers/description_composer.rb b/ee/lib/gitlab/llm/anthropic/response_modifiers/description_composer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..95764efcbba6467dd64fa37f852c2c6ee9b239a4
--- /dev/null
+++ b/ee/lib/gitlab/llm/anthropic/response_modifiers/description_composer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Llm
+    module Anthropic
+      module ResponseModifiers
+        class DescriptionComposer < Gitlab::Llm::BaseResponseModifier
+          def response_body
+            ai_response&.dig('content', 0, 'text')
+          end
+
+          def errors
+            @errors ||= [ai_response&.dig('error', 'message')].compact
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/llm/anthropic/templates/description_composer.rb b/ee/lib/gitlab/llm/anthropic/templates/description_composer.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e9514ccd238252c50b6529b243f0b7a2e9a344f5
--- /dev/null
+++ b/ee/lib/gitlab/llm/anthropic/templates/description_composer.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Llm
+    module Anthropic
+      module Templates
+        class DescriptionComposer
+          include Gitlab::Llm::Chain::Concerns::AnthropicPrompt
+          include Gitlab::Utils::StrongMemoize
+
+          USER_MESSAGE = Gitlab::Llm::Chain::Utils::Prompt.as_user(
+            <<~PROMPT.chomp
+            You are a merge request description composer. Your task is to update a specific part of a merge request description based on a user's prompt. Here's the information you'll be working with:
+
+            <merge_request_title>
+            %<title>s
+            </merge_request_title>
+
+            <merge_request_description>
+            %<description>s
+            </merge_request_description>
+
+            <diffs>
+            %<diff>s
+            </diffs>
+
+            Your goal is to update only the part of the description that is enclosed in <selected-text> tags. The user has provided a prompt to guide this update:
+
+            <user_prompt>
+            %<user_prompt>s
+            </user_prompt>
+
+            Follow these steps to complete the task:
+
+            1. Carefully read the entire merge request description to understand the context.
+            2. Locate the <selected-text> section within the description.
+            3. Analyze the text surrounding the <selected-text> section to better understand which part of the description is being updated and how it relates to the rest of the content.
+            4. Read the user's prompt and the diffs to gather additional context and requirements for the update.
+            5. Update the content within the <selected-text> tags based on the user's prompt and the overall context of the merge request. Ensure that the updated text:
+              - Addresses the user's prompt
+              - Maintains consistency with the surrounding text
+              - Reflects any relevant information from the diffs
+              - Keeps the same tone and style as the original description
+            6. Do not modify any part of the description outside of the <selected-text> tags.
+            7. Return only the updated content that should replace the original <selected-text> section. Do not include the <selected-text> tags in your response.
+
+            Your response should contain only the updated text, without any additional explanation or commentary. Ensure that the updated text flows seamlessly with the surrounding content in the original description.
+            PROMPT
+          )
+
+          def initialize(merge_request, params = {})
+            @merge_request = merge_request
+            @params = params
+          end
+
+          def to_prompt
+            {
+              messages: Gitlab::Llm::Chain::Utils::Prompt.role_conversation(
+                Gitlab::Llm::Chain::Utils::Prompt.format_conversation([USER_MESSAGE], variables)
+              ),
+              model: ::Gitlab::Llm::Anthropic::Client::CLAUDE_3_7_SONNET
+            }
+          end
+
+          def variables
+            {
+              diff: extracted_diff,
+              description: params[:description],
+              title: merge_request.title,
+              user_prompt: params[:user_prompt]
+            }
+          end
+
+          private
+
+          attr_reader :merge_request, :params
+
+          def extracted_diff
+            Gitlab::Llm::Utils::MergeRequestTool.extract_diff(
+              source_project: merge_request.source_project,
+              source_branch: merge_request.source_branch,
+              target_project: merge_request.project,
+              target_branch: merge_request.target_branch,
+              character_limit: 10000
+            )
+          end
+          strong_memoize_attr :extracted_diff
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb b/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb
index e9fd0fb5730548b7723b80c5f4ac788f7a79f946..42ad8e5873be6a4a3f70b61415ed5f8518845e42 100644
--- a/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb
+++ b/ee/lib/gitlab/llm/utils/ai_features_catalogue.rb
@@ -69,6 +69,16 @@ class AiFeaturesCatalogue
             self_managed: true,
             internal: false
           },
+          description_composer: {
+            service_class: ::Gitlab::Llm::Anthropic::Completions::DescriptionComposer,
+            aigw_service_class: nil,
+            prompt_class: ::Gitlab::Llm::Anthropic::Templates::DescriptionComposer,
+            feature_category: :code_review_workflow,
+            execute_method: ::Llm::DescriptionComposerService,
+            maturity: :experimental,
+            self_managed: true,
+            internal: false
+          },
           chat: {
             service_class: ::Gitlab::Llm::Completions::Chat,
             prompt_class: nil,
diff --git a/ee/spec/factories/ai_messages.rb b/ee/spec/factories/ai_messages.rb
index f7ed69538168b1ebe2710156ff9a987325aee28b..6382e68bee8bdc265a582f5ef7f2d6ed04247dfc 100644
--- a/ee/spec/factories/ai_messages.rb
+++ b/ee/spec/factories/ai_messages.rb
@@ -66,6 +66,10 @@
       ai_action { :generate_commit_message }
     end
 
+    trait :description_composer do
+      ai_action { :description_composer }
+    end
+
     trait :summarize_review do
       ai_action { :summarize_review }
     end
diff --git a/ee/spec/lib/gitlab/llm/anthropic/completions/description_composer_spec.rb b/ee/spec/lib/gitlab/llm/anthropic/completions/description_composer_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e52266137adc0661bab3a42909dd857bcbe29022
--- /dev/null
+++ b/ee/spec/lib/gitlab/llm/anthropic/completions/description_composer_spec.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Llm::Anthropic::Completions::DescriptionComposer, feature_category: :code_review_workflow do
+  let(:prompt_class) { Gitlab::Llm::Anthropic::Templates::DescriptionComposer }
+  let(:options) { {} }
+  let(:response_modifier) { double }
+  let(:response_service) { double }
+  let_it_be(:user) { create(:user) }
+  let_it_be(:merge_request) { create(:merge_request) }
+  let(:params) do
+    [user, merge_request, response_modifier, { options: { request_id: 'uuid', ai_action: :description_composer } }]
+  end
+
+  let(:prompt_message) do
+    build(:ai_message, :description_composer, user: user, resource: merge_request, request_id: 'uuid')
+  end
+
+  subject(:generate) { described_class.new(prompt_message, prompt_class, options) }
+
+  describe '#execute' do
+    context 'when the text model returns an unsuccessful response' do
+      before do
+        allow_next_instance_of(Gitlab::Llm::Anthropic::Client) do |client|
+          allow(client).to receive(:messages_complete).and_return(
+            { error: 'Error' }.to_json
+          )
+        end
+      end
+
+      it 'publishes the error to the graphql subscription' do
+        errors = { error: 'Error' }
+        expect(::Gitlab::Llm::Anthropic::ResponseModifiers::DescriptionComposer).to receive(:new)
+          .with(errors.to_json)
+          .and_return(response_modifier)
+        expect(::Gitlab::Llm::GraphqlSubscriptionResponseService).to receive(:new).with(*params).and_return(
+          response_service
+        )
+        expect(response_service).to receive(:execute)
+
+        generate.execute
+      end
+    end
+
+    context 'when the text model returns a successful response' do
+      let(:example_answer) { "AI generated commit message" }
+      let(:example_response) { { content: [{ text: example_answer }] } }
+
+      before do
+        allow_next_instance_of(Gitlab::Llm::Anthropic::Client) do |client|
+          allow(client).to receive(:messages_complete).and_return(example_response&.to_json)
+        end
+      end
+
+      it 'publishes the content from the AI response' do
+        expect(::Gitlab::Llm::Anthropic::ResponseModifiers::DescriptionComposer).to receive(:new)
+          .with(example_response.to_json)
+          .and_return(response_modifier)
+        expect(::Gitlab::Llm::GraphqlSubscriptionResponseService).to receive(:new).with(*params).and_return(
+          response_service
+        )
+        expect(response_service).to receive(:execute)
+
+        generate.execute
+      end
+
+      context 'when response is nil' do
+        let(:example_response) { nil }
+
+        it 'publishes the content from the AI response' do
+          expect(::Gitlab::Llm::Anthropic::ResponseModifiers::DescriptionComposer)
+            .to receive(:new)
+            .with(nil)
+            .and_return(response_modifier)
+
+          expect(::Gitlab::Llm::GraphqlSubscriptionResponseService)
+            .to receive(:new)
+            .with(*params)
+            .and_return(response_service)
+
+          expect(response_service).to receive(:execute)
+
+          generate.execute
+        end
+      end
+
+      context 'when an unexpected error is raised' do
+        let(:error) { StandardError.new("Error") }
+
+        before do
+          allow_next_instance_of(Gitlab::Llm::Anthropic::Client) do |client|
+            allow(client).to receive(:messages_complete).and_raise(error)
+          end
+          allow(Gitlab::ErrorTracking).to receive(:track_exception)
+        end
+
+        it 'records the error' do
+          generate.execute
+          expect(Gitlab::ErrorTracking).to have_received(:track_exception).with(error)
+        end
+
+        it 'publishes a generic error to the graphql subscription' do
+          errors = { error: { message: 'An unexpected error has occurred.' } }
+          expect(::Gitlab::Llm::Anthropic::ResponseModifiers::DescriptionComposer).to receive(:new)
+            .with(errors.to_json)
+            .and_return(response_modifier)
+          expect(::Gitlab::Llm::GraphqlSubscriptionResponseService).to receive(:new).with(*params).and_return(
+            response_service
+          )
+          expect(response_service).to receive(:execute)
+
+          generate.execute
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/lib/gitlab/llm/anthropic/templates/description_composer_spec.rb b/ee/spec/lib/gitlab/llm/anthropic/templates/description_composer_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cddea800d4d557ef9b3ed756b2a331ebd6bb2c81
--- /dev/null
+++ b/ee/spec/lib/gitlab/llm/anthropic/templates/description_composer_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Llm::Anthropic::Templates::DescriptionComposer, feature_category: :code_review_workflow do
+  let_it_be(:merge_request) { create(:merge_request) }
+
+  let(:params) do
+    {
+      description: 'Client merge request description',
+      user_prompt: 'Hello world from user prompt'
+    }
+  end
+
+  subject(:template) { described_class.new(merge_request, params) }
+
+  describe '#to_prompt' do
+    it 'includes raw diff' do
+      diff_file = merge_request.raw_diffs.to_a[0]
+
+      expect(template.to_prompt[:messages][0][:content]).to include(diff_file.diff.split("\n")[1])
+    end
+
+    it 'includes merge request title' do
+      expect(template.to_prompt[:messages][0][:content]).to include(merge_request.title)
+    end
+
+    it 'includes user prompt' do
+      expect(template.to_prompt[:messages][0][:content]).to include('Hello world from user prompt')
+    end
+
+    it 'includes description sent from client' do
+      expect(template.to_prompt[:messages][0][:content]).to include('Client merge request description')
+    end
+  end
+end
diff --git a/ee/spec/policies/merge_request_policy_spec.rb b/ee/spec/policies/merge_request_policy_spec.rb
index c2a830dd10f5d42f449652603660056284597218..07ebfefb1abe7231f9be01075282732700791e21 100644
--- a/ee/spec/policies/merge_request_policy_spec.rb
+++ b/ee/spec/policies/merge_request_policy_spec.rb
@@ -433,4 +433,41 @@ def policy_for(user)
       it { is_expected.to expected_result }
     end
   end
+
+  describe 'access_description_composer' do
+    let(:authorizer) { instance_double(::Gitlab::Llm::FeatureAuthorizer) }
+    let(:user) { can_read_mr ? reporter : nil }
+
+    where(:duo_features_enabled, :feature_flag_enabled, :llm_authorized, :can_read_mr, :allowed_to_use, :expected_result) do
+      true  | true  | true  | true  | true  | be_allowed(:access_description_composer)
+      true  | true  | true  | false | true  | be_disallowed(:access_description_composer)
+      true  | false | true  | true  | true  | be_disallowed(:access_description_composer)
+      true  | true  | false | true  | true  | be_disallowed(:access_description_composer)
+      false | true  | true  | true  | true  | be_disallowed(:access_description_composer)
+      true  | true  | true  | true  | false | be_disallowed(:access_description_composer)
+    end
+
+    with_them do
+      subject { policy_for(user) }
+
+      before do
+        allow(project)
+          .to receive_message_chain(:project_setting, :duo_features_enabled?)
+          .and_return(duo_features_enabled)
+
+        stub_feature_flags(mr_description_composer: feature_flag_enabled)
+
+        allow(::Gitlab::Llm::FeatureAuthorizer).to receive(:new).and_return(authorizer)
+        allow(authorizer).to receive(:allowed?).and_return(llm_authorized)
+
+        if user
+          allow(user).to receive(:allowed_to_use?)
+              .with(:description_composer, licensed_feature: :description_composer)
+              .and_return(allowed_to_use)
+        end
+      end
+
+      it { is_expected.to expected_result }
+    end
+  end
 end
diff --git a/ee/spec/services/llm/description_composer_service_spec.rb b/ee/spec/services/llm/description_composer_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..da09d24ea2ad766854a2d4cfc3939c39414a41aa
--- /dev/null
+++ b/ee/spec/services/llm/description_composer_service_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Llm::DescriptionComposerService, :saas, feature_category: :code_review_workflow do
+  let_it_be_with_refind(:group) { create(:group_with_plan, :public, plan: :ultimate_plan) }
+  let_it_be(:user) { create(:user) }
+  let_it_be_with_refind(:project) { create(:project, :public, group: group) }
+  let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+  let_it_be(:issue) { create(:issue, project: project) }
+  let_it_be(:options) { {} }
+
+  subject(:service) { described_class.new(user, merge_request, options) }
+
+  before do
+    stub_ee_application_setting(should_check_namespace_plan: true)
+    stub_licensed_features(description_composer: true, ai_features: true, experimental_features: true)
+
+    allow(user).to receive(:can?).with("read_merge_request", merge_request).and_call_original
+    allow(user).to receive(:can?).with("read_issue", issue).and_call_original
+    allow(user).to receive(:can?).with(:access_duo_features, merge_request.project).and_call_original
+    allow(user).to receive(:can?).with(:admin_all_resources).and_call_original
+
+    group.namespace_settings.update!(experiment_features_enabled: true)
+  end
+
+  describe '#execute' do
+    before do
+      allow(Llm::CompletionWorker).to receive(:perform_for)
+    end
+
+    context 'when the user is permitted to view the merge request' do
+      before_all do
+        group.add_developer(user)
+      end
+
+      before do
+        allow(user)
+          .to receive(:can?)
+          .with(:access_description_composer, merge_request)
+          .and_return(true)
+        allow(user).to receive(:allowed_to_use?).with(:description_composer).and_return(true)
+      end
+
+      it_behaves_like 'schedules completion worker' do
+        let(:action_name) { :description_composer }
+        let(:resource) { merge_request }
+      end
+    end
+
+    context 'when the user is not permitted to view the merge request' do
+      before do
+        allow(project).to receive(:member?).with(user).and_return(false)
+      end
+
+      it 'returns an error' do
+        expect(service.execute).to be_error
+
+        expect(Llm::CompletionWorker).not_to have_received(:perform_for)
+      end
+    end
+  end
+
+  describe '#valid?' do
+    using RSpec::Parameterized::TableSyntax
+
+    where(:access_description_composer, :issuable_type, :result) do
+      true   | :merge_request | true
+      false  | :merge_request | false
+      true   | :issue         | false
+      false  | :issue         | false
+    end
+
+    with_them do
+      let(:issuable) do
+        case issuable_type
+        when :merge_request then merge_request
+        when :issue then issue
+        end
+      end
+
+      before_all do
+        group.add_maintainer(user)
+      end
+
+      before do
+        allow(user)
+          .to receive(:can?)
+          .with(:access_description_composer, issuable)
+          .and_return(access_description_composer)
+        allow(user).to receive(:allowed_to_use?).with(:description_composer).and_return(true)
+      end
+
+      it { expect(described_class.new(user, issuable, options).valid?).to eq(result) }
+    end
+  end
+end