From e4f59f2ecb0dd9958ab48c50dcd42ee0c6517ea8 Mon Sep 17 00:00:00 2001
From: Kassio Borges <kborges@gitlab.com>
Date: Wed, 29 Sep 2021 18:42:31 +0100
Subject: [PATCH] GithubImporter: Format diff note suggestions to the gitlab
 format

Github "suggestion" feature has a different markdown format, it uses:

    ```suggestion
    SUGGESTION
    ```

While Gitlab has a _range_ in the suggestion markdown, like:

    ```suggestion-0+0
    SUGGESTION
    ```

To convert the Github format to Gitlab, in the `DiffNote`
_representation_ the range will be added. The range is calculated by the
difference of the `start_line` and `line` when the start_line is
present, which indicates a multi-line suggestion.

Related to: https://gitlab.com/gitlab-org/gitlab/-/issues/340624

Changelog: changed
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71411
---
 .../github_import/representation/diff_note.rb |  12 +-
 .../diff_notes/suggestion_formatter.rb        |  66 +++++++
 .../importer/diff_notes_importer_spec.rb      |  12 +-
 .../representation/diff_note_spec.rb          | 117 ++++++++++---
 .../diff_notes/suggestion_formatter_spec.rb   | 164 ++++++++++++++++++
 5 files changed, 347 insertions(+), 24 deletions(-)
 create mode 100644 lib/gitlab/github_import/representation/diff_notes/suggestion_formatter.rb
 create mode 100644 spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb

diff --git a/lib/gitlab/github_import/representation/diff_note.rb b/lib/gitlab/github_import/representation/diff_note.rb
index 1d6dfbead5f1c059..a3dcd2e380c6cead 100644
--- a/lib/gitlab/github_import/representation/diff_note.rb
+++ b/lib/gitlab/github_import/representation/diff_note.rb
@@ -40,7 +40,9 @@ def self.from_api_response(note)
             note: note.body,
             created_at: note.created_at,
             updated_at: note.updated_at,
-            note_id: note.id
+            note_id: note.id,
+            end_line: note.line,
+            start_line: note.start_line
           }
 
           new(hash)
@@ -83,6 +85,14 @@ def diff_hash
           }
         end
 
