diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 760012006745a87522eab539548cc7c43e184de3..df65f46798c0baa5c1acd199278e589874e2f230 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1418,6 +1418,7 @@ Input type: `AiActionInput` | <a id="mutationaiactiongeneratedescription"></a>`generateDescription` | [`AiGenerateDescriptionInput`](#aigeneratedescriptioninput) | Input for generate_description AI action. | | <a id="mutationaiactionresolvevulnerability"></a>`resolveVulnerability` | [`AiResolveVulnerabilityInput`](#airesolvevulnerabilityinput) | Input for resolve_vulnerability AI action. | | <a id="mutationaiactionsummarizecomments"></a>`summarizeComments` | [`AiSummarizeCommentsInput`](#aisummarizecommentsinput) | Input for summarize_comments AI action. | +| <a id="mutationaiactionsummarizenewmergerequest"></a>`summarizeNewMergeRequest` | [`AiSummarizeNewMergeRequestInput`](#aisummarizenewmergerequestinput) | Input for summarize_new_merge_request AI action. | | <a id="mutationaiactionsummarizereview"></a>`summarizeReview` | [`AiSummarizeReviewInput`](#aisummarizereviewinput) | Input for summarize_review AI action. | #### Fields @@ -34985,6 +34986,19 @@ see the associated mutation type above. | ---- | ---- | ----------- | | <a id="aisummarizecommentsinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. | +### `AiSummarizeNewMergeRequestInput` + +Summarize a new merge request based on two branches. Returns `null` if the `add_ai_summary_for_new_mr` feature flag is disabled. + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="aisummarizenewmergerequestinputresourceid"></a>`resourceId` | [`AiModelID!`](#aimodelid) | Global ID of the resource to mutate. | +| <a id="aisummarizenewmergerequestinputsourcebranch"></a>`sourceBranch` | [`String!`](#string) | Source branch of the changes. | +| <a id="aisummarizenewmergerequestinputsourceprojectid"></a>`sourceProjectId` | [`ID`](#id) | ID of the project where the changes are from. | +| <a id="aisummarizenewmergerequestinputtargetbranch"></a>`targetBranch` | [`String!`](#string) | Target branch of where the changes will be merged into. | + ### `AiSummarizeReviewInput` #### Arguments diff --git a/ee/app/graphql/types/ai/summarize_new_merge_request_input_type.rb b/ee/app/graphql/types/ai/summarize_new_merge_request_input_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..f83ffea88b7a3618840b8b9373b11e58d6d03f48 --- /dev/null +++ b/ee/app/graphql/types/ai/summarize_new_merge_request_input_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Ai + class SummarizeNewMergeRequestInputType < BaseMethodInputType + graphql_name 'AiSummarizeNewMergeRequestInput' + description "Summarize a new merge request based on two branches. " \ + "Returns `null` if the `add_ai_summary_for_new_mr` feature flag is disabled." + + 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.' + end + end +end diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb index a12cf9f01f2dec1013993a52647b0b79d3ffef62..57290eee3e6e8e47199b58f7a32f1002caa45e17 100644 --- a/ee/app/models/gitlab_subscriptions/features.rb +++ b/ee/app/models/gitlab_subscriptions/features.rb @@ -245,6 +245,7 @@ class Features ssh_key_expiration_policy summarize_mr_changes summarize_my_mr_code_review + summarize_new_merge_request summarize_notes summarize_submitted_review stale_runner_cleanup_for_namespace diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index a76cafec400edf27816ade8c32f5d577d4178000..bc321761bf2b70d030c055c270d19c848f1a46f7 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -327,6 +327,16 @@ module ProjectPolicy ).allowed? end + with_scope :subject + condition(:summarize_new_merge_request_enabled) do + ::Feature.enabled?(:add_ai_summary_for_new_mr, subject) && + ::Gitlab::Llm::FeatureAuthorizer.new( + container: subject, + current_user: user, + feature_name: :summarize_new_merge_request + ).allowed? + end + with_scope :subject condition(:generate_description_enabled) do ::Gitlab::Llm::FeatureAuthorizer.new( @@ -864,6 +874,10 @@ module ProjectPolicy fill_in_merge_request_template_enabled & can?(:create_merge_request_in) end.enable :fill_in_merge_request_template + rule do + summarize_new_merge_request_enabled & can?(:create_merge_request_in) + end.enable :summarize_new_merge_request + rule do generate_description_enabled & can?(:create_issue) end.enable :generate_description diff --git a/ee/app/services/llm/execute_method_service.rb b/ee/app/services/llm/execute_method_service.rb index e989690d90980085addcc0a857b9c780879bf9c0..d200570a13447416bc88c859c44f89d470738fa4 100644 --- a/ee/app/services/llm/execute_method_service.rb +++ b/ee/app/services/llm/execute_method_service.rb @@ -10,6 +10,7 @@ class ExecuteMethodService < BaseService resolve_vulnerability: ::Llm::ResolveVulnerabilityService, summarize_comments: Llm::GenerateSummaryService, summarize_review: Llm::MergeRequests::SummarizeReviewService, + summarize_new_merge_request: Llm::SummarizeNewMergeRequestService, explain_code: Llm::ExplainCodeService, generate_description: Llm::GenerateDescriptionService, generate_commit_message: Llm::GenerateCommitMessageService, diff --git a/ee/app/services/llm/summarize_new_merge_request_service.rb b/ee/app/services/llm/summarize_new_merge_request_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..354cd667cf558ee4d331fd6cdefa3ebafb95b8ee --- /dev/null +++ b/ee/app/services/llm/summarize_new_merge_request_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Llm + class SummarizeNewMergeRequestService < ::Llm::BaseService + def valid? + super && + resource.is_a?(Project) && + Ability.allowed?(user, :summarize_new_merge_request, resource) + end + + private + + def ai_action + :summarize_new_merge_request + end + + def perform + schedule_completion_worker + end + end +end diff --git a/ee/config/feature_flags/development/add_ai_summary_for_new_mr.yml b/ee/config/feature_flags/development/add_ai_summary_for_new_mr.yml new file mode 100644 index 0000000000000000000000000000000000000000..2071f95b4dbf368392035bfa49cdea840690c61e --- /dev/null +++ b/ee/config/feature_flags/development/add_ai_summary_for_new_mr.yml @@ -0,0 +1,9 @@ +--- +name: add_ai_summary_for_new_mr +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/429882 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/142739 +rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/production/-/issues/17533 +milestone: '16.9' +group: group::code review +type: development +default_enabled: false diff --git a/ee/lib/gitlab/llm/completions_factory.rb b/ee/lib/gitlab/llm/completions_factory.rb index 49bf6d48d607b5965e03566d131e984eeb616425..a4b3eb34016ee71377a9df96e0fabb232a693f9b 100644 --- a/ee/lib/gitlab/llm/completions_factory.rb +++ b/ee/lib/gitlab/llm/completions_factory.rb @@ -64,6 +64,11 @@ class CompletionsFactory prompt_class: ::Gitlab::Llm::Templates::SummarizeMergeRequest, feature_category: :code_review_workflow }, + summarize_new_merge_request: { + service_class: ::Gitlab::Llm::VertexAi::Completions::SummarizeNewMergeRequest, + prompt_class: ::Gitlab::Llm::Templates::SummarizeNewMergeRequest, + feature_category: :code_review_workflow + }, generate_cube_query: { service_class: ::Gitlab::Llm::VertexAi::Completions::GenerateCubeQuery, prompt_class: ::Gitlab::Llm::VertexAi::Templates::GenerateCubeQuery, diff --git a/ee/lib/gitlab/llm/stage_check.rb b/ee/lib/gitlab/llm/stage_check.rb index 424d1c2d2cd92aa249dbe2f3feb32b3786dc133e..486106de6c0f353753d02a6cc6defbd206cab9e0 100644 --- a/ee/lib/gitlab/llm/stage_check.rb +++ b/ee/lib/gitlab/llm/stage_check.rb @@ -14,6 +14,7 @@ class StageCheck :resolve_vulnerability, :generate_commit_message, :fill_in_merge_request_template, + :summarize_new_merge_request, :summarize_submitted_review ].freeze BETA_FEATURES = [:chat].freeze diff --git a/ee/lib/gitlab/llm/templates/summarize_new_merge_request.rb b/ee/lib/gitlab/llm/templates/summarize_new_merge_request.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d9e8287d8792f92a520442cbaf1242ce9b8a6f6 --- /dev/null +++ b/ee/lib/gitlab/llm/templates/summarize_new_merge_request.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + module Templates + class SummarizeNewMergeRequest + include Gitlab::Utils::StrongMemoize + + CHARACTER_LIMIT = 2000 + + def initialize(user, project, params = {}) + @user = user + @project = project + @params = params + end + + def to_prompt + return if extracted_diff.blank? + + <<~PROMPT + You are a code assistant, developed to help summarize code in non-technical terms. + + ``` + #{extracted_diff} + ``` + + The code above, enclosed by three ticks, is the code diff of a merge request. + + Write a summary of the changes in couple sentences, the way an expert engineer would summarize the + changes using simple - generally non-technical - terms. + + You MUST ensure that it is no longer than 1800 characters. A character is considered anything, not only + letters. + 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+ + + next if raw_diff.diff.encoding != Encoding::UTF_8 || raw_diff.has_binary_notice? + + diff_output(raw_diff.old_path, raw_diff.new_path, raw_diff.diff.sub(Gitlab::Regex.git_diff_prefix, "")) + end.join.truncate_words(CHARACTER_LIMIT) + end + strong_memoize_attr :extracted_diff + + def diff_output(old_path, new_path, diff) + <<~DIFF + --- #{old_path} + +++ #{new_path} + #{diff} + DIFF + 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/summarize_new_merge_request.rb b/ee/lib/gitlab/llm/vertex_ai/completions/summarize_new_merge_request.rb new file mode 100644 index 0000000000000000000000000000000000000000..2078a8d6299d490aba4af0f1636a8ddf901b809c --- /dev/null +++ b/ee/lib/gitlab/llm/vertex_ai/completions/summarize_new_merge_request.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + module VertexAi + module Completions + class SummarizeNewMergeRequest < Gitlab::Llm::Completions::Base + def execute + response = response_for(user, project, options) + response_modifier = ::Gitlab::Llm::VertexAi::ResponseModifiers::Predictions.new(response) + + ::Gitlab::Llm::GraphqlSubscriptionResponseService.new( + user, project, response_modifier, options: response_options + ).execute + + response_modifier + end + + private + + def project + resource + end + + 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, tracking_context: tracking_context) + .text(content: template.to_prompt) + end + end + end + end + end +end diff --git a/ee/spec/factories/ai_messages.rb b/ee/spec/factories/ai_messages.rb index 50cc1a0cc6b5ea0a094f76e845b5ad499e10e1ff..578cb6c0d3169195746ced289e36aa23b9711904 100644 --- a/ee/spec/factories/ai_messages.rb +++ b/ee/spec/factories/ai_messages.rb @@ -60,6 +60,10 @@ ai_action { :fill_in_merge_request_template } end + trait :summarize_new_merge_request do + ai_action { :summarize_new_merge_request } + end + trait :generate_commit_message do ai_action { :generate_commit_message } end diff --git a/ee/spec/lib/gitlab/llm/templates/summarize_new_merge_request_spec.rb b/ee/spec/lib/gitlab/llm/templates/summarize_new_merge_request_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..28dc3ef056e9a1f63cb51a44c2e7bf0c5fb1c05f --- /dev/null +++ b/ee/spec/lib/gitlab/llm/templates/summarize_new_merge_request_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Llm::Templates::SummarizeNewMergeRequest, feature_category: :code_review_workflow do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.owner } + + let(:source_project) { project } + let(:source_branch) { 'feature' } + let(:target_branch) { 'master' } + + describe '#to_prompt' do + let(:params) do + { + source_project_id: source_project.id, + source_branch: source_branch, + target_branch: target_branch + } + end + + subject(:template) { described_class.new(user, project, params) } + + shared_examples_for 'prompt without errors' do + it "returns a prompt with diff" do + expect(template.to_prompt) + .to include("+class Feature\n+ def foo\n+ puts 'bar'\n+ end\n+end") + end + end + + it_behaves_like "prompt without errors" + + it 'is under the character limit' do + expect(template.to_prompt.size).to be <= described_class::CHARACTER_LIMIT + end + + context 'when user cannot create merge request from source_project_id' do + let_it_be(:source_project) { create(:project) } + + it_behaves_like "prompt without errors" + end + + context 'when no source_project_id is specified' do + let(:params) do + { + source_project_id: nil, + source_branch: source_branch, + target_branch: target_branch + } + end + + it_behaves_like "prompt without errors" + end + + context "when there is a diff with an edge case" do + let(:good_diff) { { diff: "@@ -0,0 +1 @@hellothere\n+🌚\n" } } + let(:compare) { instance_double(Compare) } + + before do + allow(CompareService).to receive_message_chain(:new, :execute).and_return(compare) + end + + context 'when a diff is not encoded with UTF-8' do + let(:other_diff) do + { diff: "@@ -1 +1 @@\n-This should not be in the prompt\n+#{(0..255).map(&:chr).join}\n" } + end + + let(:diff_files) { Gitlab::Git::DiffCollection.new([good_diff, other_diff]) } + + it 'does not raise any error and not contain the non-UTF diff' do + allow(compare).to receive(:raw_diffs).and_return(diff_files) + + expect { template.to_prompt }.not_to raise_error + + expect(template.to_prompt).to include("hellothere") + expect(template.to_prompt).not_to include("This should not be in the prompt") + end + end + + context 'when a diff contains the binary notice' do + let(:binary_message) { Gitlab::Git::Diff.binary_message('a', 'b') } + let(:other_diff) { { diff: binary_message } } + let(:diff_files) { Gitlab::Git::DiffCollection.new([good_diff, other_diff]) } + + it 'does not contain the binary diff' do + allow(compare).to receive(:raw_diffs).and_return(diff_files) + + expect(template.to_prompt).to include("hellothere") + expect(template.to_prompt).not_to include(binary_message) + end + end + + context 'when extracted diff is blank' do + before do + allow(template).to receive(:extracted_diff).and_return([]) + end + + it 'returns nil' do + expect(template.to_prompt).to be_nil + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/llm/vertex_ai/completions/summarize_new_merge_request_spec.rb b/ee/spec/lib/gitlab/llm/vertex_ai/completions/summarize_new_merge_request_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..563d994896e8a5e15adc1b8fae0933e7d67b6aeb --- /dev/null +++ b/ee/spec/lib/gitlab/llm/vertex_ai/completions/summarize_new_merge_request_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Llm::VertexAi::Completions::SummarizeNewMergeRequest, feature_category: :code_review_workflow do + let(:prompt_class) { Gitlab::Llm::Templates::SummarizeNewMergeRequest } + let(:options) { {} } + let(:response_modifier) { double } + let(:response_service) { double } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let(:params) do + [user, project, response_modifier, { options: { ai_action: :summarize_new_merge_request, request_id: 'uuid' } }] + end + + let(:prompt_message) do + build(:ai_message, :summarize_new_merge_request, user: user, resource: project, request_id: 'uuid') + end + + let(:completion) { described_class.new(prompt_message, prompt_class, options) } + + describe '#execute' do + context 'when the text client returns a successful response' do + let(:example_answer) { "Super cool merge request summary" } + + 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) + + completion.execute + 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) + + completion.execute + end + end + end +end diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index 2719043098047c24bacb33257490e779a17b9fbf..ff5e4d373c1585aef54a98fa0e7f81c047666954 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -3066,6 +3066,49 @@ def create_member_role(member, abilities = member_role_abilities) end end + describe 'summarize_new_merge_request policy' do + let_it_be(:namespace) { group } + let_it_be(:project) { private_project } + let_it_be(:current_user) { maintainer } + + let(:authorizer) { instance_double(::Gitlab::Llm::FeatureAuthorizer) } + + before do + allow(::Gitlab::Llm::FeatureAuthorizer).to receive(:new).and_return(authorizer) + allow(project).to receive(:namespace).and_return(namespace) + end + + context "when feature is authorized" do + before do + allow(authorizer).to receive(:allowed?).and_return(true) + end + + it { is_expected.to be_allowed(:summarize_new_merge_request) } + + context 'when add_ai_summary_for_new_mr feature flag is disabled' do + before do + stub_feature_flags(add_ai_summary_for_new_mr: false) + end + + it { is_expected.to be_disallowed(:summarize_new_merge_request) } + end + + context 'when user cannot create_merge_request_in' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(:summarize_new_merge_request) } + end + end + + context "when feature is not authorized" do + before do + allow(authorizer).to receive(:allowed?).and_return(false) + end + + it { is_expected.to be_disallowed(:summarize_new_merge_request) } + end + end + describe 'admin_target_branch_rule policy' do let(:current_user) { owner } diff --git a/ee/spec/requests/api/graphql/mutations/projects/summarize_new_merge_request_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/summarize_new_merge_request_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..140d28ee8cdf5c3145146aff033e7641ec5d0f09 --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/projects/summarize_new_merge_request_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe 'AiAction for Summarize New Merge Request', :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 = { + summarize_new_merge_request: { + resource_id: project.to_gid, + source_branch: 'feature', + target_branch: 'master' + } + } + + 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(summarize_new_merge_request: true, ai_features: true, experimental_features: true) + group.namespace_settings.update!(experiment_features_enabled: true) + end + + before_all do + group.add_developer(current_user) + end + + it 'successfully performs an explain code request' do + expect(Llm::CompletionWorker).to receive(:perform_for).with( + an_object_having_attributes( + user: current_user, + resource: project, + ai_action: :summarize_new_merge_request), + hash_including( + source_branch: 'feature', + target_branch: 'master' + ) + ) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_mutation_response(:ai_action)['errors']).to eq([]) + end + + context 'when ai_global_switch feature flag is disabled' do + before do + stub_feature_flags(ai_global_switch: false) + end + + it 'returns nil' do + expect(Llm::CompletionWorker).not_to receive(:perform_for) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(fresh_response_data['errors'][0]['message']).to eq("required feature flag is disabled.") + 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_for) + + post_graphql_mutation(mutation, current_user: current_user) + end + end +end diff --git a/ee/spec/services/llm/summarize_new_merge_request_service_spec.rb b/ee/spec/services/llm/summarize_new_merge_request_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2f029d248d9e934eac5b4d353ef1deba2e20bce0 --- /dev/null +++ b/ee/spec/services/llm/summarize_new_merge_request_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Llm::SummarizeNewMergeRequestService, :saas, feature_category: :code_review_workflow do + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) } + let_it_be(:project) { create(:project, :public, group: group) } + + let(:summarize_new_merge_request_enabled) { true } + let(:current_user) { user } + + describe '#perform' do + include_context 'with ai features enabled for group' + + before_all do + group.add_guest(user) + end + + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability) + .to receive(:allowed?) + .with(user, :summarize_new_merge_request, project) + .and_return(summarize_new_merge_request_enabled) + end + + subject { described_class.new(current_user, project, {}).execute } + + it_behaves_like 'schedules completion worker' do + subject { described_class.new(current_user, project, options) } + + let(:options) { {} } + let(:resource) { project } + let(:action_name) { :summarize_new_merge_request } + 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(ai_global_switch: false) + end + + it { is_expected.to be_error.and have_attributes(message: eq(described_class::INVALID_MESSAGE)) } + end + + context 'when project is not a project' do + let(:project) { 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 summarize_new_merge_request' do + let(:summarize_new_merge_request_enabled) { false } + + it { is_expected.to be_error.and have_attributes(message: eq(described_class::INVALID_MESSAGE)) } + end + end +end