From 6537f162b9a3f75d9b77a407ec90ca10b11f0fd0 Mon Sep 17 00:00:00 2001
From: "Sincheol (David) Kim" <dkim@gitlab.com>
Date: Mon, 5 Aug 2024 03:22:32 +0000
Subject: [PATCH] Avoid creating reviews on the same diff hunk

---
 .../completions/review_merge_request.rb       |  64 +++--
 .../completions/review_merge_request_spec.rb  | 248 ++++++++----------
 2 files changed, 155 insertions(+), 157 deletions(-)

diff --git a/ee/lib/gitlab/llm/vertex_ai/completions/review_merge_request.rb b/ee/lib/gitlab/llm/vertex_ai/completions/review_merge_request.rb
index d31972cf668e0..f5d89784ddbf2 100644
--- a/ee/lib/gitlab/llm/vertex_ai/completions/review_merge_request.rb
+++ b/ee/lib/gitlab/llm/vertex_ai/completions/review_merge_request.rb
@@ -79,24 +79,37 @@ def build_draft_note_params(response_modifier, diff_file, hunk, diff_refs)
             # create the note on the old line.
             old_line = hunk[:removed].last&.old_pos if hunk[:added].empty?
 
+            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: old_line,
+              new_line: hunk[:added].last&.new_pos,
+              ignore_whitespace_change: false
+            }
+
+            return if review_note_already_exists?(position)
+
             @draft_notes_params << {
               merge_request: merge_request,
               author: review_bot,
               note: response_modifier.response_body,
-              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: old_line,
-                new_line: hunk[:added].last&.new_pos,
-                ignore_whitespace_change: false
-              }
+              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
             @progress_note = Notes::CreateService.new(
               merge_request.project,
@@ -108,26 +121,27 @@ def create_progress_note
           end
 
           def update_progress_note_with_review_summary(draft_notes)
-            summary_note = if draft_notes.blank?
-                             s_("DuoCodeReview|I finished my review and found nothing to comment on. Nice work! :tada:")
-                           else
-                             response = summary_response_for(user, draft_notes)
-
-                             if response.errors.any? || response.response_body.blank?
-                               s_("DuoCodeReview|I have encountered some issues while I was reviewing. " \
-                                 "Please try again later.")
-                             else
-                               response.response_body
-                             end
-                           end
-
             Notes::UpdateService.new(
               merge_request.project,
               review_bot,
-              note: summary_note
+              note: summary_note(draft_notes)
             ).execute(@progress_note)
           end
 
+          def summary_note(draft_notes)
+            if draft_notes.blank?
+              s_("DuoCodeReview|I finished my review and found nothing to comment on. Nice work! :tada:")
+            else
+              response = summary_response_for(user, draft_notes)
+
+              if response.errors.any? || response.response_body.blank?
+                s_("DuoCodeReview|I have encountered some issues while I was reviewing. Please try again later.")
+              else
+                response.response_body
+              end
+            end
+          end
+
           def publish_draft_notes
             if @draft_notes_params.empty?
               update_progress_note_with_review_summary([])
diff --git a/ee/spec/lib/gitlab/llm/vertex_ai/completions/review_merge_request_spec.rb b/ee/spec/lib/gitlab/llm/vertex_ai/completions/review_merge_request_spec.rb
index 5e1a5d8fb265f..57eadf8413302 100644
--- a/ee/spec/lib/gitlab/llm/vertex_ai/completions/review_merge_request_spec.rb
+++ b/ee/spec/lib/gitlab/llm/vertex_ai/completions/review_merge_request_spec.rb
@@ -9,59 +9,59 @@
   let(:options) { {} }
   let(:response_modifier) { double }
   let(:create_note_allowed?) { true }
+
+  let(:review_start_note) do
+    s_("DuoCodeReview|Hey :wave: I'm starting to review your merge request and I will let you know when I'm finished.")
+  end
+
+  let(:review_no_comment_note) do
+    s_("DuoCodeReview|I finished my review and found nothing to comment on. Nice work! :tada:")
+  end
+
+  let(:review_error_note) do
+    s_("DuoCodeReview|I have encountered some issues while I was reviewing. Please try again later.")
+  end
+
   let_it_be(:duo_code_review_bot) { create(:user, :duo_code_review_bot) }
-  let_it_be(:merge_request) { create(:merge_request) }
-  let_it_be(:user) { create(:user, developer_of: merge_request.project) }
-  let_it_be(:diff_refs) { merge_request.diff_refs }
+  let_it_be(:project) do
+    create(:project, :custom_repo, files: { 'UPDATED.md' => "existing line 1\nexisting line 2\n" })
+  end
 
-  let(:ai_reviewable_diff_files) do
-    [
-      instance_double(
-        Gitlab::Diff::File,
-        old_path: 'NEW.md',
-        new_path: 'NEW.md',
-        diff_lines_by_hunk: [
-          {
-            added: [
-              instance_double(Gitlab::Diff::Line, old_pos: 5, new_pos: 3),
-              instance_double(Gitlab::Diff::Line, old_pos: 5, new_pos: 4)
-            ],
-            removed: [
-              instance_double(Gitlab::Diff::Line, old_pos: 3, new_pos: 3),
-              instance_double(Gitlab::Diff::Line, old_pos: 4, new_pos: 3)
-            ]
-          }
-        ]
-      ),
-      instance_double(
-        Gitlab::Diff::File,
-        old_path: 'UPDATED.md',
-        new_path: 'UPDATED.md',
-        diff_lines_by_hunk: [
-          {
-            added: [],
-            removed: [
-              instance_double(Gitlab::Diff::Line, old_pos: 3, new_pos: 3),
-              instance_double(Gitlab::Diff::Line, old_pos: 4, new_pos: 3)
-            ]
-          }
-        ]
-      )
-    ]
+  let_it_be(:user) { create(:user, developer_of: project) }
+  let_it_be(:source_branch) { 'review-merge-request-test' }
+  let_it_be(:merge_request) do
+    project.repository.create_branch(source_branch, project.default_branch)
+    project.repository.update_file(
+      user,
+      'UPDATED.md',
+      "existing line 1\nnew line\n",
+      message: 'Update file',
+      branch_name: source_branch)
+
+    project.repository.create_file(
+      user,
+      'NEW.md',
+      "new line1\nnew line 2\n",
+      message: 'Create file',
+      branch_name: source_branch)
+
+    create(
+      :merge_request,
+      target_project: project,
+      source_project: project,
+      source_branch: source_branch,
+      target_branch: project.default_branch
+    )
   end
 
+  let_it_be(:diff_refs) { merge_request.diff_refs }
+
   let(:review_prompt_message) do
     build(:ai_message, :review_merge_request, user: user, resource: merge_request, request_id: 'uuid')
   end
 
   subject(:completion) { described_class.new(review_prompt_message, review_prompt_class, options) }
 
-  before do
-    allow(merge_request)
-      .to receive(:ai_reviewable_diff_files)
-      .and_return(ai_reviewable_diff_files)
-  end
-
   describe '#execute' do
     let(:review_prompt) { 'This is a review prompt' }
     let(:summary_prompt) { 'This is a summary prompt' }
@@ -147,56 +147,56 @@
         }
       end
 
