Commit 2db9d635 authored by Kassio Borges's avatar Kassio Borges

GithubImport: Enable users to import _modern_ diff notes

To enable users to import _modern_ diff notes features, like
suggestions, the diff note must be created with `DiffNote` model,
instead of `LegacyDiffNote`.

This commit enables the GithubImporter to use `DiffNotes` when the
`:github_importer_replace_legacy_diffnote` feature flag is enabled.

With the intent to get better performance, and given that Github doesn't
support `quick_actions`, the quick actions parsing is disabled for notes
created by the importer.

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

MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71765
parent fbbcaddf
# frozen_string_literal: true
module Import
module Github
module Notes
class CreateService < ::Notes::CreateService
# Github does not have support to quick actions in notes (like /assign)
# Therefore, when importing notes we skip the quick actions processing
def quick_actions_supported?(_note)
false
end
end
end
end
end
...@@ -43,7 +43,7 @@ module Notes ...@@ -43,7 +43,7 @@ module Notes
private private
def execute_quick_actions(note) def execute_quick_actions(note)
return yield(false) unless quick_actions_service.supported?(note) return yield(false) unless quick_actions_supported?(note)
content, update_params, message = quick_actions_service.execute(note, quick_action_options) content, update_params, message = quick_actions_service.execute(note, quick_action_options)
only_commands = content.empty? only_commands = content.empty?
...@@ -54,6 +54,10 @@ module Notes ...@@ -54,6 +54,10 @@ module Notes
do_commands(note, update_params, message, only_commands) do_commands(note, update_params, message, only_commands)
end end
def quick_actions_supported?(note)
quick_actions_service.supported?(note)
end
def quick_actions_service def quick_actions_service
@quick_actions_service ||= QuickActionsService.new(project, current_user) @quick_actions_service ||= QuickActionsService.new(project, current_user)
end end
......
---
name: github_importer_use_diff_note_with_suggestions
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71765
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/344309
milestone: '14.5'
type: development
group: group::import
default_enabled: false
...@@ -4,58 +4,132 @@ module Gitlab ...@@ -4,58 +4,132 @@ module Gitlab
module GithubImport module GithubImport
module Importer module Importer
class DiffNoteImporter class DiffNoteImporter
attr_reader :note, :project, :client, :user_finder # note - An instance of `Gitlab::GithubImport::Representation::DiffNote`
# project - An instance of `Project`
# note - An instance of `Gitlab::GithubImport::Representation::DiffNote`. # client - An instance of `Gitlab::GithubImport::Client`
# project - An instance of `Project`.
# client - An instance of `Gitlab::GithubImport::Client`.
def initialize(note, project, client) def initialize(note, project, client)
@note = note @note = note
@project = project @project = project
@client = client @client = client
@user_finder = GithubImport::UserFinder.new(project, client)
end end
def execute def execute
return unless (mr_id = find_merge_request_id) return if merge_request_id.blank?
author_id, author_found = user_finder.author_id_for(note) build_author_attributes
note_body = MarkdownText.format(note.note, note.author, author_found) # Diff notes with suggestions are imported with DiffNote, which is
# slower to import than LegacyDiffNote. Importing DiffNote is slower
# because it cannot use the BulkImporting strategy, which skips
# callbacks and validations. For this reason, notes that don't have
# suggestions are still imported with LegacyDiffNote
if import_with_diff_note?
import_with_diff_note
else
import_with_legacy_diff_note
end
rescue ActiveRecord::InvalidForeignKey => e
# It's possible the project and the issue have been deleted since
# scheduling this job. In this case we'll just skip creating the note
Logger.info(
message: e.message,
github_identifiers: note.github_identifiers
)
end
attributes = { private
discussion_id: Discussion.discussion_id(note),
attr_reader :note, :project, :client, :author_id, :author_found
def import_with_diff_note?
note.contains_suggestion? && use_diff_note_with_suggestions_enabled?
end
def use_diff_note_with_suggestions_enabled?
Feature.enabled?(
:github_importer_use_diff_note_with_suggestions,
default_enabled: :yaml
)
end
def build_author_attributes
@author_id, @author_found = user_finder.author_id_for(note)
end
# rubocop:disable Gitlab/BulkInsert
def import_with_legacy_diff_note
log_diff_note_creation('LegacyDiffNote')
# It's possible that during an import we'll insert tens of thousands
# of diff notes. If we were to use the Note/LegacyDiffNote model here
# we'd also have to run additional queries for both validations and
# callbacks, putting a lot of pressure on the database.
#
# To work around this we're using bulk_insert with a single row. This
# allows us to efficiently insert data (even if it's just 1 row)
# without having to use all sorts of hacks to disable callbacks.
Gitlab::Database.main.bulk_insert(LegacyDiffNote.table_name, [{
noteable_type: 'MergeRequest', noteable_type: 'MergeRequest',
noteable_id: mr_id, system: false,
type: 'LegacyDiffNote',
discussion_id: Discussion.discussion_id(note),
noteable_id: merge_request_id,
project_id: project.id, project_id: project.id,
author_id: author_id, author_id: author_id,
note: note_body, note: note_body,
system: false,
commit_id: note.original_commit_id, commit_id: note.original_commit_id,
line_code: note.line_code, line_code: note.line_code,
type: 'LegacyDiffNote',
created_at: note.created_at, created_at: note.created_at,
updated_at: note.updated_at, updated_at: note.updated_at,
st_diff: note.diff_hash.to_yaml st_diff: note.diff_hash.to_yaml
} }])
end
# rubocop:enabled Gitlab/BulkInsert
# It's possible that during an import we'll insert tens of thousands def import_with_diff_note
# of diff notes. If we were to use the Note/LegacyDiffNote model here log_diff_note_creation('DiffNote')
# we'd also have to run additional queries for both validations and
# callbacks, putting a lot of pressure on the database. ::Import::Github::Notes::CreateService.new(project, author, {
# noteable_type: 'MergeRequest',
# To work around this we're using bulk_insert with a single row. This system: false,
# allows us to efficiently insert data (even if it's just 1 row) type: 'DiffNote',
# without having to use all sorts of hacks to disable callbacks. noteable_id: merge_request_id,
Gitlab::Database.main.bulk_insert(LegacyDiffNote.table_name, [attributes]) # rubocop:disable Gitlab/BulkInsert project_id: project.id,
rescue ActiveRecord::InvalidForeignKey note: note_body,
# It's possible the project and the issue have been deleted since commit_id: note.original_commit_id,
# scheduling this job. In this case we'll just skip creating the note. created_at: note.created_at,
updated_at: note.updated_at,
position: note.diff_position(merge_request)
}).execute
end
def note_body
@note_body ||= MarkdownText.format(note.note, note.author, author_found)
end
def author
@author ||= User.find(author_id)
end
def merge_request
@merge_request ||= MergeRequest.find(merge_request_id)
end end
# Returns the ID of the merge request this note belongs to. # Returns the ID of the merge request this note belongs to.
def find_merge_request_id def merge_request_id
GithubImport::IssuableFinder.new(project, note).database_id @merge_request_id ||= GithubImport::IssuableFinder.new(project, note).database_id
end
def user_finder
@user_finder ||= GithubImport::UserFinder.new(project, client)
end
def log_diff_note_creation(model)
Logger.info(
project_id: project.id,
importer: self.class.name,
github_identifiers: note.github_identifiers,
model: model
)
end end
end end
end end
......
...@@ -10,8 +10,9 @@ module Gitlab ...@@ -10,8 +10,9 @@ module Gitlab
attr_reader :attributes attr_reader :attributes
expose_attribute :noteable_type, :noteable_id, :commit_id, :file_path, expose_attribute :noteable_type, :noteable_id, :commit_id, :file_path,
:diff_hunk, :author, :note, :created_at, :updated_at, :diff_hunk, :author, :created_at, :updated_at,
:original_commit_id, :note_id :original_commit_id, :note_id, :end_line, :start_line,
:side
NOTEABLE_ID_REGEX = %r{/pull/(?<iid>\d+)}i.freeze NOTEABLE_ID_REGEX = %r{/pull/(?<iid>\d+)}i.freeze
...@@ -42,7 +43,8 @@ module Gitlab ...@@ -42,7 +43,8 @@ module Gitlab
updated_at: note.updated_at, updated_at: note.updated_at,
note_id: note.id, note_id: note.id,
end_line: note.line, end_line: note.line,
start_line: note.start_line start_line: note.start_line,
side: note.side
} }
new(hash) new(hash)
...@@ -60,17 +62,31 @@ module Gitlab ...@@ -60,17 +62,31 @@ module Gitlab
# Hash must be Symbols. # Hash must be Symbols.
def initialize(attributes) def initialize(attributes)
@attributes = attributes @attributes = attributes
@note_formatter = DiffNotes::SuggestionFormatter.new(
note: attributes[:note],
start_line: attributes[:start_line],
end_line: attributes[:end_line]
)
end
def contains_suggestion?
@note_formatter.contains_suggestion?
end
def note
@note_formatter.formatted_note
end end
def line_code def line_code
diff_line = Gitlab::Diff::Parser.new.parse(diff_hunk.lines).to_a.last diff_line = Gitlab::Diff::Parser.new.parse(diff_hunk.lines).to_a.last
Gitlab::Git Gitlab::Git.diff_line_code(file_path, diff_line.new_pos, diff_line.old_pos)
.diff_line_code(file_path, diff_line.new_pos, diff_line.old_pos)
end end
# Returns a Hash that can be used to populate `notes.st_diff`, removing # Returns a Hash that can be used to populate `notes.st_diff`, removing
# the need for requesting Git data for every diff note. # the need for requesting Git data for every diff note.
# Used when importing with LegacyDiffNote
def diff_hash def diff_hash
{ {
diff: diff_hunk, diff: diff_hunk,
...@@ -85,12 +101,15 @@ module Gitlab ...@@ -85,12 +101,15 @@ module Gitlab
} }
end end
def note # Used when importing with DiffNote
@note ||= DiffNotes::SuggestionFormatter.formatted_note_for( def diff_position(merge_request)
note: attributes[:note], position_params = {
start_line: attributes[:start_line], diff_refs: merge_request.diff_refs,
end_line: attributes[:end_line] old_path: file_path,
) new_path: file_path
}
Gitlab::Diff::Position.new(position_params.merge(diff_line_params))
end end
def github_identifiers def github_identifiers
...@@ -100,6 +119,20 @@ module Gitlab ...@@ -100,6 +119,20 @@ module Gitlab
noteable_type: noteable_type noteable_type: noteable_type
} }
end end
private
def diff_line_params
if addition?
{ new_line: end_line, old_line: nil }
else
{ new_line: nil, old_line: end_line }
end
end
def addition?
side == 'RIGHT'
end
end end
end end
end end
......
...@@ -10,30 +10,38 @@ module Gitlab ...@@ -10,30 +10,38 @@ module Gitlab
module Representation module Representation
module DiffNotes module DiffNotes
class SuggestionFormatter class SuggestionFormatter
include Gitlab::Utils::StrongMemoize
# A github suggestion: # A github suggestion:
# - the ```suggestion tag must be the first text of the line # - the ```suggestion tag must be the first text of the line
# - it might have up to 3 spaces before the ```suggestion tag # - it might have up to 3 spaces before the ```suggestion tag
# - extra text on the ```suggestion tag line will be ignored # - extra text on the ```suggestion tag line will be ignored
GITHUB_SUGGESTION = /^\ {,3}(?<suggestion>```suggestion\b).*(?<eol>\R)/.freeze 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) def initialize(note:, start_line: nil, end_line: nil)
@note = note @note = note
@start_line = start_line @start_line = start_line
@end_line = end_line @end_line = end_line
end end
# Returns a tuple with:
# - a boolean indicating if the note has suggestions
# - the note with the suggestion formatted for Gitlab
def formatted_note def formatted_note
if contains_suggestion? @formatted_note ||=
note.gsub( if contains_suggestion?
GITHUB_SUGGESTION, note.gsub(
"\\k<suggestion>:#{suggestion_range}\\k<eol>" GITHUB_SUGGESTION,
) "\\k<suggestion>:#{suggestion_range}\\k<eol>"
else )
note else
note
end
end
def contains_suggestion?
strong_memoize(:contain_suggestion) do
note.to_s.match?(GITHUB_SUGGESTION)
end end
end end
...@@ -41,10 +49,6 @@ module Gitlab ...@@ -41,10 +49,6 @@ module Gitlab
attr_reader :note, :start_line, :end_line 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. # Github always saves the comment on the _last_ line of the range.
# Therefore, the diff hunk will always be related to lines before # Therefore, the diff hunk will always be related to lines before
# the comment itself. # the comment itself.
......
...@@ -20,6 +20,7 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do ...@@ -20,6 +20,7 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do
line: 23, line: 23,
start_line: nil, start_line: nil,
id: 1, id: 1,
side: 'RIGHT',
body: <<~BODY body: <<~BODY
Hello World Hello World
......
...@@ -9,13 +9,19 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -9,13 +9,19 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note)).to eq(note) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(note)
expect(note_formatter.contains_suggestion?).to eq(false)
end end
it 'handles nil value for note' do it 'handles nil value for note' do
note = nil note = nil
expect(described_class.formatted_note_for(note: note)).to eq(note) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(note)
expect(note_formatter.contains_suggestion?).to eq(false)
end end
it 'does not allow over 3 leading spaces for valid suggestion' do it 'does not allow over 3 leading spaces for valid suggestion' do
...@@ -26,7 +32,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -26,7 +32,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note)).to eq(note) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(note)
expect(note_formatter.contains_suggestion?).to eq(false)
end end
it 'allows up to 3 leading spaces' do it 'allows up to 3 leading spaces' do
...@@ -44,7 +53,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -44,7 +53,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note)).to eq(expected) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(expected)
expect(note_formatter.contains_suggestion?).to eq(true)
end end
it 'does nothing when there is any text without space after the suggestion tag' do it 'does nothing when there is any text without space after the suggestion tag' do
...@@ -53,7 +65,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -53,7 +65,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note)).to eq(note) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(note)
expect(note_formatter.contains_suggestion?).to eq(false)
end end
it 'formats single-line suggestions' do it 'formats single-line suggestions' do
...@@ -71,7 +86,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -71,7 +86,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note)).to eq(expected) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(expected)
expect(note_formatter.contains_suggestion?).to eq(true)
end end
it 'ignores text after suggestion tag on the same line' do it 'ignores text after suggestion tag on the same line' do
...@@ -89,7 +107,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -89,7 +107,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note)).to eq(expected) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(expected)
expect(note_formatter.contains_suggestion?).to eq(true)
end end
it 'formats multiple single-line suggestions' do it 'formats multiple single-line suggestions' do
...@@ -115,7 +136,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -115,7 +136,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note)).to eq(expected) note_formatter = described_class.new(note: note)
expect(note_formatter.formatted_note).to eq(expected)
expect(note_formatter.contains_suggestion?).to eq(true)
end end
it 'formats multi-line suggestions' do it 'formats multi-line suggestions' do
...@@ -133,7 +157,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -133,7 +157,10 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected) note_formatter = described_class.new(note: note, start_line: 6, end_line: 8)
expect(note_formatter.formatted_note).to eq(expected)
expect(note_formatter.contains_suggestion?).to eq(true)
end end
it 'formats multiple multi-line suggestions' do it 'formats multiple multi-line suggestions' do
...@@ -159,6 +186,9 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat ...@@ -159,6 +186,9 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNotes::SuggestionFormat
``` ```
BODY BODY
expect(described_class.formatted_note_for(note: note, start_line: 6, end_line: 8)).to eq(expected) note_formatter = described_class.new(note: note, start_line: 6, end_line: 8)
expect(note_formatter.formatted_note).to eq(expected)
expect(note_formatter.contains_suggestion?).to eq(true)
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Import::Github::Notes::CreateService do
it 'does not support quick actions' do
project = create(:project, :repository)
user = create(:user)
merge_request = create(:merge_request, source_project: project)
project.add_maintainer(user)
note = described_class.new(
project,
user,
note: '/close',
noteable_type: 'MergeRequest',
noteable_id: merge_request.id
).execute
expect(note.note).to eq('/close')
expect(note.noteable.closed?).to be(false)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment