diff --git a/config/feature_flags/development/fill_in_mr_template.yml b/config/feature_flags/development/fill_in_mr_template.yml
new file mode 100644
index 0000000000000000000000000000000000000000..871d2d09f2ecad643e329a23680576910fe73987
--- /dev/null
+++ b/config/feature_flags/development/fill_in_mr_template.yml
@@ -0,0 +1,8 @@
+---
+name: fill_in_mr_template
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121233
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/412796
+milestone: '16.1'
+type: development
+group: group::code review
+default_enabled: false
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 810dae24f25093a6e57f39932ebed4ef8d4d623b..bdf5d557f99ad28ddcc100853ecc8d2c5ef4ab73 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1043,6 +1043,7 @@ Input type: `AiActionInput`
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `explainCode` | [`AiExplainCodeInput`](#aiexplaincodeinput) | Input for explain_code AI action. |
| `explainVulnerability` | [`AiExplainVulnerabilityInput`](#aiexplainvulnerabilityinput) | Input for explain_vulnerability AI action. |
+| `fillInMergeRequestTemplate` | [`AiFillInMergeRequestTemplateInput`](#aifillinmergerequesttemplateinput) | Input for fill_in_merge_request_template AI action. |
| `generateCommitMessage` | [`AiGenerateCommitMessageInput`](#aigeneratecommitmessageinput) | Input for generate_commit_message AI action. |
| `generateDescription` | [`AiGenerateDescriptionInput`](#aigeneratedescriptioninput) | Input for generate_description AI action. |
| `generateTestFile` | [`GenerateTestFileInput`](#generatetestfileinput) | Input for generate_test_file AI action. |
@@ -27918,6 +27919,19 @@ see the associated mutation type above.
| ---- | ---- | ----------- |
| `resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
+### `AiFillInMergeRequestTemplateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `content` | [`String!`](#string) | Template content to fill in. |
+| `resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. |
+| `sourceBranch` | [`String!`](#string) | Source branch of the changes. |
+| `sourceProjectId` | [`ID`](#id) | ID of the project where the changes are from. |
+| `targetBranch` | [`String!`](#string) | Target branch of where the changes will be merged into. |
+| `title` | [`String!`](#string) | Title of the merge request to be created. |
+
### `AiGenerateCommitMessageInput`
#### Arguments
diff --git a/ee/app/graphql/types/ai/fill_in_merge_request_template_input_type.rb b/ee/app/graphql/types/ai/fill_in_merge_request_template_input_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c1d0cf64e5432b74582c827b683f931203961522
--- /dev/null
+++ b/ee/app/graphql/types/ai/fill_in_merge_request_template_input_type.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Types
+ module Ai
+ class FillInMergeRequestTemplateInputType < BaseMethodInputType
+ graphql_name 'AiFillInMergeRequestTemplateInput'
+
+ argument :title, ::GraphQL::Types::String,
+ required: true,
+ description: 'Title of the merge request to be created.'
+
+ argument :source_project_id, ::GraphQL::Types::ID,
+ required: false,
+ description: 'ID of the project where the changes are from.'
+
+ argument :source_branch, ::GraphQL::Types::String,
+ required: true,
+ description: 'Source branch of the changes.'
+
+ argument :target_branch, ::GraphQL::Types::String,
+ required: true,
+ description: 'Target branch of where the changes will be merged into.'
+
+ argument :content, ::GraphQL::Types::String,
+ required: true,
+ description: 'Template content to fill in.'
+ end
+ end
+end
diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb
index a289c504c479fbb6bb18b3123548e4785a37df16..6bddc3d62a41966896b1f30573545e102b5d0637 100644
--- a/ee/app/models/gitlab_subscriptions/features.rb
+++ b/ee/app/models/gitlab_subscriptions/features.rb
@@ -182,6 +182,7 @@ class Features
api_fuzzing
auto_rollback
breach_and_attack_simulation
+ fill_in_merge_request_template
no_code_automation
ci_namespace_catalog
cluster_image_scanning
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index 2ca2644bba9921a21be324a439ed83d18732fdfa..d6dc53b60cbb7456c7c1c9856d7471a6b42cae06 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -230,6 +230,18 @@ module ProjectPolicy
@subject.can_suggest_reviewers?
end
+ with_scope :subject
+ condition(:ai_features_enabled) do
+ ::Feature.enabled?(:openai_experimentation)
+ end
+
+ with_scope :subject
+ condition(:fill_in_merge_request_template_enabled) do
+ ::Feature.enabled?(:fill_in_mr_template, subject) &&
+ subject.licensed_feature_available?(:fill_in_merge_request_template) &&
+ ::Gitlab::Llm::StageCheck.available?(subject.root_ancestor, :fill_in_merge_request_template)
+ end
+
rule { visual_review_bot }.policy do
prevent :read_note
enable :create_note
@@ -580,6 +592,10 @@ module ProjectPolicy
enable :create_pipeline
enable :push_code
end
+
+ rule do
+ ai_features_enabled & fill_in_merge_request_template_enabled & can?(:create_merge_request_in)
+ end.enable :fill_in_merge_request_template
end
override :lookup_access_level!
diff --git a/ee/app/services/llm/execute_method_service.rb b/ee/app/services/llm/execute_method_service.rb
index 9c8d018f66e03eb813238640a6a6b13aa0d18d93..c110fdffb5db68249ca5dac00275608f015d92ad 100644
--- a/ee/app/services/llm/execute_method_service.rb
+++ b/ee/app/services/llm/execute_method_service.rb
@@ -14,7 +14,8 @@ class ExecuteMethodService < BaseService
generate_test_file: Llm::GenerateTestFileService,
generate_description: Llm::GenerateDescriptionService,
generate_commit_message: Llm::GenerateCommitMessageService,
- chat: Llm::ChatService
+ chat: Llm::ChatService,
+ fill_in_merge_request_template: Llm::FillInMergeRequestTemplateService
}.freeze
def initialize(user, resource, method, options = {})
diff --git a/ee/app/services/llm/fill_in_merge_request_template_service.rb b/ee/app/services/llm/fill_in_merge_request_template_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1183ee9548cddb9171567fd4003e65db17c0229e
--- /dev/null
+++ b/ee/app/services/llm/fill_in_merge_request_template_service.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Llm
+ class FillInMergeRequestTemplateService < BaseService
+ extend ::Gitlab::Utils::Override
+
+ override :valid
+ def valid?
+ super &&
+ resource.is_a?(Project) &&
+ Ability.allowed?(user, :fill_in_merge_request_template, resource)
+ end
+
+ private
+
+ def perform
+ worker_perform(user, resource, :fill_in_merge_request_template, options)
+ end
+ end
+end
diff --git a/ee/lib/gitlab/llm/completions_factory.rb b/ee/lib/gitlab/llm/completions_factory.rb
index e19bcdaa3c452dd5d69d899769a2041f8bc97f9c..96c872643ce1fa10628336744a96cb81315106a6 100644
--- a/ee/lib/gitlab/llm/completions_factory.rb
+++ b/ee/lib/gitlab/llm/completions_factory.rb
@@ -39,6 +39,10 @@ class CompletionsFactory
chat: {
service_class: ::Gitlab::Llm::Completions::Chat,
prompt_class: nil
+ },
+ fill_in_merge_request_template: {
+ service_class: ::Gitlab::Llm::VertexAi::Completions::FillInMergeRequestTemplate,
+ prompt_class: ::Gitlab::Llm::Templates::FillInMergeRequestTemplate
}
}.freeze
diff --git a/ee/lib/gitlab/llm/stage_check.rb b/ee/lib/gitlab/llm/stage_check.rb
index 5d4125895f8b10a4500c62dcca78345bffa37a95..d497a7482d2dfb49b19bbb79adb320e6c393e3a6 100644
--- a/ee/lib/gitlab/llm/stage_check.rb
+++ b/ee/lib/gitlab/llm/stage_check.rb
@@ -12,7 +12,8 @@ class StageCheck
:summarize_diff,
:explain_vulnerability,
:generate_commit_message,
- :chat
+ :chat,
+ :fill_in_merge_request_template
].freeze
BETA_FEATURES = [].freeze
THIRD_PARTY_FEATURES = EXPERIMENTAL_FEATURES + BETA_FEATURES
diff --git a/ee/lib/gitlab/llm/templates/fill_in_merge_request_template.rb b/ee/lib/gitlab/llm/templates/fill_in_merge_request_template.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0a73340eff3aff5c31a245f0826fdcb0f0f14c4c
--- /dev/null
+++ b/ee/lib/gitlab/llm/templates/fill_in_merge_request_template.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Llm
+ module Templates
+ class FillInMergeRequestTemplate
+ include Gitlab::Utils::StrongMemoize
+
+ GIT_DIFF_PREFIX_REGEX = /\A@@( -\d+,\d+ \+\d+,\d+ )@@/
+
+ def initialize(user, project, params = {})
+ @user = user
+ @project = project
+ @params = params
+ end
+
+ def to_prompt
+ <<-PROMPT
+ You are an AI code assistant that can understand DIFF in Git diff format, TEMPLATE in a Markdown format and can produce Markdown as a result.
+
+ You will be given TITLE, DIFF, and TEMPLATE. Do the following:
+ 1. Create a merge request description from the given TEMPLATE.
+ 2. Given the TITLE and DIFF, explain the diff in detail and add it to the section in the description for explaining the DIFF.
+ 3. For sections with placeholder in the description, copy the content from TEMPLATE.
+ 4. Return the merge request description.
+
+ TITLE: #{params[:title]}
+
+ DIFF:
+ #{extracted_diff}
+
+ TEMPLATE:
+ #{content}
+ PROMPT
+ end
+
+ private
+
+ attr_reader :user, :project, :params
+
+ def extracted_diff
+ compare = CompareService
+ .new(source_project, params[:source_branch])
+ .execute(project, params[:target_branch])
+
+ return unless compare
+
+ # Extract only the diff strings and discard everything else
+ compare.raw_diffs.to_a.map do |raw_diff|
+ # Each diff string starts with information about the lines changed,
+ # bracketed by @@. Removing this saves us tokens.
+ #
+ # Ex: @@ -0,0 +1,58 @@\n+# frozen_string_literal: true\n+\n+module MergeRequests\n+
+ raw_diff.diff.sub(GIT_DIFF_PREFIX_REGEX, "")
+ end.join.truncate_words(2000)
+ end
+
+ def content
+ # We truncate words of the template content to 600 words so we can
+ # ensure that it fits the maxOutputTokens of Vertex AI which is set to
+ # 1024 in the client.
+ params[:content]&.truncate_words(600)
+ end
+
+ def source_project
+ return project unless params[:source_project_id]
+
+ source_project = Project.find_by_id(params[:source_project_id])
+
+ return source_project if source_project.present? && user.can?(:create_merge_request_from, source_project)
+
+ project
+ end
+ end
+ end
+ end
+end
diff --git a/ee/lib/gitlab/llm/vertex_ai/completions/fill_in_merge_request_template.rb b/ee/lib/gitlab/llm/vertex_ai/completions/fill_in_merge_request_template.rb
new file mode 100644
index 0000000000000000000000000000000000000000..58c4e25ddb7218c61572eaf762f893656a17e39b
--- /dev/null
+++ b/ee/lib/gitlab/llm/vertex_ai/completions/fill_in_merge_request_template.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Llm
+ module VertexAi
+ module Completions
+ class FillInMergeRequestTemplate < Gitlab::Llm::Completions::Base
+ def execute(user, project, options)
+ response = response_for(user, project, options)
+ response_modifier = ::Gitlab::Llm::VertexAi::ResponseModifiers::Predictions.new(response)
+
+ ::Gitlab::Llm::GraphqlSubscriptionResponseService.new(
+ user, project, response_modifier, options: { request_id: params[:request_id] }
+ ).execute
+ end
+
+ private
+
+ def response_for(user, project, options)
+ template = ai_prompt_class.new(user, project, options)
+ request(user, template)
+ end
+
+ def request(user, template)
+ ::Gitlab::Llm::VertexAi::Client
+ .new(user)
+ .text(content: template.to_prompt)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/gitlab/llm/templates/fill_in_merge_request_template_spec.rb b/ee/spec/lib/gitlab/llm/templates/fill_in_merge_request_template_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0e5fb445df20feb5fd639876c8047c558d3e4cb4
--- /dev/null
+++ b/ee/spec/lib/gitlab/llm/templates/fill_in_merge_request_template_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Llm::Templates::FillInMergeRequestTemplate, feature_category: :code_review_workflow do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
+ let(:source_project) { project }
+
+ let(:params) do
+ {
+ source_project_id: source_project.id,
+ source_branch: 'feature',
+ target_branch: 'master',
+ title: 'A merge request',
+ content: 'This is content'
+ }
+ end
+
+ subject { described_class.new(user, project, params) }
+
+ describe '#to_prompt' do
+ it 'includes title param' do
+ expect(subject.to_prompt).to include(params[:title])
+ end
+
+ it 'includes raw diff' do
+ expect(subject.to_prompt)
+ .to include("+class Feature\n+ def foo\n+ puts 'bar'\n+ end\n+end")
+ end
+
+ it 'includes the content' do
+ expect(subject.to_prompt).to include('This is content')
+ end
+
+ context 'when user cannot create merge request from source_project_id' do
+ let(:source_project) { create(:project) }
+
+ it 'includes diff comparison from project' do
+ expect(subject.to_prompt)
+ .to include("+class Feature\n+ def foo\n+ puts 'bar'\n+ end\n+end")
+ end
+ end
+
+ context 'when no source_project_id is specified' do
+ let(:params) do
+ {
+ source_branch: 'feature',
+ target_branch: 'master',
+ title: 'A merge request',
+ content: 'This is content'
+ }
+ end
+
+ it 'includes diff comparison from project' do
+ expect(subject.to_prompt)
+ .to include("+class Feature\n+ def foo\n+ puts 'bar'\n+ end\n+end")
+ end
+ end
+ end
+end
diff --git a/ee/spec/lib/gitlab/llm/vertex_ai/completions/fill_in_merge_request_template_spec.rb b/ee/spec/lib/gitlab/llm/vertex_ai/completions/fill_in_merge_request_template_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b09c5bf2025fd7f293ce52b7f5ce9d78f285f6e3
--- /dev/null
+++ b/ee/spec/lib/gitlab/llm/vertex_ai/completions/fill_in_merge_request_template_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Llm::VertexAi::Completions::FillInMergeRequestTemplate, feature_category: :code_review_workflow do
+ let(:prompt_class) { Gitlab::Llm::Templates::FillInMergeRequestTemplate }
+ let(:options) { { request_id: 'uuid' } }
+ let(:response_modifier) { double }
+ let(:response_service) { double }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:params) { [user, project, response_modifier, { options: { request_id: 'uuid' } }] }
+
+ subject { described_class.new(prompt_class, options) }
+
+ describe '#execute' do
+ context 'when the text client returns a successful response' do
+ let(:example_answer) { "AI filled in template" }
+
+ let(:example_response) do
+ {
+ "predictions" => [
+ {
+ "content" => example_answer,
+ "safetyAttributes" => {
+ "categories" => ["Violent"],
+ "scores" => [0.4000000059604645],
+ "blocked" => false
+ }
+ }
+ ]
+ }
+ end
+
+ before do
+ allow_next_instance_of(Gitlab::Llm::VertexAi::Client) do |client|
+ allow(client).to receive(:text).and_return(example_response.to_json)
+ end
+ end
+
+ it 'publishes the content from the AI response' do
+ expect(::Gitlab::Llm::VertexAi::ResponseModifiers::Predictions)
+ .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)
+
+ subject.execute(user, project, options)
+ end
+ end
+
+ context 'when the text client returns an unsuccessful response' do
+ let(:error) { { error: 'Error' } }
+
+ before do
+ allow_next_instance_of(Gitlab::Llm::VertexAi::Client) do |client|
+ allow(client).to receive(:text).and_return(error.to_json)
+ end
+ end
+
+ it 'publishes the error to the graphql subscription' do
+ expect(::Gitlab::Llm::VertexAi::ResponseModifiers::Predictions)
+ .to receive(:new)
+ .with(error.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)
+
+ subject.execute(user, project, options)
+ end
+ end
+ end
+end
diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb
index 51f46747700b5790006be951d89339b42aa6b8c3..fe2c76b139831a9bb53a99d5a1557fea13fb4419 100644
--- a/ee/spec/policies/project_policy_spec.rb
+++ b/ee/spec/policies/project_policy_spec.rb
@@ -2776,4 +2776,82 @@ def create_member_role(member, abilities = member_role_abilities)
end
end
end
+
+ describe 'fill_in_merge_request_template policy', :saas do
+ let_it_be(:namespace) { create(:group_with_plan, plan: :ultimate_plan) }
+ let_it_be(:project) { create(:project, group: namespace) }
+ let_it_be(:current_user) { owner }
+
+ before do
+ namespace.add_owner(owner)
+ allow(project).to receive(:namespace).and_return(namespace)
+ stub_ee_application_setting(should_check_namespace_plan: true)
+
+ stub_licensed_features(
+ fill_in_merge_request_template: true,
+ ai_features: true
+ )
+
+ stub_feature_flags(
+ openai_experimentation: true,
+ fill_in_mr_template: true
+ )
+
+ namespace.namespace_settings.update!(
+ experiment_features_enabled: true,
+ third_party_ai_features_enabled: true
+ )
+ end
+
+ it { is_expected.to be_allowed(:fill_in_merge_request_template) }
+
+ context 'when global AI feature flag is disabled' do
+ before do
+ stub_feature_flags(openai_experimentation: false)
+ end
+
+ it { is_expected.to be_disallowed(:fill_in_merge_request_template) }
+ end
+
+ context 'when fill_in_mr_template feature flag is disabled' do
+ before do
+ stub_feature_flags(
+ openai_experimentation: true,
+ fill_in_mr_template: false
+ )
+ end
+
+ it { is_expected.to be_disallowed(:fill_in_merge_request_template) }
+ end
+
+ context 'when license is not set' do
+ before do
+ stub_licensed_features(fill_in_merge_request_template: false)
+ end
+
+ it { is_expected.to be_disallowed(:fill_in_merge_request_template) }
+ end
+
+ context 'when experiment features are disabled' do
+ before do
+ namespace.namespace_settings.update!(experiment_features_enabled: false)
+ end
+
+ it { is_expected.to be_disallowed(:fill_in_merge_request_template) }
+ end
+
+ context 'when third party ai features are disabled' do
+ before do
+ namespace.namespace_settings.update!(third_party_ai_features_enabled: false)
+ end
+
+ it { is_expected.to be_disallowed(:fill_in_merge_request_template) }
+ end
+
+ context 'when user cannot create_merge_request_in' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_disallowed(:fill_in_merge_request_template) }
+ end
+ end
end
diff --git a/ee/spec/requests/api/graphql/mutations/projects/fill_in_merge_request_template_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/fill_in_merge_request_template_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..367d2d8ffa6dc8543d40d79ec13bd2ff03b20847
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/projects/fill_in_merge_request_template_spec.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe 'AiAction for Fill In Merge Request Template', :saas, feature_category: :code_review_workflow do
+ include GraphqlHelpers
+ include Graphql::Subscriptions::Notes::Helper
+
+ let_it_be(:group) { create(:group_with_plan, :public, plan: :ultimate_plan) }
+ let_it_be(:project) { create(:project, :public, group: group) }
+ let_it_be(:current_user) { create(:user, developer_projects: [project]) }
+
+ let(:mutation) do
+ params = {
+ fill_in_merge_request_template: {
+ resource_id: project.to_gid,
+ source_project_id: project.id,
+ source_branch: 'feature',
+ target_branch: 'master',
+ title: 'A merge request',
+ content: 'This is content'
+ }
+ }
+
+ graphql_mutation(:ai_action, params) do
+ <<-QL.strip_heredoc
+ errors
+ QL
+ end
+ end
+
+ before do
+ stub_ee_application_setting(should_check_namespace_plan: true)
+ stub_licensed_features(fill_in_merge_request_template: true, ai_features: true)
+ group.namespace_settings.update!(third_party_ai_features_enabled: true, experiment_features_enabled: true)
+ end
+
+ it 'successfully performs an explain code request' do
+ expect(Llm::CompletionWorker).to receive(:perform_async).with(
+ current_user.id,
+ project.id,
+ 'Project',
+ :fill_in_merge_request_template,
+ {
+ markup_format: :raw,
+ request_id: an_instance_of(String),
+ source_project_id: project.id.to_s,
+ source_branch: 'feature',
+ target_branch: 'master',
+ title: 'A merge request',
+ content: 'This is content'
+ }
+ )
+
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_mutation_response(:ai_action)['errors']).to eq([])
+ end
+
+ context 'when openai_experimentation feature flag is disabled' do
+ before do
+ stub_feature_flags(openai_experimentation: false)
+ end
+
+ it 'returns nil' do
+ expect(Llm::CompletionWorker).not_to receive(:perform_async)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(fresh_response_data['errors'][0]['message']).to eq("`openai_experimentation` feature flag is disabled.")
+ end
+ end
+
+ context 'when third_party_ai_features_enabled disabled' do
+ before do
+ group.namespace_settings.update!(third_party_ai_features_enabled: false)
+ end
+
+ it 'returns nil' do
+ expect(Llm::CompletionWorker).not_to receive(:perform_async)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+ end
+
+ context 'when experiment_features_enabled disabled' do
+ before do
+ group.namespace_settings.update!(experiment_features_enabled: false)
+ end
+
+ it 'returns nil' do
+ expect(Llm::CompletionWorker).not_to receive(:perform_async)
+
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+ end
+end
diff --git a/ee/spec/services/llm/fill_in_merge_request_template_service_spec.rb b/ee/spec/services/llm/fill_in_merge_request_template_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d0921248cafa1003cc632bf3713aafe8ae51dd9f
--- /dev/null
+++ b/ee/spec/services/llm/fill_in_merge_request_template_service_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Llm::FillInMergeRequestTemplateService, feature_category: :code_review_workflow do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:resource) { create(:project, :public, group: group) }
+
+ let(:fill_in_merge_request_template_enabled) { true }
+ let(:current_user) { user }
+
+ describe '#perform' do
+ before do
+ group.add_guest(user)
+
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability)
+ .to receive(:allowed?)
+ .with(user, :fill_in_merge_request_template, resource)
+ .and_return(fill_in_merge_request_template_enabled)
+ end
+
+ subject { described_class.new(current_user, resource, {}).execute }
+
+ it_behaves_like 'completion worker sync and async' do
+ subject { described_class.new(current_user, resource, options) }
+
+ let(:action_name) { :fill_in_merge_request_template }
+ let(:options) { {} }
+ let(:content) { 'Fill in merge request template' }
+ end
+
+ context 'when user is not member of project group' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to be_error.and have_attributes(message: eq(described_class::INVALID_MESSAGE)) }
+ end
+
+ context 'when general feature flag is disabled' do
+ before do
+ stub_feature_flags(openai_experimentation: false)
+ end
+
+ it { is_expected.to be_error.and have_attributes(message: eq(described_class::INVALID_MESSAGE)) }
+ end
+
+ context 'when resource is not a project' do
+ let(:resource) { create(:epic, group: group) }
+
+ it { is_expected.to be_error.and have_attributes(message: eq(described_class::INVALID_MESSAGE)) }
+ end
+
+ context 'when user has no ability to fill_in_merge_request_template' do
+ let(:fill_in_merge_request_template_enabled) { false }
+
+ it { is_expected.to be_error.and have_attributes(message: eq(described_class::INVALID_MESSAGE)) }
+ end
+ end
+end