+        def note
+          @note ||= DiffNotes::SuggestionFormatter.formatted_note_for(
+            note: attributes[:note],
+            start_line: attributes[:start_line],
+            end_line: attributes[:end_line]
+          )
+        end
+
         def github_identifiers
           {
             note_id: note_id,
diff --git a/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter.rb b/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter.rb
new file mode 100644
index 0000000000000000..4e5855ee4cdbdf36
--- /dev/null
+++ b/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+# This class replaces Github markdown suggestion tag with
+# a Gitlab suggestion tag. The difference between
+# Github's and Gitlab's suggestion tags is that Gitlab
+# includes the range of the suggestion in the tag, while Github
+# uses other note attributes to position the suggestion.
+module Gitlab
+  module GithubImport
+    module Representation
+      module DiffNotes
+        class SuggestionFormatter
+          # A github suggestion:
+          # - the ```suggestion tag must be the first text of the line
+          #   - it might have up to 3 spaces before the ```suggestion tag
+          # - extra text on the ```suggestion tag line will be ignored
+          GITHUB_SUGGESTION = /^\ {,3}(?<suggestion>```suggestion\b).*(?<eol>\R)/.freeze
+
+          def self.formatted_note_for(...)
+            new(...).formatted_note
+          end
+
+          def initialize(note:, start_line: nil, end_line: nil)
+            @note = note
+            @start_line = start_line
+            @end_line = end_line
+          end
+
+          def formatted_note
+            if contains_suggestion?
+              note.gsub(
+                GITHUB_SUGGESTION,
+                "\\k<suggestion>:#{suggestion_range}\\k<eol>"
+              )
+            else
+              note
+            end
+          end
+
+          private
+
+          attr_reader :note, :start_line, :end_line
+
+          def contains_suggestion?
+            note.to_s.match?(GITHUB_SUGGESTION)
+          end
+
+          # Github always saves the comment on the _last_ line of the range.
+          # Therefore, the diff hunk will always be related to lines before
+          # the comment itself.
+          def suggestion_range
+            "-#{line_count}+0"
+          end
+
+          def line_count
+            if start_line.present?
+              end_line - start_line
+            else
+              0
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
index 46b9959ff64f88cc..be4fc3cbf16ff4d7 100644
--- a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
@@ -15,10 +15,18 @@
       original_commit_id: 'original123abc',
       diff_hunk: "@@ -1 +1 @@\n-Hello\n+Hello world",
       user: double(:user, id: 4, login: 'alice'),
-      body: 'Hello world',
       created_at: Time.zone.now,
       updated_at: Time.zone.now,
-      id: 1
+      line: 23,
+      start_line: nil,
+      id: 1,
+      body: <<~BODY
+        Hello World
+
+        ```suggestion
+        sug1
+        ```
+      BODY
     )
   end
 
diff --git a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
index 91479d23f43bb01d..81722c0eba740cae 100644
--- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
@@ -73,6 +73,8 @@
         body: 'Hello world',
         created_at: created_at,
         updated_at: updated_at,
+        line: 23,
+        start_line: nil,
         id: 1
       )
     end
@@ -90,47 +92,70 @@
 
       expect(note.author).to be_nil
     end
-  end
 
-  describe '.from_json_hash' do
-    it_behaves_like 'a DiffNote' do
-      let(:hash) do
-        {
-          'noteable_type' => 'MergeRequest',
-          'noteable_id' => 42,
-          'file_path' => 'README.md',
-          'commit_id' => '123abc',
-          'original_commit_id' => 'original123abc',
-          'diff_hunk' => hunk,
-          'author' => { 'id' => 4, 'login' => 'alice' },
-          'note' => 'Hello world',
-          'created_at' => created_at.to_s,
-          'updated_at' => updated_at.to_s,
-          'note_id' => 1
-        }
-      end
+    it 'formats a suggestion in the note body' do
+      allow(response)
+        .to receive(:body)
+        .and_return <<~BODY
+      ```suggestion
+      Hello World
+      ```
+      BODY
 
-      let(:note) { described_class.from_json_hash(hash) }
+      note = described_class.from_api_response(response)
+
+      expect(note.note).to eq <<~BODY
+      ```suggestion:-0+0
+      Hello World
+      ```
+      BODY
     end
+  end
 
-    it 'does not convert the author if it was not specified' do
-      hash = {
+  describe '.from_json_hash' do
+    let(:hash) do
+      {
         'noteable_type' => 'MergeRequest',
         'noteable_id' => 42,
         'file_path' => 'README.md',
         'commit_id' => '123abc',
         'original_commit_id' => 'original123abc',
         'diff_hunk' => hunk,
+        'author' => { 'id' => 4, 'login' => 'alice' },
         'note' => 'Hello world',
         'created_at' => created_at.to_s,
         'updated_at' => updated_at.to_s,
         'note_id' => 1
       }
+    end
+
+    it_behaves_like 'a DiffNote' do
+      let(:note) { described_class.from_json_hash(hash) }
+    end
+
+    it 'does not convert the author if it was not specified' do
+      hash.delete('author')
 
       note = described_class.from_json_hash(hash)
 
       expect(note.author).to be_nil
     end
+
+    it 'formats a suggestion in the note body' do
+      hash['note'] = <<~BODY
+      ```suggestion
+      Hello World
+      ```
+      BODY
+
+      note = described_class.from_json_hash(hash)
+
+      expect(note.note).to eq <<~BODY
+      ```suggestion:-0+0
+      Hello World
+      ```
+      BODY
+    end
   end
 
   describe '#line_code' do
@@ -181,4 +206,54 @@
       expect(note.github_identifiers).to eq(github_identifiers)
     end
   end
+
+  describe '#note' do
+    it 'returns the given note' do
+      hash = {
+        'note': 'simple text'
+      }
+
+      note = described_class.new(hash)
+
+      expect(note.note).to eq 'simple text'
+    end
+
+    it 'returns the suggestion formatted in the note' do
+      hash = {
+        'note': <<~BODY
+        ```suggestion
+        Hello World
+        ```
+        BODY
+      }
+
+      note = described_class.new(hash)
+
+      expect(note.note).to eq <<~BODY
+      ```suggestion:-0+0
+      Hello World
+      ```
+      BODY
+    end
+
+    it 'returns the multi-line suggestion formatted in the note' do
+      hash = {
+        'start_line': 20,
+        'end_line': 23,
+        'note': <<~BODY
+        ```suggestion
+        Hello World
+        ```
+        BODY
+      }
+
+      note = described_class.new(hash)
+
+      expect(note.note).to eq <<~BODY
+      ```suggestion:-3+0
+      Hello World
+      ```
+      BODY
+    end
+  end
 end
diff --git a/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb b/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb
new file mode 100644
index 0000000000000000..2ffd5f50d3ba901c
--- /dev/null
+++ b/spec/lib/gitlab/github_import/representation/diff_notes/suggestion_formatter_spec.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormatter do
+  it 'does nothing when there is any text before the suggestion tag' do
+    note = <<~BODY
+    looks like```suggestion but it isn't
+    ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note)).to eq(note)
+  end
+
+  it 'handles nil value for note' do
+    note = nil
+
+    expect(described_class.formatted_note_for(note: note)).to eq(note)
+  end
+
+  it 'does not allow over 3 leading spaces for valid suggestion' do
+    note = <<~BODY
+      Single-line suggestion
+          ```suggestion
+      sug1
+      ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note)).to eq(note)
+  end
+
+  it 'allows up to 3 leading spaces' do
+    note = <<~BODY
+      Single-line suggestion
+         ```suggestion
+      sug1
+      ```
+    BODY
+
+    expected = <<~BODY
+      Single-line suggestion
+      ```suggestion:-0+0
+      sug1
+      ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note)).to eq(expected)
+  end
+
+  it 'does nothing when there is any text without space after the suggestion tag' do
+    note = <<~BODY
+    ```suggestionbut it isn't
+    ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note)).to eq(note)
+  end
+
+  it 'formats single-line suggestions' do
+    note = <<~BODY
+      Single-line suggestion
+      ```suggestion
+      sug1
+      ```
+    BODY
+
+    expected = <<~BODY
+      Single-line suggestion
+      ```suggestion:-0+0
+      sug1
+      ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note)).to eq(expected)
+  end
+
+  it 'ignores text after suggestion tag on the same line' do
+    note = <<~BODY
+    looks like
+    ```suggestion text to be ignored
+    suggestion
+    ```
+    BODY
+
+    expected = <<~BODY
+    looks like
+    ```suggestion:-0+0
+    suggestion
+    ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note)).to eq(expected)
+  end
+
+  it 'formats multiple single-line suggestions' do
+    note = <<~BODY
+      Single-line suggestion
+      ```suggestion
+      sug1
+      ```
+      OR
+      ```suggestion
+      sug2
+      ```
+    BODY
+
+    expected = <<~BODY
+      Single-line suggestion
+      ```suggestion:-0+0
+      sug1
+      ```
+      OR
+      ```suggestion:-0+0
+      sug2
+      ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note)).to eq(expected)
+  end
+
+  it 'formats multi-line suggestions' do
+    note = <<~BODY
+      Multi-line suggestion
+      ```suggestion
+      sug1
+      ```
+    BODY
+
+    expected = <<~BODY
+      Multi-line suggestion
+      ```suggestion:-2+0
+      sug1
+      ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected)
+  end
+
+  it 'formats multiple multi-line suggestions' do
+    note = <<~BODY
+      Multi-line suggestion
+      ```suggestion
+      sug1
+      ```
+      OR
+      ```suggestion
+      sug2
+      ```
+    BODY
+
+    expected = <<~BODY
+      Multi-line suggestion
+      ```suggestion:-2+0
+      sug1
+      ```
+      OR
+      ```suggestion:-2+0
+      sug2
+      ```
+    BODY
+
+    expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected)
+  end
+end
-- 
GitLab