ee/lib/gitlab/llm/anthropic/completions/review_merge_request.rb (308 lines of code) (raw):

# frozen_string_literal: true module Gitlab module Llm module Anthropic module Completions class ReviewMergeRequest < Gitlab::Llm::Completions::Base include Gitlab::Utils::StrongMemoize DRAFT_NOTES_COUNT_LIMIT = 50 PRIORITY_THRESHOLD = 3 class << self def resource_not_found_msg s_("DuoCodeReview|Can't access the merge request. When SAML single sign-on is enabled " \ "on a group or its parent, Duo Code Reviews can't be requested from the API. Request a " \ "review from the GitLab UI instead.") end def nothing_to_review_msg s_("DuoCodeReview|:wave: There's nothing for me to review.") end def no_comment_msg s_("DuoCodeReview|I finished my review and found nothing to comment on. Nice work! :tada:") end def error_msg s_("DuoCodeReview|I have encountered some problems while I was reviewing. Please try again later.") end end def execute # Progress note may not exist for existing jobs so we create one if we can @progress_note = find_progress_note || create_progress_note unless progress_note.present? Gitlab::ErrorTracking.track_exception( StandardError.new("Unable to perform Duo Code Review: progress_note and resource not found") ) return # Cannot proceed without both progress note and resource end # Resource can be empty when permission check fails in Llm::Internal::CompletionService. # This would most likely happen when the parent group has SAML SSO enabled and the Duo Code Review is # triggered via an API call. It's a known limitation of SAML SSO currently. return update_progress_note(self.class.resource_not_found_msg) unless resource.present? update_review_state('review_started') if merge_request.ai_reviewable_diff_files.blank? update_progress_note(self.class.nothing_to_review_msg) else perform_review end rescue StandardError => error Gitlab::ErrorTracking.track_exception(error) update_progress_note(self.class.error_msg, with_todo: true) if progress_note.present? ensure update_review_state('reviewed') if merge_request.present? @progress_note&.destroy end private attr_reader :progress_note def perform_review # Initialize ivar that will be populated as AI review diff hunks @draft_notes_by_priority = [] diff_files = merge_request.ai_reviewable_diff_files if diff_files.blank? update_progress_note(self.class.nothing_to_review_msg) return end mr_diff_refs = merge_request.diff_refs if Feature.enabled?(:duo_code_review_multi_file, user) process_all_files_together(diff_files, mr_diff_refs) else process_files_individually(diff_files, mr_diff_refs) end if @draft_notes_by_priority.empty? update_progress_note(self.class.no_comment_msg, with_todo: true) else publish_draft_notes end end def process_all_files_together(diff_files, mr_diff_refs) diffs_and_paths = {} files_content = {} diff_files.each do |diff_file| diffs_and_paths[diff_file.new_path] = diff_file.raw_diff # Skip newly added files and deleted files since their full content is already in the diff next if diff_file.new_file? || diff_file.deleted_file? next unless include_file_content? content = diff_file.old_blob&.data files_content[diff_file.new_path] = content if content.present? end response = process_review_with_retry(diffs_and_paths, files_content) return if note_not_required?(response) parsed_body = ResponseBodyParser.new(response.response_body) comments_by_file = parsed_body.comments.group_by(&:file) log_comment_metrics(parsed_body.comments) diff_files.each do |diff_file| file_comments = comments_by_file[diff_file.new_path] next if file_comments.blank? process_comments(file_comments, diff_file, mr_diff_refs) end end def process_files_individually(diff_files, mr_diff_refs) diff_files.each do |diff_file| single_file_diff = { diff_file.new_path => diff_file.raw_diff } single_file_content = {} if !diff_file.new_file? && include_file_content? content = diff_file.old_blob&.data single_file_content[diff_file.new_path] = content if content.present? end review_prompt = generate_review_prompt(single_file_diff, single_file_content) next unless review_prompt.present? response = review_response_for(review_prompt) next if note_not_required?(response) parsed_body = ResponseBodyParser.new(response.response_body) file_comments = parsed_body.comments.select { |comment| comment.file == diff_file.new_path } process_comments(file_comments, diff_file, mr_diff_refs) end end def process_review_with_retry(diffs_and_paths, files_content) # First try with file content (if any) if files_content.present? prompt = generate_review_prompt(diffs_and_paths, files_content) return unless prompt.present? response = review_response_for(prompt) unless response.errors.any? log_llm_response_metrics(response.ai_response) return response end if duo_code_review_logging_enabled? Gitlab::AppLogger.info( message: "Review request failed with files content, retrying without file content", event: "review_merge_request_retry_without_content", merge_request_id: merge_request&.id, error: response.errors ) end end # Retry without file content on failure or if no file content was provided prompt = generate_review_prompt(diffs_and_paths, {}) response = review_response_for(prompt) log_llm_response_metrics(response.ai_response) response end def include_file_content? Feature.enabled?(:duo_code_review_full_file, user) end strong_memoize_attr :include_file_content? def duo_code_review_logging_enabled? Feature.enabled?(:duo_code_review_response_logging, user) end strong_memoize_attr :duo_code_review_logging_enabled? def process_comments(comments, diff_file, diff_refs) comments.each do |comment| # NOTE: LLM may return invalid line numbers sometimes so we should double check the existence of the line. line = diff_file.diff_lines.find do |line| line.old_line == comment.old_line && line.new_line == comment.new_line end next unless line.present? draft_note_params = build_draft_note_params(comment.content, diff_file, line, diff_refs) next unless draft_note_params.present? @draft_notes_by_priority << [comment.priority, draft_note_params] end end def ai_client @ai_client ||= ::Gitlab::Llm::Anthropic::Client.new( user, unit_primitive: "review_merge_request", tracking_context: tracking_context ) end def review_bot Users::Internal.duo_code_review_bot end strong_memoize_attr :review_bot def merge_request # Fallback is needed to handle review state change as much as possible resource || progress_note&.noteable end def generate_review_prompt(diffs_and_paths, files_content) ai_prompt_class.new( mr_title: merge_request.title, mr_description: merge_request.description, diffs_and_paths: diffs_and_paths, files_content: files_content, user: user ).to_prompt end def review_response_for(prompt) response = ai_client.messages_complete(**prompt) ::Gitlab::Llm::Anthropic::ResponseModifiers::ReviewMergeRequest.new(response) end def summary_response_for(draft_notes) summary_prompt = Gitlab::Llm::Templates::SummarizeReview.new(draft_notes).to_prompt response = ai_client.messages_complete(**summary_prompt) ::Gitlab::Llm::Anthropic::ResponseModifiers::ReviewMergeRequest.new(response) end def log_llm_response_metrics(response) return unless duo_code_review_logging_enabled? input_tokens = response&.dig("usage", "input_tokens") output_tokens = response&.dig("usage", "output_tokens") total_tokens = input_tokens.to_i + output_tokens.to_i Gitlab::AppLogger.info( message: "LLM response metrics", event: "review_merge_request_llm_response_received", merge_request_id: merge_request&.id, response_id: response&.[]("id"), stop_reason: response&.[]("stop_reason"), input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total_tokens, error_message: response&.dig("error", "message") ) end def log_comment_metrics(comments) return unless duo_code_review_logging_enabled? grouped_comments = comments.group_by(&:priority) Gitlab::AppLogger.info( message: "LLM response comments metrics", event: "review_merge_request_llm_response_comments", merge_request_id: merge_request&.id, total_comments: comments.count, p1_comments: grouped_comments[1]&.count || 0, p2_comments: grouped_comments[2]&.count || 0, p3_comments: grouped_comments[3]&.count || 0 ) end def note_not_required?(response_modifier) response_modifier.errors.any? || response_modifier.response_body.blank? end def build_draft_note_params(comment, diff_file, line, diff_refs) position = { base_sha: diff_refs.base_sha, start_sha: diff_refs.start_sha, head_sha: diff_refs.head_sha, old_path: diff_file.old_path, new_path: diff_file.new_path, position_type: 'text', old_line: line.old_line, new_line: line.new_line, ignore_whitespace_change: false } return if review_note_already_exists?(position) { merge_request: merge_request, author: review_bot, note: comment, position: position } end def review_note_already_exists?(position) merge_request .notes .diff_notes .authored_by(review_bot) .positions .any? { |pos| pos.to_h >= position } end def create_progress_note return unless merge_request.present? ::SystemNotes::MergeRequestsService.new( noteable: merge_request, container: merge_request.project, author: review_bot ).duo_code_review_started end def update_progress_note(note, with_todo: false) todo_service.new_review(merge_request, review_bot) if with_todo ::Notes::CreateService.new( merge_request.project, review_bot, noteable: merge_request, note: note ).execute end def find_progress_note Note.find_by_id(options[:progress_note_id]) end def summary_note(draft_notes) response = summary_response_for(draft_notes) if response.errors.any? || response.response_body.blank? self.class.error_msg else response.response_body end end # rubocop: disable CodeReuse/ActiveRecord -- NOT a ActiveRecord object def trimmed_draft_note_params # Filter out lower priority comments (< 3) and take only a limited # number of reviews to minimize the review volume @draft_notes_by_priority .select { |note| note.first >= PRIORITY_THRESHOLD } .take(DRAFT_NOTES_COUNT_LIMIT) .map(&:last) end # rubocop: enable CodeReuse/ActiveRecord def publish_draft_notes return unless Ability.allowed?(user, :create_note, merge_request) draft_notes = trimmed_draft_note_params.map do |params| DraftNote.new(params) end if draft_notes.empty? update_progress_note(self.class.no_comment_msg, with_todo: true) return end DraftNote.bulk_insert!(draft_notes, batch_size: 20) update_progress_note(summary_note(draft_notes)) # We set `executing_user` as the user who executed the duo code # review action as we only want to publish duo code review bot's review # if the executing user is allowed to create notes on the MR. DraftNotes::PublishService .new( merge_request, review_bot ).execute(executing_user: user) end def update_review_state_service ::MergeRequests::UpdateReviewerStateService .new(project: merge_request.project, current_user: review_bot) end strong_memoize_attr :update_review_state_service def update_review_state(state) update_review_state_service.execute(merge_request, state) end def todo_service TodoService.new end strong_memoize_attr :todo_service end end end end end