-      it 'builds draft note and publish to create diff note on new and updated files' do
-        new_file_draft_note_params = {
-          merge_request: merge_request,
-          author: duo_code_review_bot,
-          note: review_answer,
-          position: {
-            base_sha: diff_refs.base_sha,
-            start_sha: diff_refs.start_sha,
-            head_sha: diff_refs.head_sha,
-            old_path: 'NEW.md',
-            new_path: 'NEW.md',
-            position_type: 'text',
-            old_line: nil,
-            new_line: 4,
-            ignore_whitespace_change: false
-          }
-        }
+      it 'creates diff notes on new and updated files' do
+        completion.execute
 
-        updated_file_draft_note_params = {
-          merge_request: merge_request,
-          author: duo_code_review_bot,
-          note: review_answer,
-          position: {
-            base_sha: diff_refs.base_sha,
-            start_sha: diff_refs.start_sha,
-            head_sha: diff_refs.head_sha,
-            old_path: 'UPDATED.md',
-            new_path: 'UPDATED.md',
-            position_type: 'text',
-            old_line: 4,
-            new_line: nil,
-            ignore_whitespace_change: false
-          }
-        }
+        diff_notes = merge_request.notes.diff_notes.authored_by(duo_code_review_bot)
+        expect(diff_notes.count).to eq 2
+
+        new_file_note = diff_notes[0]
+        expect(new_file_note.note).to eq review_answer
+        expect(new_file_note.position.to_h).to eq({
+          base_sha: diff_refs.base_sha,
+          start_sha: diff_refs.start_sha,
+          head_sha: diff_refs.head_sha,
+          old_path: 'NEW.md',
+          new_path: 'NEW.md',
+          position_type: 'text',
+          old_line: nil,
+          new_line: 2,
+          line_range: nil,
+          ignore_whitespace_change: false
+        })
+
+        updated_file_note = diff_notes[1]
+        expect(updated_file_note.note).to eq review_answer
+        expect(updated_file_note.position.to_h).to eq({
+          base_sha: diff_refs.base_sha,
+          start_sha: diff_refs.start_sha,
+          head_sha: diff_refs.head_sha,
+          old_path: 'UPDATED.md',
+          new_path: 'UPDATED.md',
+          position_type: 'text',
+          old_line: nil,
+          new_line: 2,
+          line_range: nil,
+          ignore_whitespace_change: false
+        })
+      end
 
