diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index 6f83978841d9737ba643fa7a3dcfd2e641f71e2c..2805a9a7ccc7d596c6356ed90a2decd1e771b465 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -5,27 +5,30 @@ class MergeRequestSerializer < BaseSerializer # to serialize the `merge_request` based on `serializer` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(merge_request, opts = {}, entity = nil) - entity ||= - case opts[:serializer] - when 'sidebar' - MergeRequestSidebarBasicEntity - when 'sidebar_extras' - MergeRequestSidebarExtrasEntity - when 'basic' - MergeRequestBasicEntity - when 'noteable' - MergeRequestNoteableEntity - when 'poll_cached_widget' - MergeRequestPollCachedWidgetEntity - when 'poll_widget' - MergeRequestPollWidgetEntity - else - # fallback to widget for old poll requests without `serializer` set - MergeRequestWidgetEntity - end + entity ||= identified_entity(opts) super(merge_request, opts, entity) end + + def identified_entity(opts) + case opts[:serializer] + when 'sidebar' + MergeRequestSidebarBasicEntity + when 'sidebar_extras' + MergeRequestSidebarExtrasEntity + when 'basic' + MergeRequestBasicEntity + when 'noteable' + MergeRequestNoteableEntity + when 'poll_cached_widget' + MergeRequestPollCachedWidgetEntity + when 'poll_widget' + MergeRequestPollWidgetEntity + else + # fallback to widget for old poll requests without `serializer` set + MergeRequestWidgetEntity + end + end end MergeRequestSerializer.prepend_mod diff --git a/doc/user/gitlab_duo_chat/index.md b/doc/user/gitlab_duo_chat/index.md index 8c4384a5b29d609c0682d90313da49e63293dd7a..41e315b08a0903d0088b898f68fa554fa4b7ddf2 100644 --- a/doc/user/gitlab_duo_chat/index.md +++ b/doc/user/gitlab_duo_chat/index.md @@ -51,11 +51,12 @@ Other times, you must be more specific with your request. In the GitLab UI, GitLab Duo Chat knows about these areas: -| Area | How to ask Chat | -|---------|------------------| -| Epics | From the epic, ask about `this epic`, `this`, or the URL. From any UI area, ask about the URL. | -| Issues | From the issue, ask about `this issue`, `this`, or the URL. From any UI area, ask about the URL. | -| Code files | From the single file, ask about `this code` or `this file`. From any UI area, ask about the URL. | +| Area | How to ask Chat | +|---------------|------------------------------------------------------------------------------------------------------------------| +| Epics | From the epic, ask about `this epic`, `this`, or the URL. From any UI area, ask about the URL. | +| Issues | From the issue, ask about `this issue`, `this`, or the URL. From any UI area, ask about the URL. | +| Merge Request | From the merge request, ask about `this merge request`, `this`, or the URL. From any UI area, ask about the URL. | +| Code files | From the single file, ask about `this code` or `this file`. From any UI area, ask about the URL. | In the IDEs, GitLab Duo Chat knows about these areas: diff --git a/ee/app/controllers/ee/projects/merge_requests_controller.rb b/ee/app/controllers/ee/projects/merge_requests_controller.rb index dc9a6885e271ca8331f119849ff8b2622bea7638..bf81baccc5a4743985c0f38216d2748032939f1b 100644 --- a/ee/app/controllers/ee/projects/merge_requests_controller.rb +++ b/ee/app/controllers/ee/projects/merge_requests_controller.rb @@ -23,6 +23,7 @@ module MergeRequestsController before_action :authorize_read_licenses!, only: [:license_scanning_reports, :license_scanning_reports_collapsed] before_action :authorize_read_security_reports!, only: [:security_reports] + before_action :set_application_context!, only: [:show, :diffs, :commits, :pipelines] feature_category :vulnerability_management, [:container_scanning_reports, :dependency_scanning_reports, :sast_reports, :secret_detection_reports, :dast_reports, diff --git a/ee/app/models/ai/ai_resource/base_ai_resource.rb b/ee/app/models/ai/ai_resource/base_ai_resource.rb index 15992bb32d339d7c3e8ed3f070fbe1f1cd0469fb..6935c700493b3f30672bdc75445fc8cdb300b7cb 100644 --- a/ee/app/models/ai/ai_resource/base_ai_resource.rb +++ b/ee/app/models/ai/ai_resource/base_ai_resource.rb @@ -3,13 +3,14 @@ module Ai module AiResource class BaseAiResource - attr_reader :resource + attr_reader :resource, :current_user - def initialize(resource) + def initialize(user, resource) @resource = resource + @current_user = user end - def serialize_for_ai(_user:, _content_limit:) + def serialize_for_ai(_content_limit:) raise NotImplementedError end end diff --git a/ee/app/models/ai/ai_resource/concerns/noteable.rb b/ee/app/models/ai/ai_resource/concerns/noteable.rb index 0c64be121b235c656f0f570cd64a1fce37954c8e..a0f2ad0ec41bc6a6e8874fdd50184a6e74cae97c 100644 --- a/ee/app/models/ai/ai_resource/concerns/noteable.rb +++ b/ee/app/models/ai/ai_resource/concerns/noteable.rb @@ -5,8 +5,8 @@ module AiResource module Concerns module Noteable extend ActiveSupport::Concern - def notes_with_limit(user, notes_limit:) - limited_notes = Ai::NotesForAiFinder.new(user, resource: resource).execute + def notes_with_limit(notes_limit:) + limited_notes = Ai::NotesForAiFinder.new(current_user, resource: resource).execute return [] if limited_notes.empty? diff --git a/ee/app/models/ai/ai_resource/epic.rb b/ee/app/models/ai/ai_resource/epic.rb index 89153e894931bfdbcf97b6a1db7fcf0095672336..0ac6fb0a533bb9bb8eb86baade95a396e19034ad 100644 --- a/ee/app/models/ai/ai_resource/epic.rb +++ b/ee/app/models/ai/ai_resource/epic.rb @@ -5,10 +5,10 @@ module AiResource class Epic < Ai::AiResource::BaseAiResource include Ai::AiResource::Concerns::Noteable - def serialize_for_ai(user:, content_limit:) - ::EpicSerializer.new(current_user: user) # rubocop: disable CodeReuse/Serializer + def serialize_for_ai(content_limit:) + ::EpicSerializer.new(current_user: current_user) # rubocop: disable CodeReuse/Serializer .represent(resource, { - user: user, + user: current_user, notes_limit: content_limit, serializer: 'ai', resource: self diff --git a/ee/app/models/ai/ai_resource/issue.rb b/ee/app/models/ai/ai_resource/issue.rb index 0c653175ffb2f5141948416a43ee0a49a02f9029..4c9d24a141f8e5dd455923e37664b719ca4995e5 100644 --- a/ee/app/models/ai/ai_resource/issue.rb +++ b/ee/app/models/ai/ai_resource/issue.rb @@ -5,10 +5,10 @@ module AiResource class Issue < Ai::AiResource::BaseAiResource include Ai::AiResource::Concerns::Noteable - def serialize_for_ai(user:, content_limit:) - ::IssueSerializer.new(current_user: user, project: resource.project) # rubocop: disable CodeReuse/Serializer + def serialize_for_ai(content_limit:) + ::IssueSerializer.new(current_user: current_user, project: resource.project) # rubocop: disable CodeReuse/Serializer .represent(resource, { - user: user, + user: current_user, notes_limit: content_limit, serializer: 'ai', resource: self diff --git a/ee/app/models/ai/ai_resource/merge_request.rb b/ee/app/models/ai/ai_resource/merge_request.rb new file mode 100644 index 0000000000000000000000000000000000000000..077a4ff78b0b5cd02001385a162eeb6c6fb3c049 --- /dev/null +++ b/ee/app/models/ai/ai_resource/merge_request.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Ai + module AiResource + class MergeRequest < Ai::AiResource::BaseAiResource + include Ai::AiResource::Concerns::Noteable + + def serialize_for_ai(content_limit:) + ::MergeRequestSerializer.new(current_user: current_user) # rubocop: disable CodeReuse/Serializer -- existing serializer + .represent(resource, { + user: current_user, + notes_limit: content_limit, + serializer: 'ai', + resource: self + }) + end + + def current_page_type + "merge_request" + end + + def current_page_sentence + return '' unless Feature.enabled?(:ai_merge_request_reader_for_chat, current_user) + + <<~SENTENCE + The user is currently on a page that displays a merge request with a description, comments, etc., which the user might refer to, for example, as 'current', 'this' or 'that'. The data is provided in tags, and if it is sufficient in answering the question, utilize it instead of using the 'MergeRequestReader' tool. + SENTENCE + end + + def current_page_short_description + return '' unless Feature.enabled?(:ai_merge_request_reader_for_chat, current_user) + + <<~SENTENCE + The user is currently on a page that displays a merge request with a description, comments, etc., which the user might refer to, for example, as 'current', 'this' or 'that'. The title of the merge request is '#{resource.title}'. Remember to use the 'MergeRequestReader' tool if they ask a question about the Merge Request. + SENTENCE + end + end + end +end diff --git a/ee/app/serializers/ee/issue_ai_entity.rb b/ee/app/serializers/ee/issue_ai_entity.rb index bf0010f23e9fd3663c96bee93e489a3d6194fb3d..04af21d80a8e56de8bb353fd4ce82683f8110a2f 100644 --- a/ee/app/serializers/ee/issue_ai_entity.rb +++ b/ee/app/serializers/ee/issue_ai_entity.rb @@ -3,7 +3,7 @@ module EE class IssueAiEntity < ::IssueEntity expose :issue_comments do |_issue, options| - options[:resource].notes_with_limit(options[:user], notes_limit: options[:notes_limit]) + options[:resource].notes_with_limit(notes_limit: options[:notes_limit]) end end end diff --git a/ee/app/serializers/ee/merge_request_ai_entity.rb b/ee/app/serializers/ee/merge_request_ai_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..811915ec04565aae8b0aaaf3e33d3afd42a6d9de --- /dev/null +++ b/ee/app/serializers/ee/merge_request_ai_entity.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module EE + class MergeRequestAiEntity < ::API::Entities::MergeRequest + expose :mr_comments do |_mr, options| + options[:resource].notes_with_limit(notes_limit: options[:notes_limit] / 2) + end + + expose :diff do |mr, options| + ::Gitlab::Llm::Utils::MergeRequestTool.extract_diff( + source_project: mr.source_project, + source_branch: mr.source_branch, + target_project: mr.target_project, + target_branch: mr.target_branch, + character_limit: options[:notes_limit] / 2 + ) + end + end +end diff --git a/ee/app/serializers/ee/merge_request_serializer.rb b/ee/app/serializers/ee/merge_request_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..761690e893e94b3525818b9572c7ed1610f43e2e --- /dev/null +++ b/ee/app/serializers/ee/merge_request_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module EE + module MergeRequestSerializer + extend ::Gitlab::Utils::Override + + override :identified_entity + def identified_entity(opts) + if opts[:serializer] == 'ai' + MergeRequestAiEntity + else + super(opts) + end + end + end +end diff --git a/ee/app/serializers/epic_ai_entity.rb b/ee/app/serializers/epic_ai_entity.rb index 00dd6b482d96c36f3e6a0c430ac379e0cfac5b36..0b4267c5c4d00f00d86cf146cbc24ef36e4a5174 100644 --- a/ee/app/serializers/epic_ai_entity.rb +++ b/ee/app/serializers/epic_ai_entity.rb @@ -3,7 +3,7 @@ # rubocop: disable Gitlab/NamespacedClass class EpicAiEntity < EpicEntity expose :epic_comments do |_epic, options| - options[:resource].notes_with_limit(options[:user], notes_limit: options[:notes_limit]) + options[:resource].notes_with_limit(notes_limit: options[:notes_limit]) end end # rubocop: enable Gitlab/NamespacedClass diff --git a/ee/config/feature_flags/gitlab_com_derisk/ai_merge_request_reader_for_chat.yml b/ee/config/feature_flags/gitlab_com_derisk/ai_merge_request_reader_for_chat.yml new file mode 100644 index 0000000000000000000000000000000000000000..cdb18f9896bd1deff3b39fca4b2999bb54ce4d59 --- /dev/null +++ b/ee/config/feature_flags/gitlab_com_derisk/ai_merge_request_reader_for_chat.yml @@ -0,0 +1,9 @@ +--- +name: ai_merge_request_reader_for_chat +feature_issue_url: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/153616 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/463499 +milestone: '17.1' +group: group::duo chat +type: gitlab_com_derisk +default_enabled: false diff --git a/ee/lib/gitlab/llm/chain/agents/zero_shot/executor.rb b/ee/lib/gitlab/llm/chain/agents/zero_shot/executor.rb index a1a707dded293dc0fafb431eed200babbec24948..f5067caae7aef82d28a721cc949b3b28aefa9740 100644 --- a/ee/lib/gitlab/llm/chain/agents/zero_shot/executor.rb +++ b/ee/lib/gitlab/llm/chain/agents/zero_shot/executor.rb @@ -116,7 +116,8 @@ def options current_resource: current_resource, source_template: source_template, current_code: current_code, - resources: available_resources_names + resources: available_resources_names, + unavailable_resources: unavailable_resources_names } end @@ -150,6 +151,14 @@ def available_resources_names end strong_memoize_attr :available_resources_names + def unavailable_resources_names + resources = %w[Pipelines Vulnerabilities] + resources << 'Merge Requests' unless Feature.enabled?(:ai_merge_request_reader_for_chat, + context.current_user) + + resources.join(', ') + end + def prompt_version return CUSTOM_AGENT_PROMPT_TEMPLATE if context.agent_version @@ -244,7 +253,7 @@ def source_template You have access to the following GitLab resources: %s. You also have access to all information that can be helpful to someone working in software development of any kind. - At the moment, you do not have access to the following GitLab resources: Merge Requests, Pipelines, Vulnerabilities. + At the moment, you do not have access to the following GitLab resources: %s. At the moment, you do not have the ability to search Issues or Epics based on a description or keywords. You can only read information about a specific issue/epic IF the user is on the specific issue/epic's page, or provides a URL or ID. Do not use the IssueReader or EpicReader tool if you do not have these specified identifiers. diff --git a/ee/lib/gitlab/llm/chain/gitlab_context.rb b/ee/lib/gitlab/llm/chain/gitlab_context.rb index 9a9cfb94da94dedd35a20aa0e0fbaebe3429e909..d4d1c2cbb37f46c3a31dd3f3c7488c9522830454 100644 --- a/ee/lib/gitlab/llm/chain/gitlab_context.rb +++ b/ee/lib/gitlab/llm/chain/gitlab_context.rb @@ -28,10 +28,8 @@ def initialize( def resource_serialized(content_limit:) return '' unless authorized_resource - authorized_resource.serialize_for_ai( - user: current_user, - content_limit: content_limit - ).to_xml(root: :root, skip_types: true, skip_instruct: true) + authorized_resource.serialize_for_ai(content_limit: content_limit) + .to_xml(root: :root, skip_types: true, skip_instruct: true) end private @@ -44,7 +42,7 @@ def authorized_resource return unless Utils::ChatAuthorizer.resource(resource: resource, user: current_user).allowed? - resource_wrapper_class.new(resource) + resource_wrapper_class.new(current_user, resource) end end end diff --git a/ee/lib/gitlab/llm/chain/tools/merge_request_reader/executor.rb b/ee/lib/gitlab/llm/chain/tools/merge_request_reader/executor.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0b93306e1c8dca87394426c56315d62857cb6e1 --- /dev/null +++ b/ee/lib/gitlab/llm/chain/tools/merge_request_reader/executor.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + module Chain + module Tools + module MergeRequestReader + class Executor < Identifier + include Concerns::ReaderTooling + + RESOURCE_NAME = 'merge request' + NAME = "MergeRequestReader" + HUMAN_NAME = 'Merge Request Search' + DESCRIPTION = 'Gets the content of the current merge request (also referenced as this or that, or MR) ' \ + 'the user sees or a specific merge request identified by an ID or a URL.' \ + 'In this context, "merge request" means part of work that is ready to be merged. ' \ + 'Action Input for this tool should be the original question or merge request identifier.' + + EXAMPLE = + <<~PROMPT + Question: Please identify the author of !123 merge request + Thought: You have access to the same resources as user who asks a question. + Question is about the content of a merge request, so you need to use "MergeRequestReader" tool to retrieve and read merge request. + Based on this information you can present final answer about merge request. + Action: MergeRequestReader + Action Input: Please identify the author of !123 merge request + PROMPT + + PROVIDER_PROMPT_CLASSES = { + ai_gateway: ::Gitlab::Llm::Chain::Tools::MergeRequestReader::Prompts::Anthropic, + anthropic: ::Gitlab::Llm::Chain::Tools::MergeRequestReader::Prompts::Anthropic + }.freeze + + PROJECT_REGEX = { + 'url' => MergeRequest.link_reference_pattern, + 'reference' => MergeRequest.reference_pattern + }.freeze + + SYSTEM_PROMPT = Utils::Prompt.as_system( + <<~PROMPT + You can fetch information about a resource called: a merge request. + A merge request can be referenced by url or numeric IDs preceded by symbol. + A merge request can also be referenced by a GitLab reference. A GitLab reference ends with a number preceded by the delimiter ! and contains one or more /. + ResourceIdentifierType can only be one of [current, iid, url, reference]. + ResourceIdentifier can be number, url. If ResourceIdentifier is not a number or a url, use "current". + When you see a GitLab reference, ResourceIdentifierType should be reference. + + Make sure the response is a valid JSON. The answer should be just the JSON without any other commentary! + References in the given question to the current issue can be also for example "this merge request" or "that merge request", + referencing the merge request that the user currently sees. + Question: (the user question) + Response (follow the exact JSON response): + ```json + { + "ResourceIdentifierType": + "ResourceIdentifier": + } + ``` + + Examples of merge request reference identifier: + + Question: The user question or request may include https://some.host.name/some/long/path/-/merge_requests/410692 + Response: + ```json + { + "ResourceIdentifierType": "url", + "ResourceIdentifier": "https://some.host.name/some/long/path/-/merge_requests/410692" + } + ``` + + Question: the user question or request may include: !12312312 + Response: + ```json + { + "ResourceIdentifierType": "iid", + "ResourceIdentifier": 12312312 + } + ``` + + Question: the user question or request may include long/groups/path!12312312 + Response: + ```json + { + "ResourceIdentifierType": "reference", + "ResourceIdentifier": "long/groups/path!12312312" + } + ``` + + Question: Summarize the current merge request + Response: + ```json + { + "ResourceIdentifierType": "current", + "ResourceIdentifier": "current" + } + ``` + + Begin! + PROMPT + ) + + PROMPT_TEMPLATE = [ + SYSTEM_PROMPT, + Utils::Prompt.as_assistant("%s"), + Utils::Prompt.as_user("Question: %s") + ].freeze + + private + + def reference_pattern_by_type + PROJECT_REGEX + end + + def by_iid(resource_identifier) + return unless projects_from_context + + mrs = MergeRequest.in_projects(projects_from_context).iid_in(resource_identifier.to_i) + + mrs.first if mrs.one? + end + + def extract_resource(text, type) + project = extract_project(text, type) + return unless project + + extractor = Gitlab::ReferenceExtractor.new(project, context.current_user) + extractor.analyze(text, {}) + mrs = extractor.merge_requests + + mrs.first if mrs.one? + end + + def resource_name + RESOURCE_NAME + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/llm/chain/tools/merge_request_reader/prompts/anthropic.rb b/ee/lib/gitlab/llm/chain/tools/merge_request_reader/prompts/anthropic.rb new file mode 100644 index 0000000000000000000000000000000000000000..cd54204e8f5b6cc477aee05bae1f78112f55f00c --- /dev/null +++ b/ee/lib/gitlab/llm/chain/tools/merge_request_reader/prompts/anthropic.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Gitlab + module Llm + module Chain + module Tools + module MergeRequestReader + module Prompts + class Anthropic + include Concerns::AnthropicPrompt + + def self.prompt(options) + conversation = Utils::Prompt.role_conversation([ + Gitlab::Llm::Chain::Tools::MergeRequestReader::Executor::SYSTEM_PROMPT, + Utils::Prompt.as_user(options[:input]), + Utils::Prompt.as_assistant(options[:suggestions], "```json + \{ + \"ResourceIdentifierType\": \"") + ]) + + { + prompt: conversation, + options: { model: ::Gitlab::Llm::Anthropic::Client::CLAUDE_3_HAIKU } + } + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/llm/chain/tools/tool.rb b/ee/lib/gitlab/llm/chain/tools/tool.rb index 354c8c164a5a28df9b21966d92fd878b0d7da920..1e08bf425fa811843055e7aa2a93b5a4c3f35886 100644 --- a/ee/lib/gitlab/llm/chain/tools/tool.rb +++ b/ee/lib/gitlab/llm/chain/tools/tool.rb @@ -56,7 +56,8 @@ def perform end def current_resource?(resource_identifier_type, resource_name) - resource_identifier_type == 'current' && context.resource.class.name.downcase == resource_name + resource_identifier_type == 'current' && + context.resource.class.name.underscore == resource_name.tr(' ', '_') end def projects_from_context diff --git a/ee/lib/gitlab/llm/completions/chat.rb b/ee/lib/gitlab/llm/completions/chat.rb index c4d277236f1be88c77a82c323277a72668ea2bbc..5425f953aea60191eb4bf6c478e624a3880afe3f 100644 --- a/ee/lib/gitlab/llm/completions/chat.rb +++ b/ee/lib/gitlab/llm/completions/chat.rb @@ -91,7 +91,13 @@ def execute # allows conditional logic e.g. feature flagging def tools - TOOLS + tools = TOOLS.dup + + if Feature.enabled?(:ai_merge_request_reader_for_chat, user) + tools << ::Gitlab::Llm::Chain::Tools::MergeRequestReader + end + + tools end def response_post_processing diff --git a/ee/spec/lib/gitlab/llm/chain/agents/zero_shot/prompts/anthropic_spec.rb b/ee/spec/lib/gitlab/llm/chain/agents/zero_shot/prompts/anthropic_spec.rb index 57d1bc22ff387b0c1fe08619a0e338533f430378..8a0c0c9f560bdf1c7dde4104b15aece5e1e08d84 100644 --- a/ee/spec/lib/gitlab/llm/chain/agents/zero_shot/prompts/anthropic_spec.rb +++ b/ee/spec/lib/gitlab/llm/chain/agents/zero_shot/prompts/anthropic_spec.rb @@ -30,6 +30,7 @@ current_user: user, zero_shot_prompt: zero_shot_prompt, system_prompt: system_prompt, + unavailable_resources: '', source_template: "source template" } end @@ -110,6 +111,7 @@ current_user: user, zero_shot_prompt: zero_shot_prompt, system_prompt: system_prompt, + unavailable_resources: '', source_template: "source template" } end diff --git a/ee/spec/lib/gitlab/llm/chain/gitlab_context_spec.rb b/ee/spec/lib/gitlab/llm/chain/gitlab_context_spec.rb index 0100995a78359305c669d153c4f30bd54e67290c..43c3990ef3d52f54debabd5b6dd3d4e290758255 100644 --- a/ee/spec/lib/gitlab/llm/chain/gitlab_context_spec.rb +++ b/ee/spec/lib/gitlab/llm/chain/gitlab_context_spec.rb @@ -38,13 +38,13 @@ context 'with an authorized, serializable resource' do let(:resource) { create(:issue, project: project) } let(:resource_xml) do - Ai::AiResource::Issue.new(resource).serialize_for_ai(user: user, content_limit: content_limit) + Ai::AiResource::Issue.new(user, resource).serialize_for_ai(content_limit: content_limit) .to_xml(root: :root, skip_types: true, skip_instruct: true) end let(:resource2) { create(:issue, project: project) } let(:resource_xml2) do - Ai::AiResource::Issue.new(resource2).serialize_for_ai(user: user, content_limit: content_limit) + Ai::AiResource::Issue.new(user, resource2).serialize_for_ai(content_limit: content_limit) .to_xml(root: :root, skip_types: true, skip_instruct: true) end @@ -100,4 +100,22 @@ end end end + + describe '#current_page_description' do + context 'with an unauthorized resource' do + let(:resource) { create(:issue) } + + it 'returns nil' do + expect(context.current_page_sentence).to be_nil + end + end + + context 'with an authorized resource' do + let(:resource) { create(:issue, project: project) } + + it 'returns sentence about the resource' do + expect(context.current_page_sentence).to include("The user is currently on a page that displays an issue") + end + end + end end diff --git a/ee/spec/lib/gitlab/llm/chain/tools/epic_reader/executor_spec.rb b/ee/spec/lib/gitlab/llm/chain/tools/epic_reader/executor_spec.rb index 725862aee2ae83096f261b9d9b7c9d58889f3f68..868850b0717d01aaed168dd03753cb66c0661be9 100644 --- a/ee/spec/lib/gitlab/llm/chain/tools/epic_reader/executor_spec.rb +++ b/ee/spec/lib/gitlab/llm/chain/tools/epic_reader/executor_spec.rb @@ -8,9 +8,8 @@ ai_request = double allow(ai_request).to receive(:request).and_return(ai_response) allow(context).to receive(:ai_request).and_return(ai_request) - resource_serialized = Ai::AiResource::Epic.new(resource) + resource_serialized = Ai::AiResource::Epic.new(context.current_user, resource) .serialize_for_ai( - user: context.current_user, content_limit: ::Gitlab::Llm::Chain::Tools::EpicReader::Prompts::Anthropic::MAX_CHARACTERS ).to_xml(root: :root, skip_types: true, skip_instruct: true) diff --git a/ee/spec/lib/gitlab/llm/chain/tools/issue_reader/executor_spec.rb b/ee/spec/lib/gitlab/llm/chain/tools/issue_reader/executor_spec.rb index 1ddf19256d4ede7a05ebd144805c325ff8bc6bd9..29436b78ccaa74a087922a78a392df55f55da23e 100644 --- a/ee/spec/lib/gitlab/llm/chain/tools/issue_reader/executor_spec.rb +++ b/ee/spec/lib/gitlab/llm/chain/tools/issue_reader/executor_spec.rb @@ -8,9 +8,8 @@ ai_request = double allow(ai_request).to receive(:request).and_return(ai_response) allow(context).to receive(:ai_request).and_return(ai_request) - resource_serialized = Ai::AiResource::Issue.new(resource) + resource_serialized = Ai::AiResource::Issue.new(context.current_user, resource) .serialize_for_ai( - user: context.current_user, content_limit: ::Gitlab::Llm::Chain::Tools::IssueReader::Prompts::Anthropic::MAX_CHARACTERS ).to_xml(root: :root, skip_types: true, skip_instruct: true) diff --git a/ee/spec/lib/gitlab/llm/chain/tools/merge_request_reader/executor_spec.rb b/ee/spec/lib/gitlab/llm/chain/tools/merge_request_reader/executor_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b4cc1ce3e72aadb4e86553e04726c8fd97c3808c --- /dev/null +++ b/ee/spec/lib/gitlab/llm/chain/tools/merge_request_reader/executor_spec.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Llm::Chain::Tools::MergeRequestReader::Executor, feature_category: :duo_chat do + RSpec.shared_examples 'success response' do + it 'returns success response' do + ai_request = double + allow(ai_request).to receive(:request).and_return(ai_response) + allow(context).to receive(:ai_request).and_return(ai_request) + resource_serialized = Ai::AiResource::MergeRequest.new(context.current_user, resource) + .serialize_for_ai( + content_limit: ::Gitlab::Llm::Chain::Tools::MergeRequestReader::Prompts::Anthropic::MAX_CHARACTERS + ).to_xml(root: :root, skip_types: true, skip_instruct: true) + + response = "Please use this information about identified merge request: #{resource_serialized}" + + expect(tool.execute.content).to eq(response) + end + end + + RSpec.shared_examples 'merge request not found response' do + it 'returns success response' do + allow(tool).to receive(:request).and_return(ai_response) + + response = "I'm sorry, I can't generate a response. You might want to try again. " \ + "You could also be getting this error because the items you're asking about " \ + "either don't exist, you don't have access to them, or your session has expired." + expect(tool.execute.content).to eq(response) + end + end + + describe '#name' do + it 'returns tool name' do + expect(described_class::NAME).to eq('MergeRequestReader') + end + + it 'returns tool human name' do + expect(described_class::HUMAN_NAME).to eq('Merge Request Search') + end + end + + describe '#description' do + it 'returns tool description' do + expect(described_class::DESCRIPTION) + .to include('Gets the content of the current merge request (also referenced as this or that, or MR)') + end + end + + describe '#execute', :saas do + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) } + let_it_be_with_reload(:project) { create(:project, group: group) } + + before_all do + project.add_developer(user) + end + + before do + stub_const("::Gitlab::Llm::Chain::Tools::MergeRequestReader::Prompts::Anthropic::MAX_CHARACTERS", + 999999) + allow(tool).to receive(:provider_prompt_class) + .and_return(::Gitlab::Llm::Chain::Tools::MergeRequestReader::Prompts::Anthropic) + end + + context 'when merge request is identified' do + let_it_be(:merge_request1) { create(:merge_request, source_project: project, source_branch: 'branch-1') } + let_it_be(:merge_request2) { create(:merge_request, source_project: project, source_branch: 'branch-2') } + let(:context) do + Gitlab::Llm::Chain::GitlabContext.new( + container: project, + resource: merge_request1, + current_user: user, + ai_request: double + ) + end + + let(:tool) { described_class.new(context: context, options: input_variables, stream_response_handler: nil) } + let(:input_variables) do + { input: "user input", suggestions: "Action: MergeRequestReader\nActionInput: #{merge_request1.iid}" } + end + + context 'when user has permission to read resource' do + before do + stub_application_setting(check_namespace_plan: true) + stub_licensed_features(ai_chat: true) + + allow(project.root_ancestor.namespace_settings).to receive(:experiment_settings_allowed?).and_return(true) + project.root_ancestor.update!(experiment_features_enabled: true) + end + + context 'when ai response has invalid JSON' do + it 'retries the ai call' do + input_variables = { input: "user input", suggestions: "" } + tool = described_class.new(context: context, options: input_variables) + + allow(tool).to receive(:request).and_return("random string") + allow(Gitlab::Json).to receive(:parse).and_raise(JSON::ParserError) + + expect(tool).to receive(:request).exactly(3).times + + response = "I'm sorry, I can't generate a response. You might want to try again. " \ + "You could also be getting this error because the items you're asking about " \ + "either don't exist, you don't have access to them, or your session has expired." + expect(tool.execute.content).to eq(response) + end + end + + context 'when there is a StandardError' do + it 'returns an error' do + input_variables = { input: "user input", suggestions: "" } + tool = described_class.new(context: context, options: input_variables) + + allow(tool).to receive(:request).and_raise(StandardError) + + expect(tool.execute.content).to eq("I'm sorry, I can't generate a response. Please try again.") + end + end + + context 'when merge request is the current MR in context' do + let(:identifier) { 'current' } + let(:ai_response) { "current\", \"ResourceIdentifier\": \"#{identifier}\"}" } + let(:resource) { merge_request1 } + + it_behaves_like 'success response' + end + + context 'when merge request is identified by iid' do + let(:identifier) { merge_request2.iid } + let(:ai_response) { "iid\", \"ResourceIdentifier\": #{identifier}}" } + let(:resource) { merge_request2 } + + it_behaves_like 'success response' + end + + context 'when is merge request identified with reference' do + let(:identifier) { merge_request2.to_reference(full: true) } + let(:ai_response) do + "reference\", \"ResourceIdentifier\": \"#{identifier}\"}" + end + + let(:resource) { merge_request2 } + + it_behaves_like 'success response' + end + + context 'when MR mistaken with an issue' do + let_it_be(:issue) { create(:issue, project: project) } + + let(:ai_response) { "current\", \"ResourceIdentifier\": \"current\"}" } + + before do + context.resource = issue + end + + it_behaves_like 'merge request not found response' + end + + context 'when context container is a group' do + before do + context.container = group + end + + let(:identifier) { merge_request2.iid } + let(:ai_response) { "iid\", \"ResourceIdentifier\": #{identifier}}" } + let(:resource) { merge_request2 } + + it_behaves_like 'success response' + + context 'when multiple merge requests are identified' do + let_it_be(:project) { create(:project, group: group) } + let_it_be(:merge_request3) { create(:merge_request, iid: merge_request2.iid, source_project: project) } + + let(:identifier) { merge_request2.iid } + let(:ai_response) { "iid\", \"ResourceIdentifier\": #{identifier}}" } + + it_behaves_like 'merge request not found response' + end + end + + context 'when context container is a project namespace' do + before do + context.container = project.project_namespace + end + + context 'when merge request is the current merge_request in context' do + let(:identifier) { merge_request2.iid } + let(:ai_response) { "iid\", \"ResourceIdentifier\": #{identifier}}" } + let(:resource) { merge_request2 } + + it_behaves_like 'success response' + end + end + + context 'when context container is nil' do + before do + context.container = nil + end + + context 'when merge request is identified by iid' do + let(:identifier) { merge_request2.iid } + let(:ai_response) { "iid\", \"ResourceIdentifier\": #{identifier}}" } + + it_behaves_like 'merge request not found response' + end + + context 'when merge request is the current MR in context' do + let(:identifier) { 'current' } + let(:ai_response) { "current\", \"ResourceIdentifier\": \"#{identifier}\"}" } + let(:resource) { merge_request1 } + + it_behaves_like 'success response' + end + + context 'when is merge request identified with reference' do + let(:identifier) { merge_request2.to_reference(full: true) } + let(:ai_response) do + "reference\", \"ResourceIdentifier\": \"#{identifier}\"}" + end + + let(:resource) { merge_request2 } + + it_behaves_like 'success response' + end + + context 'when is merge request identified with not-full reference' do + let(:identifier) { merge_request2.to_reference(full: false) } + let(:ai_response) do + "reference\", \"ResourceIdentifier\": \"#{identifier}\"}" + end + + it_behaves_like 'merge request not found response' + end + + context 'when group does not have ai enabled' do + let(:identifier) { 'current' } + let(:ai_response) { "current\", \"ResourceIdentifier\": \"#{identifier}\"}" } + let(:resource) { merge_request1 } + + before do + stub_licensed_features(ai_chat: false) + end + + it_behaves_like 'success response' + end + + context 'when duo features are disabled for project' do + let(:identifier) { 'current' } + let(:ai_response) { "current\", \"ResourceIdentifier\": \"#{identifier}\"}" } + let(:response) do + "I am sorry, I cannot access the information you are asking about. " \ + "A group or project owner has turned off Duo features in this group or project." + end + + before do + project.update!(duo_features_enabled: false) + end + + it 'returns success response' do + allow(tool).to receive(:request).and_return(ai_response) + + expect(tool.execute.content).to eq(response) + end + end + end + + context 'when merge request was already identified' do + let(:resource_iid) { merge_request1.iid } + let(:ai_response) { "iid\", \"ResourceIdentifier\": #{merge_request1.iid}}" } + + before do + context.tools_used << described_class + end + + it 'returns already identified response' do + ai_request = double + allow(ai_request).to receive_message_chain(:complete, :dig, :to_s, :strip).and_return(ai_response) + allow(context).to receive(:ai_request).and_return(ai_request) + + response = "You already have identified the merge request #{context.resource.to_global_id}, read carefully." + expect(tool.execute.content).to eq(response) + end + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/llm/chain/tools/merge_request_reader/prompts/anthropic_spec.rb b/ee/spec/lib/gitlab/llm/chain/tools/merge_request_reader/prompts/anthropic_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f7e7d6520cfe0e1c2307439b2f5ed77bac8b345e --- /dev/null +++ b/ee/spec/lib/gitlab/llm/chain/tools/merge_request_reader/prompts/anthropic_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Llm::Chain::Tools::MergeRequestReader::Prompts::Anthropic, feature_category: :duo_chat do + describe '.prompt' do + let(:options) { { input: 'test input', suggestions: 'test suggestions' } } + + describe '.prompt' do + it 'returns prompt' do + prompt = described_class.prompt(options)[:prompt] + + expect(prompt.length).to eq(3) + + expect(prompt[0][:role]).to eq(:system) + expect(prompt[0][:content]).to eq(system_prompt) + + expect(prompt[1][:role]).to eq(:user) + expect(prompt[1][:content]).to eq(options[:input]) + + expect(prompt[2][:role]).to eq(:assistant) + expect(prompt[2][:content]).to include(options[:suggestions], "\"ResourceIdentifierType\": \"") + end + + it "calls with haiku model" do + model = described_class.prompt(options)[:options][:model] + + expect(model).to eq(::Gitlab::Llm::Anthropic::Client::CLAUDE_3_HAIKU) + end + end + + def system_prompt + Gitlab::Llm::Chain::Tools::MergeRequestReader::Executor::SYSTEM_PROMPT[1] + end + end +end diff --git a/ee/spec/lib/gitlab/llm/completions/chat_real_requests_spec.rb b/ee/spec/lib/gitlab/llm/completions/chat_real_requests_spec.rb index d0f1a320cf16eca6fa57b3e9ad571649773c2bbc..b87ddcf7da6c29b4535bababfc7dcf941f367931 100644 --- a/ee/spec/lib/gitlab/llm/completions/chat_real_requests_spec.rb +++ b/ee/spec/lib/gitlab/llm/completions/chat_real_requests_spec.rb @@ -83,12 +83,26 @@ end end - context 'without tool' do + context 'with predefined MR' do + let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + + where(:input_template, :tools) do + 'Summarize this Merge Request' | %w[MergeRequestReader] + 'Summarize %s Merge Request' | %w[MergeRequestReader] + end + + with_them do + let(:resource) { merge_request } + let(:input) { format(input_template, merge_request_identifier: merge_request.to_reference(full: true).to_s) } + + it_behaves_like 'successful prompt processing' + end + end + + context 'without predefined tools' do let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) } where(:input_template, :tools) do - 'Summarize this Merge Request' | [] - 'Summarize %s Merge Request' | [] 'Why did this pipeline fail?' | [] end diff --git a/ee/spec/lib/gitlab/llm/completions/chat_spec.rb b/ee/spec/lib/gitlab/llm/completions/chat_spec.rb index 070d177cf23d3197aa3db641f96b1b1a14175326..a3c96fc56d4b344e90eedb42656478dcdb3e272d 100644 --- a/ee/spec/lib/gitlab/llm/completions/chat_spec.rb +++ b/ee/spec/lib/gitlab/llm/completions/chat_spec.rb @@ -187,6 +187,7 @@ allow(Gitlab::Llm::Chain::Requests::AiGateway).to receive(:new).and_return(ai_request) allow(context).to receive(:tools_used).and_return([Gitlab::Llm::Chain::Tools::IssueReader::Executor]) stub_saas_features(duo_chat_categorize_question: true) + stub_feature_flags(ai_merge_request_reader_for_chat: false) end context 'when resource is an issue' do @@ -227,17 +228,38 @@ expect(::Gitlab::Llm::Chain::GitlabContext).to receive(:new) .with(current_user: user, container: expected_container, resource: resource, ai_request: ai_request, extra_resource: extra_resource, request_id: 'uuid', - current_file: current_file) + current_file: current_file, agent_version: agent_version) .and_return(context) - expect(categorize_service).to receive(:execute) - expect(Llm::ExecuteMethodService).to receive(:new) - .with(user, user, :categorize_question, categorize_service_params) - .and_return(categorize_service) + # This is temporarily commented out due to the following production issue: + # https://gitlab.com/gitlab-com/gl-infra/production/-/issues/18191 + # Since the `#response_post_processing` call is commented out, this should be too. + # expect(categorize_service).to receive(:execute) + # expect(Llm::ExecuteMethodService).to receive(:new) + # .with(user, user, :categorize_question, categorize_service_params) + # .and_return(categorize_service) subject end end + context 'with merge request reader allowed' do + before do + stub_feature_flags(ai_merge_request_reader_for_chat: true) + end + + let(:tools) do + [ + ::Gitlab::Llm::Chain::Tools::IssueReader, + ::Gitlab::Llm::Chain::Tools::GitlabDocumentation, + ::Gitlab::Llm::Chain::Tools::EpicReader, + ::Gitlab::Llm::Chain::Tools::CiEditorAssistant, + ::Gitlab::Llm::Chain::Tools::MergeRequestReader + ] + end + + it_behaves_like 'tool behind a feature flag' + end + context 'when message is a slash command' do shared_examples_for 'slash command execution' do let(:executor) { instance_double(Gitlab::Llm::Chain::Tools::ExplainCode::Executor) } diff --git a/ee/spec/models/ai/ai_resource/base_ai_resource_spec.rb b/ee/spec/models/ai/ai_resource/base_ai_resource_spec.rb index f1837751165a7fc947135a2204035cfa6f55d7d3..c140ce3d0e620a026c8480fd96ea0d3f412168e3 100644 --- a/ee/spec/models/ai/ai_resource/base_ai_resource_spec.rb +++ b/ee/spec/models/ai/ai_resource/base_ai_resource_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Ai::AiResource::BaseAiResource, feature_category: :duo_chat do describe '#serialize_for_ai' do it 'raises NotImplementedError' do - expect { described_class.new(nil).serialize_for_ai(_user: nil, _content_limit: nil) } + expect { described_class.new(nil, nil).serialize_for_ai(_content_limit: nil) } .to raise_error(NotImplementedError) end end diff --git a/ee/spec/models/ai/ai_resource/concerns/noteable_spec.rb b/ee/spec/models/ai/ai_resource/concerns/noteable_spec.rb index 624c00024fbacb341f187f85d3ce091961a7cc0e..ca11070c3aca5b55b0084542e67540b877dbc106 100644 --- a/ee/spec/models/ai/ai_resource/concerns/noteable_spec.rb +++ b/ee/spec/models/ai/ai_resource/concerns/noteable_spec.rb @@ -8,7 +8,7 @@ let_it_be(:user) { create(:user) } let_it_be(:content_limit) { 1000 } - subject(:notes) { Ai::AiResource::Issue.new(issue).notes_with_limit(user, notes_limit: content_limit) } + subject(:notes) { Ai::AiResource::Issue.new(user, issue).notes_with_limit(notes_limit: content_limit) } context 'when user can see notes' do before do diff --git a/ee/spec/models/ai/ai_resource/epic_spec.rb b/ee/spec/models/ai/ai_resource/epic_spec.rb index fa85be72bbd6df02d2a01bead2da212075b2cd96..d751764c05b7c9f7ef936f79eaf9ddeaa7b209af 100644 --- a/ee/spec/models/ai/ai_resource/epic_spec.rb +++ b/ee/spec/models/ai/ai_resource/epic_spec.rb @@ -6,7 +6,7 @@ let(:epic) { build(:epic) } let(:user) { build(:user) } - subject(:wrapped_epic) { described_class.new(epic) } + subject(:wrapped_epic) { described_class.new(user, epic) } describe '#serialize_for_ai' do it 'calls the serializations class' do @@ -19,7 +19,7 @@ resource: wrapped_epic }) - wrapped_epic.serialize_for_ai(user: user, content_limit: 100) + wrapped_epic.serialize_for_ai(content_limit: 100) end end diff --git a/ee/spec/models/ai/ai_resource/issue_spec.rb b/ee/spec/models/ai/ai_resource/issue_spec.rb index 91e653a9d1f3ea189b6dad281aa6288135979c5b..9e655f430e6f19f0cdd5e36e95ff816e30dcb547 100644 --- a/ee/spec/models/ai/ai_resource/issue_spec.rb +++ b/ee/spec/models/ai/ai_resource/issue_spec.rb @@ -6,7 +6,7 @@ let(:issue) { build(:issue) } let(:user) { build(:user) } - subject(:wrapped_issue) { described_class.new(issue) } + subject(:wrapped_issue) { described_class.new(user, issue) } describe '#serialize_for_ai' do it 'calls the serializations class' do @@ -18,7 +18,7 @@ serializer: 'ai', resource: wrapped_issue }) - wrapped_issue.serialize_for_ai(user: user, content_limit: 100) + wrapped_issue.serialize_for_ai(content_limit: 100) end end diff --git a/ee/spec/models/ai/ai_resource/merge_request_spec.rb b/ee/spec/models/ai/ai_resource/merge_request_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..485e7e3a022c0c0c4ea24518ad50ab83002b5968 --- /dev/null +++ b/ee/spec/models/ai/ai_resource/merge_request_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::AiResource::MergeRequest, feature_category: :duo_chat do + let(:merge_request) { build(:merge_request) } + let(:user) { build(:user) } + + subject(:wrapped_merge_request) { described_class.new(user, merge_request) } + + describe '#serialize_for_ai' do + it 'calls the serializations class' do + expect(MergeRequestSerializer).to receive_message_chain(:new, :represent) + .with(current_user: user) + .with(merge_request, { + user: user, + notes_limit: 100, + serializer: 'ai', + resource: wrapped_merge_request + }) + + wrapped_merge_request.serialize_for_ai(content_limit: 100) + end + end + + describe '#current_page_type' do + it 'returns type' do + expect(wrapped_merge_request.current_page_type).to eq('merge_request') + end + end + + describe '#current_page_sentence' do + it 'returns prompt' do + expect(wrapped_merge_request.current_page_sentence) + .to include("utilize it instead of using the 'MergeRequestReader' tool.") + end + + context 'with mr for chat feature flag disabled' do + before do + stub_feature_flags(ai_merge_request_reader_for_chat: false) + end + + it 'returns empty string' do + expect(wrapped_merge_request.current_page_sentence) + .to eq("") + end + end + end + + describe '#current_page_short_description' do + it 'returns prompt' do + expect(wrapped_merge_request.current_page_short_description) + .to include("The title of the merge request is '#{merge_request.title}'.") + end + + context 'with mr for chat feature flag disabled' do + before do + stub_feature_flags(ai_merge_request_reader_for_chat: false) + end + + it 'returns empty string' do + expect(wrapped_merge_request.current_page_short_description) + .to eq("") + end + end + end +end diff --git a/ee/spec/serializers/epic_serializer_spec.rb b/ee/spec/serializers/epic_serializer_spec.rb index 05985585a0fe77cdc11a9e957c0754b983497d8e..09d74c8de57d408fa5240bdafd62bdda14ba999a 100644 --- a/ee/spec/serializers/epic_serializer_spec.rb +++ b/ee/spec/serializers/epic_serializer_spec.rb @@ -28,7 +28,7 @@ context 'when ai serializer requested' do let(:json_entity) do described_class.new(current_user: user) - .represent(resource, serializer: 'ai', resource: Ai::AiResource::Epic.new(resource)) + .represent(resource, serializer: 'ai', resource: Ai::AiResource::Epic.new(user, resource)) .with_indifferent_access end diff --git a/ee/spec/serializers/issue_serializer_spec.rb b/ee/spec/serializers/issue_serializer_spec.rb index c61afeb8f2b0f399d26ddd393c18f6d6d6f2e5f4..aa8c26308eaa32374c3fb58542ff7e84290901ee 100644 --- a/ee/spec/serializers/issue_serializer_spec.rb +++ b/ee/spec/serializers/issue_serializer_spec.rb @@ -46,7 +46,7 @@ let(:json_entity) do described_class.new(current_user: user) .represent(resource, - serializer: serializer, resource: Ai::AiResource::Issue.new(resource)) + serializer: serializer, resource: Ai::AiResource::Issue.new(user, resource)) .with_indifferent_access end diff --git a/ee/spec/serializers/merge_request_ai_entity_spec.rb b/ee/spec/serializers/merge_request_ai_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..11973ea210e023899a98874092b488e590de6c77 --- /dev/null +++ b/ee/spec/serializers/merge_request_ai_entity_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe EE::MergeRequestAiEntity, feature_category: :ai_abstraction_layer do # rubocop:disable RSpec/FilePath -- path is correct + let_it_be(:user) { create(:user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- we need create it + let_it_be(:merge_request) { create(:merge_request) } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- we need create it + let(:notes_limit) { 1000 } + + let(:entity) do + described_class.new(merge_request, + user: user, + resource: Ai::AiResource::MergeRequest.new(user, merge_request), + notes_limit: notes_limit) + end + + subject(:basic_entity) { entity.as_json } + + before do + merge_request.project.add_developer(user) + end + + it "exposes basic entity fields" do + expected_fields = %i[ + merged_by merge_user merged_at closed_by closed_at target_branch user_notes_count upvotes downvotes + author assignees assignee reviewers source_project_id target_project_id labels draft work_in_progress + milestone merge_when_pipeline_succeeds merge_status detailed_merge_status sha merge_commit_sha + squash_commit_sha discussion_locked should_remove_source_branch force_remove_source_branch prepared_at + reference references web_url time_stats squash task_completion_status has_conflicts blocking_discussions_resolved + imported imported_from + ] + + is_expected.to include(*expected_fields) + end + + context "with mr comments on the entity" do + let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: merge_request.project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- we need create it + let!(:note2) { create(:note_on_merge_request, noteable: merge_request, project: merge_request.project) } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- we need create it + + it "exposes the number of comments" do + expect(basic_entity[:mr_comments]).to match_array([note.note, note2.note]) + end + end + + context "with diff on the entity" do + it "exposes the diff information" do + expect(basic_entity[:diff]).to include("--- CHANGELOG") + end + end +end diff --git a/ee/spec/serializers/merge_request_serializer_spec.rb b/ee/spec/serializers/merge_request_serializer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..eb09ac5007ef46f68d92e8da9d69a99a3d2fccbe --- /dev/null +++ b/ee/spec/serializers/merge_request_serializer_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequestSerializer, feature_category: :code_review_workflow do + let_it_be(:user) { create(:user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- we need create it + let_it_be(:merge_request) { create(:merge_request, description: "Description") } # rubocop:disable RSpec/FactoryBot/AvoidCreate -- we need create it + let(:serializer) { 'ai' } + + let(:json_entity) do + described_class.new(current_user: user) + .represent(merge_request, + serializer: serializer, + notes_limit: 2, + resource: Ai::AiResource::MergeRequest.new(user, merge_request)) + .with_indifferent_access + end + + context 'when serializing merge request for ai' do + it 'returns ai related data' do + expect(json_entity.keys).to include("mr_comments", "diff") + end + end +end