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