From 7b1a394d77921bd36de42a5c545e9091f2dadb95 Mon Sep 17 00:00:00 2001
From: Kinshuk Singh <kinsingh@gitlab.com>
Date: Thu, 13 Mar 2025 12:49:38 -0400
Subject: [PATCH] Add GitLabDuo feedback string for diff review comments

---
 .../notes/components/note_awards_list.vue     |   6 +
 .../notes/components/note_body.vue            |  37 ++++-
 locale/gitlab.pot                             |   3 +
 .../notes/components/note_body_spec.js        | 126 ++++++++++++++++++
 4 files changed, 170 insertions(+), 2 deletions(-)

diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 21f226cd20741..f968cc5a29980 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -30,6 +30,11 @@ export default {
       type: Boolean,
       required: true,
     },
+    defaultAwards: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
   },
   computed: {
     ...mapGetters(['getUserData']),
@@ -62,6 +67,7 @@ export default {
       :awards="awards"
       :can-award-emoji="canAwardEmoji"
       :current-user-id="getUserData.id"
+      :default-awards="defaultAwards"
       @award="handleAward($event)"
     />
   </div>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 8e2c343de97bc..7c5d8525e9d90 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -3,7 +3,7 @@ import { escape } from 'lodash';
 // eslint-disable-next-line no-restricted-imports
 import { mapActions, mapGetters, mapState } from 'vuex';
 import SafeHtml from '~/vue_shared/directives/safe_html';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
 import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
 import { renderGFM } from '~/behaviors/markdown/render_gfm';
 import NoteAttachment from './note_attachment.vue';
@@ -107,6 +107,33 @@ export default {
 
       return escape(suggestion);
     },
+    isDuoFirstReviewComment() {
+      // Must be a Duo bot comment of type DiffNote
+      if (this.note.author.user_type !== 'duo_code_review_bot' || this.note.type !== 'DiffNote') {
+        return false;
+      }
+      // Get the discussion
+      const discussion = this.getDiscussion(this.note.discussion_id);
+      // If can't get discussion or this is not the first note, don't show feedback
+      return discussion?.notes?.length > 0 && discussion.notes[0].id === this.note.id;
+    },
+    defaultAwardsList() {
+      return this.isDuoFirstReviewComment ? ['thumbsup', 'thumbsdown'] : [];
+    },
+    duoFeedbackText() {
+      return sprintf(
+        __(
+          'Rate this response %{separator} %{codeStart}%{botUser}%{codeEnd} in reply for more questions',
+        ),
+        {
+          separator: '•',
+          codeStart: '<code>',
+          botUser: '@GitLabDuo',
+          codeEnd: '</code>',
+        },
+        false,
+      );
+    },
   },
   watch: {
     note: {
@@ -226,13 +253,19 @@ export default {
       :action-text="__('Edited')"
       class="note_edited_ago"
     />
+    <div
+      v-if="isDuoFirstReviewComment"
+      v-safe-html:[$options.safeHtmlConfig]="duoFeedbackText"
+      class="gl-text-md gl-mt-4 gl-text-gray-500"
+    ></div>
     <note-awards-list
-      v-if="note.award_emoji && note.award_emoji.length"
+      v-if="isDuoFirstReviewComment || (note.award_emoji && note.award_emoji.length)"
       :note-id="note.id"
       :note-author-id="note.author.id"
       :awards="note.award_emoji"
       :toggle-award-path="note.toggle_award_path"
       :can-award-emoji="note.current_user.can_award_emoji"
+      :default-awards="defaultAwardsList"
     />
     <note-attachment v-if="note.attachment" :attachment="note.attachment" />
   </div>
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e8466f339c2cc..4a17cf45ea800 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -48026,6 +48026,9 @@ msgstr ""
 msgid "Rate the review"
 msgstr ""
 
+msgid "Rate this response %{separator} %{codeStart}%{botUser}%{codeEnd} in reply for more questions"
+msgstr ""
+
 msgid "Raw blob request rate limit per minute"
 msgstr ""
 
diff --git a/spec/frontend/notes/components/note_body_spec.js b/spec/frontend/notes/components/note_body_spec.js
index 6cb8474537af0..54c57678e39b3 100644
--- a/spec/frontend/notes/components/note_body_spec.js
+++ b/spec/frontend/notes/components/note_body_spec.js
@@ -167,3 +167,129 @@ describe('issue_note_body component', () => {
     );
   });
 });
+
+describe('duo code review feedback text', () => {
+  const createMockStoreWithDiscussion = (discussionId, discussionNotes) => {
+    return new Vuex.Store({
+      getters: {
+        getDiscussion: () => (id) => {
+          if (id === discussionId) {
+            return { notes: discussionNotes };
+          }
+          return {};
+        },
+        suggestionsCount: () => 0,
+        getSuggestionsFilePaths: () => [],
+      },
+      modules: {
+        diffs: {
+          namespaced: true,
+          getters: {
+            suggestionCommitMessage: () => () => '',
+          },
+        },
+        notes: {
+          state: { batchSuggestionsInfo: [] },
+        },
+        page: {
+          state: { failedToLoadMetadata: false },
+        },
+      },
+    });
+  };
+
+  const createDuoNote = (props = {}) => ({
+    ...note,
+    id: '1',
+    type: 'DiffNote',
+    discussion_id: 'discussion1',
+    author: {
+      ...note.author,
+      user_type: 'duo_code_review_bot',
+    },
+    ...props,
+  });
+
+  it('renders feedback text for the first DiffNote from GitLabDuo', () => {
+    const duoNote = createDuoNote();
+    const mockStore = createMockStoreWithDiscussion('discussion1', [duoNote]);
+
+    const wrapper = createComponent({
+      props: { note: duoNote },
+      store: mockStore,
+    });
+
+    const feedbackDiv = wrapper.find('.gl-text-md.gl-mt-4.gl-text-gray-500');
+    expect(feedbackDiv.exists()).toBe(true);
+  });
+
+  it('does not render feedback text for non-DiffNote from GitLabDuo', () => {
+    const duoNote = createDuoNote({ type: 'DiscussionNote' });
+
+    const wrapper = createComponent({
+      props: { note: duoNote },
+    });
+
+    const feedbackDiv = wrapper.find('.gl-text-md.gl-mt-4.gl-text-gray-500');
+    expect(feedbackDiv.exists()).toBe(false);
+  });
+
+  it('does not render feedback text for follow-up DiffNote from GitLabDuo', () => {
+    const duoNote = createDuoNote({ id: '2' });
+    const mockStore = createMockStoreWithDiscussion('discussion1', [
+      { id: '1' }, // First note has different ID
+      duoNote,
+    ]);
+
+    const wrapper = createComponent({
+      props: { note: duoNote },
+      store: mockStore,
+    });
+
+    const feedbackDiv = wrapper.find('.gl-text-md.gl-mt-4.gl-text-gray-500');
+    expect(feedbackDiv.exists()).toBe(false);
+  });
+
+  it('shows default awards list with thumbsup and thumbsdown for first DiffNote from GitLabDuo', () => {
+    const duoNote = createDuoNote();
+    const mockStore = createMockStoreWithDiscussion('discussion1', [duoNote]);
+
+    const wrapper = createComponent({
+      props: { note: duoNote },
+      store: mockStore,
+    });
+
+    const awardsList = wrapper.findComponent(NoteAwardsList);
+    expect(awardsList.exists()).toBe(true);
+    expect(awardsList.props('defaultAwards')).toEqual(['thumbsup', 'thumbsdown']);
+  });
+
+  it('uses empty default awards list for non-Duo comments', () => {
+    const regularNote = {
+      ...note,
+      id: '1',
+      author: {
+        ...note.author,
+        user_type: 'human',
+      },
+    };
+
+    const wrapper = createComponent({
+      props: { note: regularNote },
+    });
+
+    const awardsList = wrapper.findComponent(NoteAwardsList);
+    expect(awardsList.props('defaultAwards')).toEqual([]);
+  });
+
+  describe('duoFeedbackText computed property', () => {
+    it('returns the expected feedback text', () => {
+      const wrapper = createComponent();
+
+      const result = wrapper.vm.duoFeedbackText;
+      expect(result).toContain('Rate this response');
+      expect(result).toContain('@GitLabDuo');
+      expect(result).toContain('in reply for more questions');
+    });
+  });
+});
-- 
GitLab