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