diff options
Diffstat (limited to 'lib/gitlab/suggestions')
-rw-r--r-- | lib/gitlab/suggestions/commit_message.rb | 54 | ||||
-rw-r--r-- | lib/gitlab/suggestions/file_suggestion.rb | 107 | ||||
-rw-r--r-- | lib/gitlab/suggestions/suggestion_set.rb | 120 |
3 files changed, 281 insertions, 0 deletions
diff --git a/lib/gitlab/suggestions/commit_message.rb b/lib/gitlab/suggestions/commit_message.rb new file mode 100644 index 00000000000..d59a8fc3730 --- /dev/null +++ b/lib/gitlab/suggestions/commit_message.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Gitlab + module Suggestions + class CommitMessage + DEFAULT_SUGGESTION_COMMIT_MESSAGE = + 'Apply %{suggestions_count} suggestion(s) to %{files_count} file(s)' + + def initialize(user, suggestion_set) + @user = user + @suggestion_set = suggestion_set + end + + def message + project = suggestion_set.project + user_defined_message = project.suggestion_commit_message.presence + message = user_defined_message || DEFAULT_SUGGESTION_COMMIT_MESSAGE + + Gitlab::StringPlaceholderReplacer + .replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key| + PLACEHOLDERS[key].call(user, suggestion_set) + end + end + + def self.format_paths(paths) + paths.sort.join(', ') + end + + private_class_method :format_paths + + private + + attr_reader :user, :suggestion_set + + PLACEHOLDERS = { + 'branch_name' => ->(user, suggestion_set) { suggestion_set.branch }, + 'files_count' => ->(user, suggestion_set) { suggestion_set.file_paths.length }, + 'file_paths' => ->(user, suggestion_set) { format_paths(suggestion_set.file_paths) }, + 'project_name' => ->(user, suggestion_set) { suggestion_set.project.name }, + 'project_path' => ->(user, suggestion_set) { suggestion_set.project.path }, + 'user_full_name' => ->(user, suggestion_set) { user.name }, + 'username' => ->(user, suggestion_set) { user.username }, + 'suggestions_count' => ->(user, suggestion_set) { suggestion_set.suggestions.size } + }.freeze + + # This regex is built dynamically using the keys from the PLACEHOLDER struct. + # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash. + # This regex will build the new PLACEHOLDER_REGEX with the new information + PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map do |key| + Regexp.new(Regexp.escape(key)) + end).freeze + end + end +end diff --git a/lib/gitlab/suggestions/file_suggestion.rb b/lib/gitlab/suggestions/file_suggestion.rb new file mode 100644 index 00000000000..73b9800f0b8 --- /dev/null +++ b/lib/gitlab/suggestions/file_suggestion.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Gitlab + module Suggestions + class FileSuggestion + include Gitlab::Utils::StrongMemoize + + SuggestionForDifferentFileError = Class.new(StandardError) + + def initialize + @suggestions = [] + end + + def add_suggestion(new_suggestion) + if for_different_file?(new_suggestion) + raise SuggestionForDifferentFileError, + 'Only add suggestions for the same file.' + end + + suggestions << new_suggestion + end + + def line_conflict? + strong_memoize(:line_conflict) do + _line_conflict? + end + end + + def new_content + @new_content ||= _new_content + end + + def file_path + @file_path ||= _file_path + end + + private + + attr_accessor :suggestions + + def blob + first_suggestion&.diff_file&.new_blob + end + + def blob_data_lines + blob.load_all_data! + blob.data.lines + end + + def current_content + @current_content ||= blob.nil? ? [''] : blob_data_lines + end + + def _new_content + current_content.tap do |content| + suggestions.each do |suggestion| + range = line_range(suggestion) + content[range] = suggestion.to_content + end + end.join + end + + def line_range(suggestion) + suggestion.from_line_index..suggestion.to_line_index + end + + def for_different_file?(suggestion) + file_path && file_path != suggestion_file_path(suggestion) + end + + def suggestion_file_path(suggestion) + suggestion&.diff_file&.file_path + end + + def first_suggestion + suggestions.first + end + + def _file_path + suggestion_file_path(first_suggestion) + end + + def _line_conflict? + has_conflict = false + + suggestions.each_with_object([]) do |suggestion, ranges| + range_in_test = line_range(suggestion) + + if has_range_conflict?(range_in_test, ranges) + has_conflict = true + break + end + + ranges << range_in_test + end + + has_conflict + end + + def has_range_conflict?(range_in_test, ranges) + ranges.any? do |range| + range.overlaps?(range_in_test) + end + end + end + end +end diff --git a/lib/gitlab/suggestions/suggestion_set.rb b/lib/gitlab/suggestions/suggestion_set.rb new file mode 100644 index 00000000000..22abef98bf0 --- /dev/null +++ b/lib/gitlab/suggestions/suggestion_set.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Gitlab + module Suggestions + class SuggestionSet + attr_reader :suggestions + + def initialize(suggestions) + @suggestions = suggestions + end + + def project + first_suggestion.project + end + + def branch + first_suggestion.branch + end + + def valid? + error_message.nil? + end + + def error_message + @error_message ||= _error_message + end + + def actions + @actions ||= suggestions_per_file.map do |file_path, file_suggestion| + { + action: 'update', + file_path: file_path, + content: file_suggestion.new_content + } + end + end + + def file_paths + @file_paths ||= suggestions.map(&:file_path).uniq + end + + private + + def first_suggestion + suggestions.first + end + + def suggestions_per_file + @suggestions_per_file ||= _suggestions_per_file + end + + def _suggestions_per_file + suggestions.each_with_object({}) do |suggestion, result| + file_path = suggestion.diff_file.file_path + file_suggestion = result[file_path] ||= FileSuggestion.new + file_suggestion.add_suggestion(suggestion) + end + end + + def file_suggestions + suggestions_per_file.values + end + + def first_file_suggestion + file_suggestions.first + end + + def _error_message + suggestions.each do |suggestion| + message = error_for_suggestion(suggestion) + + return message if message + end + + has_line_conflict = file_suggestions.any? do |file_suggestion| + file_suggestion.line_conflict? + end + + if has_line_conflict + return _('Suggestions are not applicable as their lines cannot overlap.') + end + + nil + end + + def error_for_suggestion(suggestion) + unless suggestion.diff_file + return _('A file was not found.') + end + + unless on_same_branch?(suggestion) + return _('Suggestions must all be on the same branch.') + end + + unless suggestion.appliable?(cached: false) + return _('A suggestion is not applicable.') + end + + unless latest_source_head?(suggestion) + return _('A file has been changed.') + end + + nil + end + + def on_same_branch?(suggestion) + branch == suggestion.branch + end + + # Checks whether the latest source branch HEAD matches with + # the position HEAD we're using to update the file content. Since + # the persisted HEAD is updated async (for MergeRequest), + # it's more consistent to fetch this data directly from the + # repository. + def latest_source_head?(suggestion) + suggestion.position.head_sha == suggestion.noteable.source_branch_sha + end + end + end +end |