diff --git a/ee/app/models/ai/amazon_q/request_payload.rb b/ee/app/models/ai/amazon_q/request_payload.rb new file mode 100644 index 0000000000000000000000000000000000000000..2cf8c05efb6976e4e1e3aa5570e14342c0d72966 --- /dev/null +++ b/ee/app/models/ai/amazon_q/request_payload.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class RequestPayload + PayloadGenerationError = Class.new(StandardError) + + def initialize( + command:, note:, source:, service_account_notes:, discussion_id:, input:, + line_position_for_comment: nil) + @command = command + @discussion_id = discussion_id + @line_position_for_comment = line_position_for_comment + @service_account_notes = service_account_notes + @source = source + @note = note + @input = input + end + + def payload + data = base_payload + data.merge!(note_payload) + + if source.is_a?(MergeRequest) + data.merge!(merge_request_payload) + data.merge!(test_command_payload) if command == 'test' + end + + data + rescue ArgumentError => e + Gitlab::AppLogger.error(message: "[amazon_q] #{e.class}: #{e.message}") + raise + end + + private + + attr_reader :command, :source, :line_position_for_comment, :service_account_notes, :discussion_id, :note, :input + + def base_payload + { + command: command, + source: source.issuable_type, + role_arn: ::Ai::Setting.instance.amazon_q_role_arn, + project_path: source.project.full_path, + project_id: source.project.id.to_s, + "#{source.issuable_type.underscore}_id": source.id.to_s, + "#{source.issuable_type.underscore}_iid": source.iid.to_s + } + end + + def merge_request_payload + { + source_branch: source.source_branch, + target_branch: source.target_branch, + last_commit_id: source.recent_commits&.first&.id + } + end + + def test_command_payload + { + start_sha: note.position.start_sha, + head_sha: note.position.head_sha, + file_path: note.position.new_path, + user_message: input&.to_s + }.merge(line_position_for_comment) + end + + def note_payload + note_reference = if use_existing_thread? + service_account_notes&.first + else + @progress_note + end + + { + note_id: note_reference&.id.to_s, + discussion_id: (note_reference&.discussion_id || discussion_id).to_s + } + end + + def use_existing_thread? + %w[dev fix review transform].include?(command) + end + end + end +end diff --git a/ee/app/services/ai/amazon_q/amazon_q_trigger_service.rb b/ee/app/services/ai/amazon_q/amazon_q_trigger_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..58021b33fa6931ded06b80e11f7fa7e7d5132de1 --- /dev/null +++ b/ee/app/services/ai/amazon_q/amazon_q_trigger_service.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class AmazonQTriggerService < BaseService + include ::Gitlab::Utils::StrongMemoize + + CloudConnectorTokenError = Class.new(StandardError) + ServiceAccountError = Class.new(StandardError) + CompositeIdentityEnforcedError = Class.new(StandardError) + MissingPrerequisiteError = Class.new(StandardError) + + REVIEW_FINDING_KEYWORDS = ["We detected", "We recommend", "Severity:"].freeze + + def initialize(user:, command:, source:, note: nil, discussion_id: nil, input: nil) + @user = user + @command = command + @input = input + @source = source + @note = note + @discussion_id = discussion_id + end + + attr_reader :user, :command, :source, :note, :discussion_id, :input + + def execute + validate_cloud_connector_token! + validate_service_account! + validate_source! + validate_command! + validate_code_position! if command == 'test' + + add_service_account_to_project + + SystemNoteService.amazon_q_called(source, user, command) + create_note_if_needed + + response = make_ai_gateway_request + handle_response(response) + response + rescue StandardError => e + Gitlab::AppLogger.error(message: "[amazon_q] Command #{command} encountered #{e.class.name}", error: e.message) + handle_note_error(e.message) + end + + private + + def make_ai_gateway_request + client = Gitlab::Llm::QAi::Client.new(user) + + client.create_event( + payload: payload, + auth_grant: create_auth_grant_new, + role_arn: ai_settings.amazon_q_role_arn + ) + end + + def service_name + :amazon_q_integration + end + + def cloud_connector_token + ::CloudConnector::AvailableServices.find_by_name(service_name).access_token + end + strong_memoize_attr :cloud_connector_token + + def handle_response(response) + return if response.success? + + update_failure_note + end + + def payload + ::Ai::AmazonQ::RequestPayload.new( + command: command, + source: source, + note: note, + service_account_notes: service_account_notes, + discussion_id: discussion_id, + input: input, + line_position_for_comment: line_position_for_comment + ).payload + end + strong_memoize_attr :payload + + def validate_source! + Ai::AmazonQValidateCommandSourceService.new(command: command, source: source).validate + end + + def validate_cloud_connector_token! + return if cloud_connector_token.present? + + raise CloudConnectorTokenError, "Unable to generate valid cloud connector token for #{service_name}" + end + + def validate_code_position! + position = note&.position + + raise ArgumentError, "Invalid code position" if position.nil? + raise ArgumentError, "Invalid code position" if position.start_sha.nil? || position.head_sha.nil? + raise ArgumentError, "Unknown code line position" unless line_position_for_comment + end + + def use_existing_thread? + %w[dev fix review transform].include?(command) + end + + def reply_only? + %w[fix].include?(command) + end + + def create_note_if_needed + return if use_existing_thread? + + create_note + end + + def line_position_for_comment + return unless note.position + + if note.position.line_range.present? + { + comment_start_line: note.position.line_range.dig("start", "new_line").to_s, + comment_end_line: note.position.line_range.dig("end", "new_line").to_s + } + elsif note.position.new_line.present? + { + comment_start_line: note.position.new_line.to_s, + comment_end_line: note.position.new_line.to_s + } + end + end + strong_memoize_attr :line_position_for_comment + + def create_auth_grant_new + OauthAccessGrant.create!( + resource_owner_id: ai_settings.amazon_q_service_account_user_id, + application_id: ai_settings.amazon_q_oauth_application_id, + redirect_uri: Gitlab::Routing.url_helpers.root_url, + expires_in: 1.hour, + scopes: Gitlab::Auth::Q_SCOPES + dynamic_user_scope + ).plaintext_token + end + + def dynamic_user_scope + ["user:#{user.id}"] + end + + def create_note + @progress_note = ::Ai::AmazonQ::CreateNoteService.new( + author: amazon_q_service_account, + note: note, + source: source, + command: command + ).execute + end + + def update_failure_note + if @progress_note.nil? + @progress_note = Notes::CreateService.new( + source.project, + amazon_q_service_account, + author: amazon_q_service_account, + noteable: source, + note: failure_message, + discussion_id: note&.discussion_id + ).execute + else + update_note_params = { note: failure_message } + + Notes::UpdateService.new( + source.project, + amazon_q_service_account, + update_note_params + ).execute(@progress_note) + end + end + + def failure_message + msg = s_("AmazonQ|Sorry, I'm not able to complete the request at this moment. Please try again later.") + request_id = Labkit::Correlation::CorrelationId.current_id + msg + format("\n\nRequest ID: %{request_id}", request_id: request_id) + end + + def amazon_q_service_account + User.find_by_id(ai_settings.amazon_q_service_account_user_id) + end + strong_memoize_attr :amazon_q_service_account + + def validate_service_account! + if amazon_q_service_account.blank? + raise ServiceAccountError, + "#{command} failed due to Amazon Q service account ID is not configured" + elsif !amazon_q_service_account.composite_identity_enforced? + raise CompositeIdentityEnforcedError, + "Cannot find the service account with composite identity enabled." + end + + true + end + + def add_service_account_to_project + ::Ai::AmazonQ::ServiceAccountMemberAddService.new(source.project).execute + end + + def validate_command! + # Check the discussions involved in the reply. + return true unless reply_only? + + # Filter only notes authored by the Amazon Q service user. + # Search for any of the keywords relating to review findings in the note. + comments = search_comments_by_service_account(REVIEW_FINDING_KEYWORDS) + return true unless comments.blank? + + raise MissingPrerequisiteError, + "#{command} can only be executed as a response to the review command" + end + + def search_comments_by_service_account(keywords = nil) + filtered_notes = service_account_notes + + return filtered_notes if keywords.blank? + + keywords = Array(keywords).map(&:downcase) + + filtered_notes.select do |note| + note_content = note.note.to_s.downcase + keywords.any? { |keyword| note_content.include?(keyword) } + end + end + + def handle_note_error(error_message) + return unless note + + note.errors.add(:quick_action, "command /q: #{error_message}") + end + + def service_account_notes + source.notes.authored_by(amazon_q_service_account).find_discussion(discussion_id)&.notes.to_a + end + strong_memoize_attr :service_account_notes + + def ai_settings + Ai::Setting.instance + end + strong_memoize_attr :ai_settings + end + end +end diff --git a/ee/app/services/ai/amazon_q/create_note_service.rb b/ee/app/services/ai/amazon_q/create_note_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..612d93c89b0bad0acb36d97bff1a3cdcbacf1f16 --- /dev/null +++ b/ee/app/services/ai/amazon_q/create_note_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class CreateNoteService + def initialize(author:, note:, source:, command:) + @author = author + @note = note + @source = source + @command = command + end + + def execute + return unless note + + Notes::UpdateService.new( + source.project, + author, + update_note_params + ).execute(new_note) + end + + private + + attr_reader :author, :note, :source, :command + + def new_note + # preserve attributes needed for diff notes (such as old/new line position) + note.dup + end + + def update_note_params + { note: generate_note_message, author: author } + end + + def generate_note_message + command_map = case source + when MergeRequest then q_merge_request_sub_commands + when Issue then q_issue_sub_commands + end + command_map&.[](command.to_sym) + end + + def q_issue_sub_commands + { + dev: s_("AmazonQ|I'm generating code for this issue. " \ + "I'll update this comment and open a merge request when I'm done."), + transform: s_("AmazonQ|I'm upgrading your code to Java 17. " \ + "I'll update this comment and open a merge request when I'm done.") + }.freeze + end + + def q_merge_request_sub_commands + { + dev: s_("AmazonQ|I'm revising this merge request based on your feedback. " \ + "I'll update this comment and this merge request when I'm done."), + fix: s_("AmazonQ|I'm generating a fix for this review finding. I'll update this comment when I'm done."), + test: s_("AmazonQ|:hourglass_flowing_sand: I'm creating unit tests for the selected lines of code. " \ + "I'll update this comment when I'm done."), + review: s_("AmazonQ|I'm reviewing this merge request for security vulnerabilities, " \ + "quality issues, and deficiencies. I'll provide an update when I'm done.") + }.freeze + end + end + end +end diff --git a/ee/app/services/ai/amazon_q/service_account_member_add_service.rb b/ee/app/services/ai/amazon_q/service_account_member_add_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..7210ddcfa0079029e4fd31aabdd430d3498eb798 --- /dev/null +++ b/ee/app/services/ai/amazon_q/service_account_member_add_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ai + module AmazonQ + class ServiceAccountMemberAddService + def initialize(project) + @project = project + end + + def execute + q_user_id = Ai::Setting.instance.amazon_q_service_account_user_id + + existing_member = project.member(q_user_id) + return ServiceResponse.success(message: "Membership already exists. Nothing to do.") if existing_member + + existing_user = User.find_by_id(q_user_id) + return ServiceResponse.error(message: "Service account user not found") unless existing_user + + result = project.add_developer(existing_user) + ServiceResponse.success(payload: result) + end + + private + + attr_reader :project + end + end +end diff --git a/ee/app/services/ai/amazon_q_validate_command_source_service.rb b/ee/app/services/ai/amazon_q_validate_command_source_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..73a34e919e4a2a3c56bbc44a6c620d451a8bbbe3 --- /dev/null +++ b/ee/app/services/ai/amazon_q_validate_command_source_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Ai + class AmazonQValidateCommandSourceService + UnsupportedCommandError = Class.new(StandardError) + UnsupportedSourceError = Class.new(StandardError) + + def initialize(command:, source:) + @command = command + @source = source + end + + def validate + case source + when Issue + command_list = ::Ai::AmazonQ::Commands::ISSUE_SUBCOMMANDS + message = "Unsupported issue command: #{command}" + raise UnsupportedCommandError, message unless command_list.include?(command) + when MergeRequest + command_list = ::Ai::AmazonQ::Commands::MERGE_REQUEST_SUBCOMMANDS + message = "Unsupported merge request command: #{command}" + raise UnsupportedCommandError, message unless command_list.include?(command) + + else + raise UnsupportedSourceError, "Unsupported source type: #{source.class}" + end + end + + private + + attr_reader :command, :source + end +end diff --git a/ee/lib/ee/gitlab/quick_actions/amazon_q_actions.rb b/ee/lib/ee/gitlab/quick_actions/amazon_q_actions.rb index e9a25b648ea76f19897d41acc9940496d7e00d85..24c2b818c16fa93e41987848ff8a90a08b2c2d27 100644 --- a/ee/lib/ee/gitlab/quick_actions/amazon_q_actions.rb +++ b/ee/lib/ee/gitlab/quick_actions/amazon_q_actions.rb @@ -26,18 +26,12 @@ module AmazonQActions end command :q do |input = "dev"| sub_command, *comment_words = input.strip.split(' ', 2) - case quick_action_target - when ::Issue - unless ::Ai::AmazonQ::Commands::ISSUE_SUBCOMMANDS.include?(sub_command) - @execution_message[:q] = "Could not apply Amazon Q command for issue" - next - end - when ::MergeRequest - unless ::Ai::AmazonQ::Commands::MERGE_REQUEST_SUBCOMMANDS.include?(sub_command) - @execution_message[:q] = "Could not apply Amazon Q command for merge request" - next - end - end + + ::Ai::AmazonQValidateCommandSourceService.new( + command: sub_command, + source: quick_action_target + ).validate + comment = comment_words.join(' ') action_data = { command: sub_command, @@ -47,6 +41,8 @@ module AmazonQActions } action_data[:input] = comment unless comment.empty? @updates[:amazon_q] = action_data + rescue Ai::AmazonQValidateCommandSourceService::StandardError => error + @execution_message[:q] = error.message end end end diff --git a/ee/lib/gitlab/llm/q_ai/client.rb b/ee/lib/gitlab/llm/q_ai/client.rb index 69f87247f3febfed4a6dc2cc566b5e8c554b9d6d..2ff1d66ebbda50e881c3456c6870abc8dcdbd2ec 100644 --- a/ee/lib/gitlab/llm/q_ai/client.rb +++ b/ee/lib/gitlab/llm/q_ai/client.rb @@ -18,18 +18,31 @@ def perform_create_auth_application(oauth_app, secret, role_arn) } Gitlab::HTTP.post( - "#{url}/v1/amazon_q/oauth/application", + url(path: "/v1/amazon_q/oauth/application"), body: payload.to_json, headers: request_headers ) end + def create_event(payload:, auth_grant:, role_arn:) + Gitlab::HTTP.post( + url(path: "/v1/amazon_q/events"), + body: { + payload: payload, + code: auth_grant, + role_arn: role_arn + }.to_json, + headers: request_headers + ) + end + private attr_reader :user - def url - Gitlab::AiGateway.url + def url(path:) + # use append_path to handle potential trailing slash in AI Gateway URL + Gitlab::Utils.append_path(Gitlab::AiGateway.url, path) end def service_name diff --git a/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb b/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb index f03b1cb112d85adc05b94d971144ca0e78378e77..62ba734c3fb4b94fa3c1907a54d9bb96f11984b2 100644 --- a/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb +++ b/ee/spec/lib/gitlab/llm/q_ai/client_spec.rb @@ -3,16 +3,50 @@ require 'spec_helper' RSpec.describe Gitlab::Llm::QAi::Client, feature_category: :ai_agents do - describe '#perform_create_auth_application' do - let_it_be(:user) { create(:user) } - let_it_be(:oauth_app) { create(:doorkeeper_application) } - let(:service_data) { instance_double(CloudConnector::SelfManaged::AvailableServiceData) } + let_it_be(:user) { create(:user) } + let_it_be(:oauth_app) { create(:doorkeeper_application) } + + let(:service_data) { instance_double(CloudConnector::SelfManaged::AvailableServiceData) } + + let(:cc_token) { 'cc_token' } + let(:response) { 'response' } + let(:role_arn) { 'role_arn' } + let(:secret) { 'secret' } + + describe '#create_event' do + subject(:create_event) do + described_class.new(user) + .create_event( + payload: {}, + auth_grant: '1234', + role_arn: '5678' + ) + end + + before do + stub_request(:post, "#{Gitlab::AiGateway.url}/v1/amazon_q/events") + .with(body: { + payload: {}, + code: '1234', + role_arn: '5678' + }.to_json).to_return(body: nil, status: 204) + end + + it 'makes expected HTTP post request' do + expect(service_data).to receive_messages( + name: 'amazon_q_integration', + access_token: 'cc_token' + ) + expect(::CloudConnector::AvailableServices).to receive(:find_by_name) + .with(:amazon_q_integration).and_return(service_data) - let(:cc_token) { 'cc_token' } - let(:response) { 'response' } - let(:role_arn) { 'role_arn' } - let(:secret) { 'secret' } + response = create_event + expect(response.code).to eq(204) + expect(response.body).to be_empty + end + end + describe '#perform_create_auth_application' do subject(:perform_create_auth_application) do described_class.new(user) .perform_create_auth_application(oauth_app, secret, role_arn) diff --git a/ee/spec/services/ai/amazon_q/amazon_q_trigger_service_spec.rb b/ee/spec/services/ai/amazon_q/amazon_q_trigger_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5e6a2541f3338af554bd6547b856f109390a5c20 --- /dev/null +++ b/ee/spec/services/ai/amazon_q/amazon_q_trigger_service_spec.rb @@ -0,0 +1,542 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::AmazonQ::AmazonQTriggerService, feature_category: :ai_agents do + let_it_be(:default_organization) { create(:organization, :default) } + let_it_be_with_reload(:service_account) { create(:user, :service_account, composite_identity_enforced: true) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:merge_request) { create(:merge_request_with_diffs, source_project: project) } + let_it_be(:oauth_app) { create(:doorkeeper_application) } + let(:response) { instance_double(HTTParty::Response, success?: true, parsed_response: nil) } + let(:source) { issue } + + before do + Ai::Setting.instance.update!( + amazon_q_service_account_user_id: service_account.id, + amazon_q_oauth_application_id: oauth_app.id + ) + end + + describe '#execute' do + let(:command) { 'dev' } + let(:source) { 'issue' } + let!(:note) { create(:note_on_issue, noteable: issue, project: project) } + let(:service) { described_class.new(user: user, command: command, source: source, note: note) } + let(:service_name) { :amazon_q_integration } + let(:service_data) do + instance_double(CloudConnector::BaseAvailableServiceData, + name: service_name, + access_token: SecureRandom.hex) + end + + let(:expected_payload) { anything } + let(:client) { instance_double(Gitlab::Llm::QAi::Client, create_event: response) } + + subject(:execution) { service.execute } + + before do + allow(Gitlab::Llm::QAi::Client).to receive(:new).with(user).and_return(client) + allow(::CloudConnector::AvailableServices).to receive(:find_by_name).with(service_name).and_return(service_data) + allow(SystemNoteService).to receive(:amazon_q_called) + end + + context 'with dev command' do + let(:command) { 'dev' } + + shared_examples 'successful dev execution' do + it 'creates an auth grant with the correct scopes', :aggregate_failures do + expect { execution }.to change { OauthAccessGrant.count }.by(1) + grant = OauthAccessGrant.find_by(resource_owner: service_account, application: oauth_app) + expect(grant.scopes.to_s).to eq("api read_repository write_repository user:#{user.id}") + end + + it 'executes successfully' do + expect { execution }.not_to change { Note.count } + expect(execution.parsed_response).to be_nil + expect(SystemNoteService).to have_received(:amazon_q_called).with(source, user, command) + end + end + + context 'with issue' do + let(:source) { issue } + + it_behaves_like 'successful dev execution' + + context 'when server returns a 500 error' do + let(:response) { instance_double(HTTParty::Response, success?: false, parsed_response: nil) } + let(:client) { instance_double(Gitlab::Llm::QAi::Client, create_event: response) } + + before do + allow(Gitlab::Llm::QAi::Client).to receive(:new).with(user).and_return(client) + end + + it 'updates a new note with an error' do + expect { execution }.to change { Note.count }.by(1) + expect(Note.last.note).to include( + "Sorry, I'm not able to complete the request at this moment. Please try again later") + expect(Note.last.note).to include("Request ID:") + end + end + end + + context 'with merge request' do + let(:source) { merge_request } + + it_behaves_like 'successful dev execution' + end + end + + context 'with fix command' do + let_it_be(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:command) { 'fix' } + let(:service) do + described_class.new(user: user, command: command, source: source, note: diff_note, + discussion_id: diff_note.discussion_id) + end + + let(:source) { merge_request } + + context 'when executes fix command after validation' do + before do + allow(service).to receive(:validate_command!) + end + + it 'creates an auth grant' do + expect { execution }.to change { OauthAccessGrant.count }.by(1) + end + + it 'executes successfully' do + expect { execution }.not_to change { Note.count } + expect(execution.parsed_response).to be_nil + expect(SystemNoteService).to have_received(:amazon_q_called).with(source, user, command) + end + end + + context 'when executes fix command with review findings' do + let(:command) { 'fix' } + let!(:diff_note) do + create(:diff_note_on_merge_request, noteable: merge_request, project: project, author: service_account, + note: 'We recommend to fix this security finding') + end + + let!(:service) do + described_class.new(user: user, command: command, source: source, note: diff_note, + discussion_id: diff_note.discussion_id) + end + + it 'executes successfully' do + expect { service.execute }.not_to raise_error + end + end + end + + context 'with test command' do + let_it_be(:diff_note) { build(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:command) { 'test' } + let(:service) do + described_class.new(user: user, command: command, source: source, note: diff_note, + discussion_id: diff_note.discussion_id) + end + + let(:source) { merge_request } + + let(:expected_payload) do + hash_including( + 'command' => 'test', + 'source' => 'merge_request', + 'merge_request_id' => merge_request.id.to_s, + 'merge_request_iid' => merge_request.iid.to_s, + 'note_id' => anything, + 'discussion_id' => diff_note.discussion_id, + 'source_branch' => merge_request.source_branch, + 'target_branch' => merge_request.target_branch, + 'last_commit_id' => merge_request.recent_commits.first.id, + 'comment_start_line' => diff_note.position.new_line.to_s, + 'comment_end_line' => diff_note.position.new_line.to_s, + 'start_sha' => diff_note.position.start_sha, + 'head_sha' => diff_note.position.head_sha, + 'file_path' => diff_note.position.new_path, + 'user_message' => anything + ) + end + + it 'creates an auth grant' do + expect { execution }.to change { OauthAccessGrant.count }.by(1) + end + + it 'executes successfully with the right payload' do + expect { execution }.to change { Note.count }.by(1) + expect(execution.parsed_response).to be_nil + end + end + + context 'when handle error' do + let(:command) { 'unsupported' } + let(:source) { issue } + let!(:service) do + described_class.new(user: user, command: command, source: source, note: note, + discussion_id: note.discussion_id) + end + + before do + allow(service).to receive(:handle_note_error) + allow(Gitlab::AppLogger).to receive(:error) + end + + context 'when UnsupportedCommandError is raised' do + let(:error_message) { "Unsupported issue command: #{command}" } + let(:error) do + Ai::AmazonQValidateCommandSourceService::UnsupportedCommandError.new("Unsupported issue command: #{command}") + end + + before do + allow(service).to receive(:validate_source!).and_raise(error) + end + + it 'logs the error and handles the note error' do + expect(Gitlab::AppLogger).to receive(:error).with( + message: "[amazon_q] Command #{command} encountered #{error.class.name}", + error: error_message + ) + expect(service).to receive(:handle_note_error).with(error_message) + + expect { execution }.not_to raise_error + end + end + end + + context 'when unable to generate a cloud connector access token' do + let(:service_data) do + instance_double(CloudConnector::BaseAvailableServiceData, + name: service_name, + access_token: nil) + end + + it 'raises CloudConnectorTokenError wth expected message' do + expect do + service.send(:validate_cloud_connector_token!) + end.to raise_error(described_class::CloudConnectorTokenError).with_message( + 'Unable to generate valid cloud connector token for amazon_q_integration' + ) + end + end + + describe '#payload' do + let(:note) { create(:note_on_issue, noteable: issue, project: project) } + let(:service) { described_class.new(user: user, command: 'dev', source: issue, note: note) } + + it 'generates the correct payload for an issue' do + service.execute + + payload = service.send(:payload) + + expect(payload.keys).to match_array( + %i[command source project_path project_id issue_id issue_iid discussion_id note_id role_arn]) + + expect(payload[:command]).to eq('dev') + expect(payload[:source]).to eq('issue') + expect(payload[:project_path]).to eq(project.full_path) + expect(payload[:project_id]).to eq(project.id.to_s) + expect(payload[:issue_id]).to eq(issue.id.to_s) + expect(payload[:issue_iid]).to eq(issue.iid.to_s) + end + end + end + + describe '#amazon_q_service_account' do + let(:service) { described_class.new(user: user, command: 'dev', source: issue) } + + context 'when service account exists' do + before do + Ai::Setting.instance.update!(amazon_q_service_account_user_id: service_account.id) + end + + it 'returns service account user object' do + service_account_user = service.send(:amazon_q_service_account) + + expect(service_account_user).to eq(service_account) + end + end + end + + describe '#validate_service_account!' do + let(:service) { described_class.new(user: user, command: 'dev', source: issue) } + + context 'when service account is properly configured' do + before do + Ai::Setting.instance.update!(amazon_q_service_account_user_id: service_account.id) + end + + it 'returns true' do + expect(service.send(:validate_service_account!)).to be true + end + end + + context 'when service account does not exist' do + before do + Ai::Setting.instance.update!(amazon_q_service_account_user_id: nil) + end + + it 'raises ServiceAccountError' do + expect { service.send(:validate_service_account!) } + .to raise_error( + Ai::AmazonQ::AmazonQTriggerService::ServiceAccountError, + 'dev failed due to Amazon Q service account ID is not configured' + ) + end + end + + context 'when service account does not have composite identity enabled' do + before do + service_account.update!(composite_identity_enforced: false) + end + + it 'raises CompositeIdentityEnforcedError' do + expect { service.send(:validate_service_account!) }.to raise_error( + Ai::AmazonQ::AmazonQTriggerService::CompositeIdentityEnforcedError, + "Cannot find the service account with composite identity enabled." + ) + end + end + end + + describe '#validate_command!' do + let(:note) { create(:note_on_issue, noteable: issue, project: project) } + let(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project).discussion } + let(:service) { described_class.new(user: user, command: 'dev', source: issue, note: discussion.notes.first) } + + context 'when not using an existing thread' do + it 'returns true' do + expect(service.send(:validate_command!)).to be_truthy + end + end + + context 'when using an existing thread' do + let!(:service) { described_class.new(user: user, command: 'fix', source: issue, note: discussion.notes.first) } + + context 'when there are no comments from the service account' do + before do + allow(service).to receive(:search_comments_by_service_account).and_return(nil) + end + + it 'raises a MissingPrerequisiteError' do + expect { service.send(:validate_command!) }.to raise_error( + Ai::AmazonQ::AmazonQTriggerService::MissingPrerequisiteError, + "fix can only be executed as a response to the review command" + ) + end + end + + context 'when there are comments from the service account' do + before do + allow(service).to receive(:search_comments_by_service_account).and_return(note) + end + + it 'returns true' do + expect(service.send(:validate_command!)).to be_truthy + end + end + end + end + + describe '#search_comments_by_service_account' do + let(:discussion) do + create(:discussion_note_on_issue, noteable: issue, project: issue.project, note: "test note", + author: service_account).discussion + end + + let(:service) do + described_class.new(user: user, command: 'dev', source: issue, note: discussion.notes.first, + discussion_id: discussion.notes.first.discussion_id) + end + + it 'filters notes by author_id' do + expect(service.send(:search_comments_by_service_account).count).to eq(1) + end + + context 'when keyword is blank' do + it 'returns notes filtered by author_id' do + expect(service.send(:search_comments_by_service_account, []).count).to eq(1) + end + end + + context 'when keyword is present' do + it 'filters notes by keyword' do + expect(service.send(:search_comments_by_service_account, ['test']).count).to eq(1) + end + + it 'returns empty array when not found keyword' do + expect(service.send(:search_comments_by_service_account, ['unknown'])).to be_empty + end + end + end + + describe '#handle_note_error' do + let!(:note) { create(:note_on_issue, noteable: issue, project: project) } + let(:error_message) { "Test error message" } + + context 'when note is already defined' do + let(:service) { described_class.new(user: user, command: 'dev', source: issue, note: note) } + + before do + allow(service).to receive(:note).and_return(note) + end + + it 'adds error to the existing note' do + error = service.send(:handle_note_error, error_message) + expect(error.type).to eq("command /q: #{error_message}") + end + end + end + + describe '#create_note' do + let(:original_note) { create(:note_on_issue, noteable: issue, project: project) } + let(:command) { 'dev' } + let(:generated_message) do + "I'm generating code for this issue. I'll update this comment and open a merge request when I'm done." + end + + let(:service) { described_class.new(user: user, command: command, source: source, note: original_note) } + let(:update_service) { instance_double(Notes::UpdateService) } + + before do + allow(service).to receive_messages( + amazon_q_service_account: service_account + ) + end + + context 'when note exists' do + before do + allow(Notes::UpdateService).to receive(:new) + .with(project, service_account, { note: generated_message, author: service_account }) + .and_return(update_service) + allow(update_service).to receive(:execute).and_return(original_note) + end + + it 'creates a new note with correct parameters' do + service.send(:create_note) + + expect(Notes::UpdateService).to have_received(:new) + .with(project, service_account, { note: generated_message, author: service_account }) + expect(update_service).to have_received(:execute).with(kind_of(Note)) + end + + it 'sets the progress note' do + service.send(:create_note) + + expect(service.instance_variable_get(:@progress_note)).to eq(original_note) + end + end + + context 'when note does not exist' do + before do + allow(service).to receive(:note).and_return(nil) + end + + it 'returns nil without creating a note' do + expect(Notes::UpdateService).not_to receive(:new) + + expect(service.send(:create_note)).to be_nil + end + end + end + + describe '#update_failure_note' do + let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project) } + + let(:service) { described_class.new(user: user, command: 'dev', source: issue, note: note) } + let(:failure_message) { 'Error occurred' } + + before do + allow(service).to receive(:failure_message).and_return(failure_message) + end + + context 'when progress note does not exist' do + let(:create_service) { instance_double(Notes::CreateService) } + let(:created_note) { create(:note) } + + before do + service.instance_variable_set(:@progress_note, nil) + + allow(Notes::CreateService).to receive(:new) + .with( + issue.project, + service_account, + { + author: service_account, + noteable: issue, + note: failure_message, + discussion_id: note.discussion_id + } + ).and_return(create_service) + allow(create_service).to receive(:execute).and_return(created_note) + end + + it 'creates a new note with failure message' do + service.send(:update_failure_note) + + expect(Notes::CreateService).to have_received(:new) + .with( + issue.project, + service_account, + { + author: service_account, + noteable: issue, + note: failure_message, + discussion_id: note.discussion_id + } + ) + expect(create_service).to have_received(:execute) + end + + it 'sets the progress note to the newly created note' do + service.send(:update_failure_note) + + expect(service.instance_variable_get(:@progress_note)).to eq(created_note) + end + end + + context 'when note is nil' do + let(:service) { described_class.new(user: user, command: 'dev', source: issue, note: nil) } + let(:create_service) { instance_double(Notes::CreateService) } + let(:created_note) { create(:note) } + + before do + service.instance_variable_set(:@progress_note, nil) + + allow(Notes::CreateService).to receive(:new) + .with( + issue.project, + service_account, + { + author: service_account, + noteable: issue, + note: failure_message, + discussion_id: nil + } + ).and_return(create_service) + allow(create_service).to receive(:execute).and_return(created_note) + end + + it 'creates a new note without discussion_id' do + service.send(:update_failure_note) + + expect(Notes::CreateService).to have_received(:new) + .with( + issue.project, + service_account, + { + author: service_account, + noteable: issue, + note: failure_message, + discussion_id: nil + } + ) + expect(create_service).to have_received(:execute) + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b4c72a11a78b5167aa0705ceac8d7e9ec6c1f61b..de5f3ac1f143c62b01a278284c49787863a8a47d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5920,6 +5920,9 @@ msgstr "" msgid "Amazon Q" msgstr "" +msgid "AmazonQ|:hourglass_flowing_sand: I'm creating unit tests for the selected lines of code. I'll update this comment when I'm done." +msgstr "" + msgid "AmazonQ|Active cloud connector token not found." msgstr "" @@ -6001,6 +6004,21 @@ msgstr "" msgid "AmazonQ|I understand that by selecting Save changes, GitLab creates a service account for Amazon Q and sends its credentials to AWS. Use of the Amazon Q Developer capabilities as part of GitLab Duo with Amazon Q is governed by the %{helpStart}AWS Customer Agreement%{helpEnd} or other written agreement between you and AWS governing your use of AWS services." msgstr "" +msgid "AmazonQ|I'm generating a fix for this review finding. I'll update this comment when I'm done." +msgstr "" + +msgid "AmazonQ|I'm generating code for this issue. I'll update this comment and open a merge request when I'm done." +msgstr "" + +msgid "AmazonQ|I'm reviewing this merge request for security vulnerabilities, quality issues, and deficiencies. I'll provide an update when I'm done." +msgstr "" + +msgid "AmazonQ|I'm revising this merge request based on your feedback. I'll update this comment and this merge request when I'm done." +msgstr "" + +msgid "AmazonQ|I'm upgrading your code to Java 17. I'll update this comment and open a merge request when I'm done." +msgstr "" + msgid "AmazonQ|IAM role's ARN" msgstr "" @@ -6034,6 +6052,9 @@ msgstr "" msgid "AmazonQ|Something went wrong saving Amazon Q settings." msgstr "" +msgid "AmazonQ|Sorry, I'm not able to complete the request at this moment. Please try again later." +msgstr "" + msgid "AmazonQ|Status" msgstr ""