-        draft_note_1 = instance_double(DraftNote)
-        draft_note_2 = instance_double(DraftNote)
-
-        expect(DraftNote).to receive(:new).with(new_file_draft_note_params).and_return(draft_note_1)
-        expect(DraftNote).to receive(:new).with(updated_file_draft_note_params).and_return(draft_note_2)
-        expect(DraftNote).to receive(:bulk_insert!).with([draft_note_1, draft_note_2], batch_size: 20)
-        expect_next_instance_of(
-          DraftNotes::PublishService,
-          merge_request,
-          duo_code_review_bot
-        ) do |svc|
-          expect(svc).to receive(:execute).with(executing_user: user)
-        end
+      context 'when review note alredy exist on the same position' do
+        it 'does not add more notes to the same position' do
+          expect do
+            described_class.new(review_prompt_message, review_prompt_class, options).execute
+          end.to change { merge_request.notes.diff_notes.count }.by(2)
+            .and change { merge_request.notes.non_diff_notes.count }.by(1) # review summary
 
-        completion.execute
+          expect { completion.execute }
+            .to not_change { merge_request.notes.diff_notes.count }
+            .and change { merge_request.notes.non_diff_notes.count }.by(1) # review summary
+
+          expect(merge_request.notes.last.note).to eq(review_no_comment_note)
+        end
       end
 
       context 'when user is not allowed to create notes' do
@@ -216,9 +216,8 @@
         it 'updates progress note with a success message' do
           completion.execute
 
-          expect(Note.count).to eq 1
-          expect(Note.last.note).to eq s_("DuoCodeReview|I finished my review and found nothing to comment on. " \
-            "Nice work! :tada:")
+          expect(merge_request.notes.count).to eq 1
+          expect(merge_request.notes.last.note).to eq(review_no_comment_note)
         end
       end
 
@@ -231,14 +230,13 @@
               merge_request.project,
               duo_code_review_bot,
               noteable: merge_request,
-              note: s_("MergeRequests|Hey :wave: I'm starting to review your merge request and " \
-                "I will let you know when I'm finished.")
+              note: review_start_note
             ).and_call_original
             allow(Notes::CreateService).to receive(:new).and_call_original
 
             completion.execute
 
-            expect(Note.last.note).to eq s_("Helpful review summary")
+            expect(merge_request.notes.non_diff_notes.last.note).to eq s_("Helpful review summary")
           end
         end
 
@@ -255,9 +253,9 @@
           it 'updates progress note with an error message' do
             completion.execute
 
-            expect(Note.count).to eq 1
-            expect(Note.last.note).to eq s_("DuoCodeReview|I have encountered some issues while I was reviewing. " \
-              "Please try again later.")
+            expect(merge_request.notes.count).to eq 3
+            expect(merge_request.notes.diff_notes.count).to eq 2
+            expect(merge_request.notes.non_diff_notes.last.note).to eq(review_error_note)
           end
         end
 
@@ -267,9 +265,9 @@
           it 'updates progress note with an error message' do
             completion.execute
 
-            expect(Note.count).to eq 1
-            expect(Note.last.note).to eq s_("DuoCodeReview|I have encountered some issues while I was reviewing. " \
-              "Please try again later.")
+            expect(merge_request.notes.count).to eq 3
+            expect(merge_request.notes.diff_notes.count).to eq 2
+            expect(merge_request.notes.non_diff_notes.last.note).to eq(review_error_note)
           end
         end
       end
@@ -279,37 +277,23 @@
           stub_const("#{described_class}::DRAFT_NOTES_COUNT_LIMIT", 1)
         end
 
-        it 'builds draft note and publish to create diff note on new and updated files' do
-          new_file_draft_note_params = {
-            merge_request: merge_request,
-            author: duo_code_review_bot,
-            note: review_answer,
-            position: {
-              base_sha: diff_refs.base_sha,
-              start_sha: diff_refs.start_sha,
-              head_sha: diff_refs.head_sha,
-              old_path: 'NEW.md',
-              new_path: 'NEW.md',
-              position_type: 'text',
-              old_line: nil,
-              new_line: 4,
-              ignore_whitespace_change: false
-            }
-          }
-
-          draft_note_1 = instance_double(DraftNote)
-
-          expect(DraftNote).to receive(:new).with(new_file_draft_note_params).and_return(draft_note_1)
-          expect(DraftNote).to receive(:bulk_insert!).with([draft_note_1], batch_size: 20)
-          expect_next_instance_of(
-            DraftNotes::PublishService,
-            merge_request,
-            duo_code_review_bot
-          ) do |svc|
-            expect(svc).to receive(:execute).with(executing_user: user)
-          end
-
+        it 'creates diff note on the first file only' do
           completion.execute
+          diff_notes = merge_request.notes.diff_notes
+          expect(diff_notes.count).to eq 1
+
+          expect(diff_notes[0].position.to_h).to eq({
+            base_sha: diff_refs.base_sha,
+            start_sha: diff_refs.start_sha,
+            head_sha: diff_refs.head_sha,
+            old_path: 'NEW.md',
+            new_path: 'NEW.md',
+            position_type: 'text',
+            old_line: nil,
+            new_line: 2,
+            line_range: nil,
+            ignore_whitespace_change: false
+          })
         end
       end
     end
-- 
GitLab