Commit a7fe3895 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '25486-batch-suggestions' into 'master'

Users can apply multiple suggestions at once.

Closes #25486

See merge request gitlab-org/gitlab!22439
parents adb5d6ac c0417474
......@@ -38,6 +38,7 @@ const Api = {
userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
applySuggestionPath: '/api/:version/suggestions/:id/apply',
applySuggestionBatchPath: '/api/:version/suggestions/batch_apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
......@@ -322,6 +323,12 @@ const Api = {
return axios.put(url);
},
applySuggestionBatch(ids) {
const url = Api.buildUrl(Api.applySuggestionBatchPath);
return axios.put(url, { ids });
},
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')
......
<script>
import { mapActions, mapGetters } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import noteEditedText from './note_edited_text.vue';
......@@ -50,6 +50,9 @@ export default {
return this.getDiscussion(this.note.discussion_id);
},
...mapState({
batchSuggestionsInfo: state => state.notes.batchSuggestionsInfo,
}),
noteBody() {
return this.note.note;
},
......@@ -79,7 +82,12 @@ export default {
}
},
methods: {
...mapActions(['submitSuggestion']),
...mapActions([
'submitSuggestion',
'submitSuggestionBatch',
'addSuggestionInfoToBatch',
'removeSuggestionInfoFromBatch',
]),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
......@@ -96,6 +104,17 @@ export default {
callback,
);
},
applySuggestionBatch({ flashContainer }) {
return this.submitSuggestionBatch({ flashContainer });
},
addSuggestionToBatch(suggestionId) {
const { discussion_id: discussionId, id: noteId } = this.note;
this.addSuggestionInfoToBatch({ suggestionId, discussionId, noteId });
},
removeSuggestionFromBatch(suggestionId) {
this.removeSuggestionInfoFromBatch(suggestionId);
},
},
};
</script>
......@@ -105,10 +124,14 @@ export default {
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
:batch-suggestions-info="batchSuggestionsInfo"
:note-html="note.note_html"
:line-type="lineType"
:help-page-path="helpPagePath"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
@removeFromBatch="removeSuggestionFromBatch"
/>
<div v-else class="note-text md" v-html="note.note_html"></div>
<note-form
......
......@@ -524,12 +524,55 @@ export const submitSuggestion = (
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
);
const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
const errorMessage = err.response.data?.message;
const flashMessage = errorMessage || defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
});
};
export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => {
const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId);
const applyAllSuggestions = () =>
state.batchSuggestionsInfo.map(suggestionInfo =>
commit(types.APPLY_SUGGESTION, suggestionInfo),
);
const resolveAllDiscussions = () =>
state.batchSuggestionsInfo.map(suggestionInfo => {
const { discussionId } = suggestionInfo;
return dispatch('resolveDiscussion', { discussionId }).catch(() => {});
});
commit(types.SET_APPLYING_BATCH_STATE, true);
return Api.applySuggestionBatch(suggestionIds)
.then(() => Promise.all(applyAllSuggestions()))
.then(() => Promise.all(resolveAllDiscussions()))
.then(() => commit(types.CLEAR_SUGGESTION_BATCH))
.catch(err => {
const defaultMessage = __(
'Something went wrong while applying the batch of suggestions. Please try again.',
);
const errorMessage = err.response.data?.message;
const flashMessage = errorMessage || defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
})
.finally(() => commit(types.SET_APPLYING_BATCH_STATE, false));
};
export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) =>
commit(types.ADD_SUGGESTION_TO_BATCH, { suggestionId, noteId, discussionId });
export const removeSuggestionInfoFromBatch = ({ commit }, suggestionId) =>
commit(types.REMOVE_SUGGESTION_FROM_BATCH, suggestionId);
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);
......
......@@ -11,6 +11,7 @@ export default () => ({
targetNoteHash: null,
lastFetchedAt: null,
currentDiscussionId: null,
batchSuggestionsInfo: [],
// View layer
isToggleStateButtonLoading: false,
......
......@@ -17,6 +17,10 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
export const SET_APPLYING_BATCH_STATE = 'SET_APPLYING_BATCH_STATE';
export const ADD_SUGGESTION_TO_BATCH = 'ADD_SUGGESTION_TO_BATCH';
export const REMOVE_SUGGESTION_FROM_BATCH = 'REMOVE_SUGGESTION_FROM_BATCH';
export const CLEAR_SUGGESTION_BATCH = 'CLEAR_SUGGESTION_BATCH';
export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION';
......
......@@ -225,6 +225,39 @@ export default {
}));
},
[types.SET_APPLYING_BATCH_STATE](state, isApplyingBatch) {
state.batchSuggestionsInfo.forEach(suggestionInfo => {
const { discussionId, noteId, suggestionId } = suggestionInfo;
const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
const comment = utils.findNoteObjectById(noteObj.notes, noteId);
comment.suggestions = comment.suggestions.map(suggestion => ({
...suggestion,
is_applying_batch: suggestion.id === suggestionId && isApplyingBatch,
}));
});
},
[types.ADD_SUGGESTION_TO_BATCH](state, { noteId, discussionId, suggestionId }) {
state.batchSuggestionsInfo.push({
suggestionId,
noteId,
discussionId,
});
},
[types.REMOVE_SUGGESTION_FROM_BATCH](state, id) {
const index = state.batchSuggestionsInfo.findIndex(({ suggestionId }) => suggestionId === id);
if (index !== -1) {
state.batchSuggestionsInfo.splice(index, 1);
}
},
[types.CLEAR_SUGGESTION_BATCH](state) {
state.batchSuggestionsInfo.splice(0, state.batchSuggestionsInfo.length);
},
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
......
......@@ -13,6 +13,11 @@ export default {
type: Object,
required: true,
},
batchSuggestionsInfo: {
type: Array,
required: false,
default: () => [],
},
disabled: {
type: Boolean,
required: false,
......@@ -24,6 +29,14 @@ export default {
},
},
computed: {
batchSuggestionsCount() {
return this.batchSuggestionsInfo.length;
},
isBatched() {
return Boolean(
this.batchSuggestionsInfo.find(({ suggestionId }) => suggestionId === this.suggestion.id),
);
},
lines() {
return selectDiffLines(this.suggestion.diff_lines);
},
......@@ -32,6 +45,15 @@ export default {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
},
applySuggestionBatch() {
this.$emit('applyBatch');
},
addSuggestionToBatch() {
this.$emit('addToBatch', this.suggestion.id);
},
removeSuggestionFromBatch() {
this.$emit('removeFromBatch', this.suggestion.id);
},
},
};
</script>
......@@ -42,8 +64,14 @@ export default {
class="qa-suggestion-diff-header js-suggestion-diff-header"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
:is-batched="isBatched"
:is-applying-batch="suggestion.is_applying_batch"
:batch-suggestions-count="batchSuggestionsCount"
:help-page-path="helpPagePath"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
@removeFromBatch="removeSuggestionFromBatch"
/>
<table class="mb-3 md-suggestion-diff js-syntax-highlight code">
<tbody>
......
<script>
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
export default {
components: { Icon, GlDeprecatedButton, GlLoadingIcon },
directives: { 'gl-tooltip': GlTooltipDirective },
props: {
batchSuggestionsCount: {
type: Number,
required: false,
default: 0,
},
canApply: {
type: Boolean,
required: false,
......@@ -16,6 +22,16 @@ export default {
required: true,
default: false,
},
isBatched: {
type: Boolean,
required: false,
default: false,
},
isApplyingBatch: {
type: Boolean,
required: false,
default: false,
},
helpPagePath: {
type: String,
required: true,
......@@ -23,17 +39,38 @@ export default {
},
data() {
return {
isApplying: false,
isApplyingSingle: false,
};
},
computed: {
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
applyingSuggestionsMessage() {
if (this.isApplyingSingle || this.batchSuggestionsCount < 2) {
return __('Applying suggestion...');
}
return __('Applying suggestions...');
},
},
methods: {
applySuggestion() {
if (!this.canApply) return;
this.isApplying = true;
this.isApplyingSingle = true;
this.$emit('apply', this.applySuggestionCallback);
},
applySuggestionCallback() {
this.isApplying = false;
this.isApplyingSingle = false;
},
applySuggestionBatch() {
if (!this.canApply) return;
this.$emit('applyBatch');
},
addSuggestionToBatch() {
this.$emit('addToBatch');
},
removeSuggestionFromBatch() {
this.$emit('removeFromBatch');
},
},
};
......@@ -47,20 +84,49 @@ export default {
<icon name="question-o" css-classes="link-highlight" />
</a>
</div>
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
<div v-if="isApplying" class="d-flex align-items-center text-secondary">
<div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div>
<div v-else-if="isApplying" class="d-flex align-items-center text-secondary">
<gl-loading-icon class="d-flex-center mr-2" />
<span>{{ __('Applying suggestion') }}</span>
<span>{{ applyingSuggestionsMessage }}</span>
</div>
<div v-else-if="canApply && isBatched" class="d-flex align-items-center">
<gl-deprecated-button
class="btn-inverted js-remove-from-batch-btn btn-grouped"
:disabled="isApplying"
@click="removeSuggestionFromBatch"
>
{{ __('Remove from batch') }}
</gl-deprecated-button>
<gl-deprecated-button
v-gl-tooltip.viewport="__('This also resolves all related threads')"
class="btn-inverted js-apply-batch-btn btn-grouped"
:disabled="isApplying"
variant="success"
@click="applySuggestionBatch"
>
{{ __('Apply suggestions') }}
<span class="badge badge-pill badge-pill-success">
{{ batchSuggestionsCount }}
</span>
</gl-deprecated-button>
</div>
<div v-else-if="canApply" class="d-flex align-items-center">
<gl-deprecated-button
class="btn-inverted js-add-to-batch-btn btn-grouped"
:disabled="isApplying"
@click="addSuggestionToBatch"
>
{{ __('Add suggestion to batch') }}
</gl-deprecated-button>
<gl-deprecated-button
v-gl-tooltip.viewport="__('This also resolves the thread')"
class="btn-inverted js-apply-btn btn-grouped"
:disabled="isApplying"
variant="success"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
</gl-deprecated-button>
</div>
<gl-deprecated-button
v-else-if="canApply"
v-gl-tooltip.viewport="__('This also resolves the discussion')"
class="btn-inverted js-apply-btn"
:disabled="isApplying"
variant="success"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
</gl-deprecated-button>
</div>
</template>
......@@ -16,6 +16,11 @@ export default {
required: false,
default: () => [],
},
batchSuggestionsInfo: {
type: Array,
required: false,
default: () => [],
},
noteHtml: {
type: String,
required: true,
......@@ -68,18 +73,30 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
const { suggestions, disabled, helpPagePath } = this;
const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
propsData: { disabled, suggestion, helpPagePath },
propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
});
suggestionDiff.$on('applyBatch', () => {
this.$emit('applyBatch', { flashContainer: this.$el });
});
suggestionDiff.$on('addToBatch', suggestionId => {
this.$emit('addToBatch', suggestionId);
});
suggestionDiff.$on('removeFromBatch', suggestionId => {
this.$emit('removeFromBatch', suggestionId);
});
return suggestionDiff;
},
reset() {
......
......@@ -3,4 +3,12 @@
background-color: $badge-bg;
color: $gray-800;
vertical-align: baseline;
// Do not use this!
// This is a temporary workaround until the new GlBadge component
// is available: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/481
&.badge-pill-success {
background-color: rgba($green-500, 0.2);
color: $green;
}
}
......@@ -2,109 +2,49 @@
module Suggestions
class ApplyService < ::BaseService
DEFAULT_SUGGESTION_COMMIT_MESSAGE = 'Apply suggestion to %{file_path}'
PLACEHOLDERS = {
'project_path' => ->(suggestion, user) { suggestion.project.path },
'project_name' => ->(suggestion, user) { suggestion.project.name },
'file_path' => ->(suggestion, user) { suggestion.file_path },
'branch_name' => ->(suggestion, user) { suggestion.branch },
'username' => ->(suggestion, user) { user.username },
'user_full_name' => ->(suggestion, user) { user.name }
}.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 { |key| Regexp.new(Regexp.escape(key)) }).freeze
attr_reader :current_user
def initialize(current_user)
def initialize(current_user, *suggestions)
@current_user = current_user
@suggestion_set = Gitlab::Suggestions::SuggestionSet.new(suggestions)
end
def execute(suggestion)
unless suggestion.appliable?(cached: false)
return error('Suggestion is not appliable')
end
unless latest_source_head?(suggestion)
return error('The file has been changed')
def execute
if suggestion_set.valid?
result
else
error(suggestion_set.error_message)
end
end
diff_file = suggestion.diff_file
unless diff_file
return error('The file was not found')
end
private
params = file_update_params(suggestion, diff_file)
result = ::Files::UpdateService.new(suggestion.project, current_user, params).execute
attr_reader :current_user, :suggestion_set
if result[:status] == :success
suggestion.update(commit_id: result[:result], applied: true)
def result
multi_service.execute.tap do |result|
update_suggestions(result)
end
result
rescue Files::UpdateService::FileChangedError
error('The file has been changed')
end
private
def update_suggestions(result)
return unless result[:status] == :success
# 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
Suggestion.id_in(suggestion_set.suggestions)
.update_all(commit_id: result[:result], applied: true)
end
def file_update_params(suggestion, diff_file)
blob = diff_file.new_blob
project = suggestion.project
file_path = suggestion.file_path
branch_name = suggestion.branch
file_content = new_file_content(suggestion, blob)
commit_message = processed_suggestion_commit_message(suggestion)
file_last_commit =
Gitlab::Git::Commit.last_for_path(project.repository,
blob.commit_id,
blob.path)
{
file_path: file_path,
branch_name: branch_name,
start_branch: branch_name,
def multi_service
params = {
commit_message: commit_message,
file_content: file_content,
last_commit_sha: file_last_commit&.id
branch_name: suggestion_set.branch,
start_branch: suggestion_set.branch,
actions: suggestion_set.actions
}
end
def new_file_content(suggestion, blob)
range = suggestion.from_line_index..suggestion.to_line_index
blob.load_all_data!
content = blob.data.lines
content[range] = suggestion.to_content
content.join
end
def suggestion_commit_message(project)
project.suggestion_commit_message.presence || DEFAULT_SUGGESTION_COMMIT_MESSAGE
::Files::MultiService.new(suggestion_set.project, current_user, params)
end
def processed_suggestion_commit_message(suggestion)
message = suggestion_commit_message(suggestion.project)
Gitlab::StringPlaceholderReplacer.replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key|
PLACEHOLDERS[key].call(suggestion, current_user)
end
def commit_message
Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set).message
end
end
end
......@@ -9,9 +9,9 @@
anchor: 'configure-the-commit-message-for-applied-suggestions'),
target: '_blank'
.mb-2
= form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Suggestions::ApplyService::DEFAULT_SUGGESTION_COMMIT_MESSAGE
= form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
%p.form-text.text-muted
= s_('ProjectSettings|The variables GitLab supports:')
- Suggestions::ApplyService::PLACEHOLDERS.keys.each do |placeholder|
- Gitlab::Suggestions::CommitMessage::PLACEHOLDERS.keys.each do |placeholder|
%code
= "%{#{placeholder}}".html_safe
---
title: User can apply multiple suggestions at the same time.
merge_request: 22439
author: Jesse Hall
type: added
......@@ -459,32 +459,60 @@ instead of the usual three.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13086) in GitLab 12.7.
GitLab uses `Apply suggestion to %{file_path}` by default as commit messages
when applying Suggestions. This commit message can be customized to
follow any guidelines you might have. To do so, expand the **Merge requests**
GitLab uses a default commit message
when applying Suggestions: `Apply %{suggestions_count} suggestion(s) to %{files_count} file(s)`
For example, consider that a user applied 3 suggestions to 2 different files, the default commit message will be: **Apply 3 suggestion(s) to 2 file(s)**
These commit messages can be customized to follow any guidelines you might have. To do so, expand the **Merge requests**
tab within your project's **General** settings and change the
**Merge suggestions** text:
![Custom commit message for applied Suggestions](img/suggestions_custom_commit_messages_v12_7.png)
![Custom commit message for applied Suggestions](img/suggestions_custom_commit_messages_v13_1.jpg)
You can also use following variables besides static text:
| Variable | Description | Output example |
|---|---|---|
| `%{branch_name}` | The name of the branch the Suggestion(s) was(were) applied to. | `my-feature-branch` |
| `%{files_count}` | The number of file(s) to which Suggestion(s) was(were) applied.| **2** |
| `%{file_paths}` | The path(s) of the file(s) Suggestion(s) was(were) applied to. Paths are separated by commas.| `docs/index.md, docs/about.md` |
| `%{project_path}` | The project path. | `my-group/my-project` |
| `%{project_name}` | The human-readable name of the project. | **My Project** |
| `%{file_path}` | The path of the file the Suggestion is applied to. | `docs/index.md` |
| `%{branch_name}` | The name of the branch the Suggestion is applied on. | `my-feature-branch` |
| `%{username}` | The username of the user applying the Suggestion. | `user_1` |
| `%{user_full_name}` | The full name of the user applying the Suggestion. | **User 1** |
| `%{suggestions_count}` | The number of Suggestions applied.| **3** |
| `%{username}` | The username of the user applying Suggestion(s). | `user_1` |
| `%{user_full_name}` | The full name of the user applying Suggestion(s). | **User 1** |
For example, to customize the commit message to output
**Addresses user_1's review**, set the custom text to
`Addresses %{username}'s review`.
NOTE: **Note:**
Custom commit messages for each applied Suggestion will be
introduced by [#25381](https://gitlab.com/gitlab-org/gitlab/-/issues/25381).
Custom commit messages for each applied Suggestion (and for batch Suggestions) will be
introduced by [#25381](https://gitlab.com/gitlab-org/gitlab/issues/25381).
### Batch Suggestions
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/25486) in GitLab 13.1.
You can apply multiple suggestions at once to reduce the number of commits added
to your branch to address your reviewers' requests.
1. To start a batch of suggestions that will be applied with a single commit, click **Add suggestion to batch**:
![A code change suggestion displayed, with the button to add the suggestion to a batch highlighted.](img/add_first_suggestion_to_batch_v13_1.jpg "Add a suggestion to a batch")
1. Add as many additional suggestions to the batch as you wish:
![A code change suggestion displayed, with the button to add an additional suggestion to a batch highlighted.](img/add_another_suggestion_to_batch_v13_1.jpg "Add another suggestion to a batch")
1. To remove suggestions, click **Remove from batch**:
![A code change suggestion displayed, with the button to remove that suggestion from its batch highlighted.](img/remove_suggestion_from_batch_v13_1.jpg "Remove a suggestion from a batch")
1. Having added all the suggestions to your liking, when ready, click **Apply suggestions**:
![A code change suggestion displayed, with the button to apply the batch of suggestions highlighted.](img/apply_batch_of_suggestions_v13_1.jpg "Apply a batch of suggestions")
## Start a thread by replying to a standard comment
......
......@@ -14,18 +14,51 @@ module API
put ':id/apply' do
suggestion = Suggestion.find_by_id(params[:id])
not_found! unless suggestion
authorize! :apply_suggestion, suggestion
if suggestion
apply_suggestions(suggestion, current_user)
else
render_api_error!(_('Suggestion is not applicable as the suggestion was not found.'), :not_found)
end
end
desc 'Apply multiple suggestion patches in the Merge Request where they were created' do
success Entities::Suggestion
end
params do
requires :ids, type: Array[String], desc: "An array of suggestion ID's"
end
put 'batch_apply' do
ids = params[:ids]
suggestions = Suggestion.id_in(ids)
result = ::Suggestions::ApplyService.new(current_user).execute(suggestion)
if suggestions.size == ids.length
apply_suggestions(suggestions, current_user)
else
render_api_error!(_('Suggestions are not applicable as one or more suggestions were not found.'), :not_found)
end
end
end
helpers do
def apply_suggestions(suggestions, current_user)
authorize_suggestions(*suggestions)
result = ::Suggestions::ApplyService.new(current_user, *suggestions).execute
if result[:status] == :success
present suggestion, with: Entities::Suggestion, current_user: current_user
present suggestions, with: Entities::Suggestion, current_user: current_user
else
http_status = result[:http_status] || 400
http_status = result[:http_status] || :bad_request
render_api_error!(result[:message], http_status)
end
end
def authorize_suggestions(*suggestions)
suggestions.each do |suggestion|
authorize! :apply_suggestion, suggestion
end
end
end
end
end
# 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
# 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
# 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
......@@ -954,6 +954,12 @@ msgstr ""
msgid "A deleted user"
msgstr ""
msgid "A file has been changed."
msgstr ""
msgid "A file was not found."
msgstr ""
msgid "A file with '%{file_name}' already exists in %{branch} branch"
msgstr ""
......@@ -1020,6 +1026,9 @@ msgstr ""
msgid "A subscription will trigger a new pipeline on the default branch of this project when a pipeline successfully completes for a new tag on the %{default_branch_docs} of the subscribed project."
msgstr ""
msgid "A suggestion is not applicable."
msgstr ""
msgid "A terraform report was generated in your pipelines."
msgstr ""
......@@ -1379,6 +1388,9 @@ msgstr ""
msgid "Add strikethrough text"
msgstr ""
msgid "Add suggestion to batch"
msgstr ""
msgid "Add system hook"
msgstr ""
......@@ -2584,6 +2596,9 @@ msgstr ""
msgid "Apply suggestion"
msgstr ""
msgid "Apply suggestions"
msgstr ""
msgid "Apply template"
msgstr ""
......@@ -2602,7 +2617,10 @@ msgstr ""
msgid "Applying multiple commands"
msgstr ""
msgid "Applying suggestion"
msgid "Applying suggestion..."
msgstr ""
msgid "Applying suggestions..."
msgstr ""
msgid "Approval rules"
......@@ -18320,6 +18338,9 @@ msgstr ""
msgid "Remove fork relationship"
msgstr ""
msgid "Remove from batch"
msgstr ""
msgid "Remove from board"
msgstr ""
......@@ -20566,6 +20587,9 @@ msgstr ""
msgid "Something went wrong while adding your award. Please try again."
msgstr ""
msgid "Something went wrong while applying the batch of suggestions. Please try again."
msgstr ""
msgid "Something went wrong while applying the suggestion. Please try again."
msgstr ""
......@@ -21523,6 +21547,18 @@ msgstr ""
msgid "SuggestedColors|Very pale orange"
msgstr ""
msgid "Suggestion is not applicable as the suggestion was not found."
msgstr ""
msgid "Suggestions are not applicable as one or more suggestions were not found."
msgstr ""
msgid "Suggestions are not applicable as their lines cannot overlap."
msgstr ""
msgid "Suggestions must all be on the same branch."
msgstr ""
msgid "Suggestions:"
msgstr ""
......@@ -22492,7 +22528,10 @@ msgstr ""
msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention."
msgstr ""
msgid "This also resolves the discussion"
msgid "This also resolves all related threads"
msgstr ""
msgid "This also resolves the thread"
msgstr ""
msgid "This application was created by %{link_to_owner}."
......
......@@ -93,6 +93,100 @@ describe 'User comments on a diff', :js do
end
end
context 'applying suggestions in batches' do
def hash(path)
diff_file = merge_request.diffs(paths: [path]).diff_files.first
Digest::SHA1.hexdigest(diff_file.file_path)
end
file1 = 'files/ruby/popen.rb'
file2 = 'files/ruby/regex.rb'
let(:files) do
[
{
hash: hash(file1),
line_code: "#{hash(file1)}_12_12"
},
{
hash: hash(file2),
line_code: "#{hash(file2)}_21_21"
}
]
end
it 'can add and remove suggestions from a batch' do
files.each_with_index do |file, index|
page.within("[id='#{file[:hash]}']") do
find("button[title='Show full file']").click
wait_for_requests
click_diff_line(find("[id='#{file[:line_code]}']"))
page.within('.js-discussion-note-form') do
fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
click_button('Add comment now')
wait_for_requests
end
end
page.within("[id='#{file[:hash]}']") do
expect(page).not_to have_content('Applied')
click_button('Add suggestion to batch')
wait_for_requests
expect(page).to have_content('Remove from batch')
expect(page).to have_content("Apply suggestions #{index + 1}")
end
end
page.within("[id='#{files[0][:hash]}']") do
click_button('Remove from batch')
wait_for_requests
expect(page).to have_content('Apply suggestion')
expect(page).to have_content('Add suggestion to batch')
end
page.within("[id='#{files[1][:hash]}']") do
expect(page).to have_content('Remove from batch')
expect(page).to have_content('Apply suggestions 1')
end
end
it 'can apply multiple suggestions as a batch' do
files.each_with_index do |file, index|
page.within("[id='#{file[:hash]}']") do
find("button[title='Show full file']").click
wait_for_requests
click_diff_line(find("[id='#{file[:line_code]}']"))
page.within('.js-discussion-note-form') do
fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
click_button('Add comment now')
wait_for_requests
end
end
page.within("[id='#{file[:hash]}']") do
click_button('Add suggestion to batch')
wait_for_requests
end
end
expect(page).not_to have_content('Applied')
page.within("[id='#{files[0][:hash]}']") do
click_button('Apply suggestions 2')
wait_for_requests
end
expect(page).to have_content('Applied').twice
end
end
context 'multiple suggestions in expanded lines' do
# https://gitlab.com/gitlab-org/gitlab/issues/38277
it 'suggestions are appliable', :quarantine do
......
......@@ -1254,3 +1254,16 @@ export const discussionFiltersMock = [
value: 2,
},
];
export const batchSuggestionsInfoMock = [
{
suggestionId: 'a123',
noteId: 'b456',
discussionId: 'c789',
},
{
suggestionId: 'a001',
noteId: 'b002',
discussionId: 'c003',
},
];
......@@ -15,6 +15,7 @@ import {
userDataMock,
noteableDataMock,
individualNote,
batchSuggestionsInfoMock,
} from '../mock_data';
import axios from '~/lib/utils/axios_utils';
......@@ -890,7 +891,23 @@ describe('Actions Notes Store', () => {
testSubmitSuggestion(done, () => {
expect(commit).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(`${TEST_ERROR_MESSAGE}.`, 'alert', flashContainer);
expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer);
});
});
it('when service fails, and no error message available, uses default message', done => {
const response = { response: 'foo' };
Api.applySuggestion.mockReturnValue(Promise.reject(response));
testSubmitSuggestion(done, () => {
expect(commit).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(
'Something went wrong while applying the suggestion. Please try again.',
'alert',
flashContainer,
);
});
});
......@@ -903,6 +920,130 @@ describe('Actions Notes Store', () => {
});
});
describe('submitSuggestionBatch', () => {
const discussionIds = batchSuggestionsInfoMock.map(({ discussionId }) => discussionId);
const batchSuggestionsInfo = batchSuggestionsInfoMock;
let flashContainer;
beforeEach(() => {
jest.spyOn(Api, 'applySuggestionBatch');
dispatch.mockReturnValue(Promise.resolve());
Api.applySuggestionBatch.mockReturnValue(Promise.resolve());
state = { batchSuggestionsInfo };
flashContainer = {};
});
const testSubmitSuggestionBatch = (done, expectFn) => {
actions
.submitSuggestionBatch({ commit, dispatch, state }, { flashContainer })
.then(expectFn)
.then(done)
.catch(done.fail);
};
it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', done => {
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[0]],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[1]],
[mutationTypes.CLEAR_SUGGESTION_BATCH],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(dispatch.mock.calls).toEqual([
['resolveDiscussion', { discussionId: discussionIds[0] }],
['resolveDiscussion', { discussionId: discussionIds[1] }],
]);
expect(Flash).not.toHaveBeenCalled();
});
});
it('when service fails, flashes error message, resets applying batch state', done => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
Api.applySuggestionBatch.mockReturnValue(Promise.reject(response));
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer);
});
});
it('when service fails, and no error message available, uses default message', done => {
const response = { response: 'foo' };
Api.applySuggestionBatch.mockReturnValue(Promise.reject(response));
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(dispatch).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(
'Something went wrong while applying the batch of suggestions. Please try again.',
'alert',
flashContainer,
);
});
});
it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', done => {
dispatch.mockReturnValue(Promise.reject());
testSubmitSuggestionBatch(done, () => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[0]],
[mutationTypes.APPLY_SUGGESTION, batchSuggestionsInfo[1]],
[mutationTypes.CLEAR_SUGGESTION_BATCH],
[mutationTypes.SET_APPLYING_BATCH_STATE, false],
]);
expect(Flash).not.toHaveBeenCalled();
});
});
});
describe('addSuggestionInfoToBatch', () => {
const suggestionInfo = batchSuggestionsInfoMock[0];
it("adds a suggestion's info to the current batch", done => {
testAction(
actions.addSuggestionInfoToBatch,
suggestionInfo,
{ batchSuggestionsInfo: [] },
[{ type: 'ADD_SUGGESTION_TO_BATCH', payload: suggestionInfo }],
[],
done,
);
});
});
describe('removeSuggestionInfoFromBatch', () => {
const suggestionInfo = batchSuggestionsInfoMock[0];
it("removes a suggestion's info the current batch", done => {
testAction(
actions.removeSuggestionInfoFromBatch,
suggestionInfo.suggestionId,
{ batchSuggestionsInfo: [suggestionInfo] },
[{ type: 'REMOVE_SUGGESTION_FROM_BATCH', payload: suggestionInfo.suggestionId }],
[],
done,
);
});
});
describe('filterDiscussion', () => {
const path = 'some-discussion-path';
const filter = 0;
......
......@@ -9,6 +9,7 @@ import {
noteableDataMock,
individualNote,
notesWithDescriptionChanges,
batchSuggestionsInfoMock,
} from '../mock_data';
const RESOLVED_NOTE = { resolvable: true, resolved: true };
......@@ -700,4 +701,108 @@ describe('Notes Store mutations', () => {
expect(state.isToggleBlockedIssueWarning).toEqual(false);
});
});
describe('SET_APPLYING_BATCH_STATE', () => {
const buildDiscussions = suggestionsInfo => {
const suggestions = suggestionsInfo.map(({ suggestionId }) => ({ id: suggestionId }));
const notes = suggestionsInfo.map(({ noteId }, index) => ({
id: noteId,
suggestions: [suggestions[index]],
}));
return suggestionsInfo.map(({ discussionId }, index) => ({
id: discussionId,
notes: [notes[index]],
}));
};
let state;
let batchedSuggestionInfo;
let discussions;
let suggestions;
beforeEach(() => {
[batchedSuggestionInfo] = batchSuggestionsInfoMock;
suggestions = batchSuggestionsInfoMock.map(({ suggestionId }) => ({ id: suggestionId }));
discussions = buildDiscussions(batchSuggestionsInfoMock);
state = {
batchSuggestionsInfo: [batchedSuggestionInfo],
discussions,
};
});
it('sets is_applying_batch to a boolean value for all batched suggestions', () => {
mutations.SET_APPLYING_BATCH_STATE(state, true);
const updatedSuggestion = {
...suggestions[0],
is_applying_batch: true,
};
const expectedSuggestions = [updatedSuggestion, suggestions[1]];
const actualSuggestions = state.discussions
.map(discussion => discussion.notes.map(n => n.suggestions))
.flat(2);
expect(actualSuggestions).toEqual(expectedSuggestions);
});
});
describe('ADD_SUGGESTION_TO_BATCH', () => {
let state;
beforeEach(() => {
state = { batchSuggestionsInfo: [] };
});
it("adds a suggestion's info to a batch", () => {
const suggestionInfo = {
suggestionId: 'a123',
noteId: 'b456',
discussionId: 'c789',
};
mutations.ADD_SUGGESTION_TO_BATCH(state, suggestionInfo);
expect(state.batchSuggestionsInfo).toEqual([suggestionInfo]);
});
});
describe('REMOVE_SUGGESTION_FROM_BATCH', () => {
let state;
let suggestionInfo1;
let suggestionInfo2;
beforeEach(() => {
[suggestionInfo1, suggestionInfo2] = batchSuggestionsInfoMock;
state = {
batchSuggestionsInfo: [suggestionInfo1, suggestionInfo2],
};
});
it("removes a suggestion's info from a batch", () => {
mutations.REMOVE_SUGGESTION_FROM_BATCH(state, suggestionInfo1.suggestionId);
expect(state.batchSuggestionsInfo).toEqual([suggestionInfo2]);
});
});
describe('CLEAR_SUGGESTION_BATCH', () => {
let state;
beforeEach(() => {
state = {
batchSuggestionsInfo: batchSuggestionsInfoMock,
};
});
it('removes info for all suggestions from a batch', () => {
mutations.CLEAR_SUGGESTION_BATCH(state);
expect(state.batchSuggestionsInfo.length).toEqual(0);
});
});
});
......@@ -5,8 +5,11 @@ exports[`Suggestion Diff component matches snapshot 1`] = `
class="md-suggestion"
>
<suggestion-diff-header-stub
batchsuggestionscount="1"
class="qa-suggestion-diff-header js-suggestion-diff-header"
helppagepath="path_to_docs"
isapplyingbatch="true"
isbatched="true"
/>
<table
......
......@@ -3,8 +3,11 @@ import { shallowMount } from '@vue/test-utils';
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
const DEFAULT_PROPS = {
batchSuggestionsCount: 2,
canApply: true,
isApplied: false,
isBatched: false,
isApplyingBatch: false,
helpPagePath: 'path_to_docs',
};
......@@ -25,6 +28,9 @@ describe('Suggestion Diff component', () => {
});
const findApplyButton = () => wrapper.find('.js-apply-btn');
const findApplyBatchButton = () => wrapper.find('.js-apply-batch-btn');
const findAddToBatchButton = () => wrapper.find('.js-add-to-batch-btn');
const findRemoveFromBatchButton = () => wrapper.find('.js-remove-from-batch-btn');
const findHeader = () => wrapper.find('.js-suggestion-diff-header');
const findHelpButton = () => wrapper.find('.js-help-btn');
const findLoading = () => wrapper.find(GlLoadingIcon);
......@@ -44,19 +50,24 @@ describe('Suggestion Diff component', () => {
expect(findHelpButton().exists()).toBe(true);
});
it('renders an apply button', () => {
it('renders apply suggestion and add to batch buttons', () => {
createComponent();
const applyBtn = findApplyButton();
const addToBatchBtn = findAddToBatchButton();
expect(applyBtn.exists()).toBe(true);
expect(applyBtn.html().includes('Apply suggestion')).toBe(true);
expect(addToBatchBtn.exists()).toBe(true);
expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true);
});
it('does not render an apply button if `canApply` is set to false', () => {
it('does not render apply suggestion and add to batch buttons if `canApply` is set to false', () => {
createComponent({ canApply: false });
expect(findApplyButton().exists()).toBe(false);
expect(findAddToBatchButton().exists()).toBe(false);
});
describe('when apply suggestion is clicked', () => {
......@@ -73,13 +84,14 @@ describe('Suggestion Diff component', () => {
});
});
it('hides apply button', () => {
it('hides apply suggestion and add to batch buttons', () => {
expect(findApplyButton().exists()).toBe(false);
expect(findAddToBatchButton().exists()).toBe(false);
});
it('shows loading', () => {
expect(findLoading().exists()).toBe(true);
expect(wrapper.text()).toContain('Applying suggestion');
expect(wrapper.text()).toContain('Applying suggestion...');
});
it('when callback of apply is called, hides loading', () => {
......@@ -93,4 +105,104 @@ describe('Suggestion Diff component', () => {
});
});
});
describe('when add to batch is clicked', () => {
it('emits addToBatch', () => {
createComponent();
findAddToBatchButton().vm.$emit('click');
expect(wrapper.emittedByOrder()).toContainEqual({
name: 'addToBatch',
args: [],
});
});
});
describe('when remove from batch is clicked', () => {
it('emits removeFromBatch', () => {
createComponent({ isBatched: true });
findRemoveFromBatchButton().vm.$emit('click');
expect(wrapper.emittedByOrder()).toContainEqual({
name: 'removeFromBatch',
args: [],
});
});
});
describe('apply suggestions is clicked', () => {
it('emits applyBatch', () => {
createComponent({ isBatched: true });
findApplyBatchButton().vm.$emit('click');
expect(wrapper.emittedByOrder()).toContainEqual({
name: 'applyBatch',
args: [],
});
});
});
describe('when isBatched is true', () => {
it('shows remove from batch and apply batch buttons and displays the batch count', () => {
createComponent({
batchSuggestionsCount: 9,
isBatched: true,
});
const applyBatchBtn = findApplyBatchButton();
const removeFromBatchBtn = findRemoveFromBatchButton();
expect(removeFromBatchBtn.exists()).toBe(true);
expect(removeFromBatchBtn.html().includes('Remove from batch')).toBe(true);
expect(applyBatchBtn.exists()).toBe(true);
expect(applyBatchBtn.html().includes('Apply suggestions')).toBe(true);
expect(applyBatchBtn.html().includes(String('9'))).toBe(true);
});
it('hides add to batch and apply buttons', () => {
createComponent({
isBatched: true,
});
expect(findApplyButton().exists()).toBe(false);
expect(findAddToBatchButton().exists()).toBe(false);
});
describe('when isBatched and isApplyingBatch are true', () => {
it('shows loading', () => {
createComponent({
isBatched: true,
isApplyingBatch: true,
});
expect(findLoading().exists()).toBe(true);
expect(wrapper.text()).toContain('Applying suggestions...');
});
it('adjusts message for batch with single suggestion', () => {
createComponent({
batchSuggestionsCount: 1,
isBatched: true,
isApplyingBatch: true,
});
expect(findLoading().exists()).toBe(true);
expect(wrapper.text()).toContain('Applying suggestion...');
});
it('hides remove from batch and apply suggestions buttons', () => {
createComponent({
isBatched: true,
isApplyingBatch: true,
});
expect(findRemoveFromBatchButton().exists()).toBe(false);
expect(findApplyBatchButton().exists()).toBe(false);
});
});
});
});
......@@ -3,9 +3,10 @@ import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
import SuggestionDiffRow from '~/vue_shared/components/markdown/suggestion_diff_row.vue';
const suggestionId = 1;
const MOCK_DATA = {
suggestion: {
id: 1,
id: suggestionId,
diff_lines: [
{
can_receive_suggestion: false,
......@@ -38,8 +39,10 @@ const MOCK_DATA = {
type: 'new',
},
],
is_applying_batch: true,
},
helpPagePath: 'path_to_docs',
batchSuggestionsInfo: [{ suggestionId }],
};
describe('Suggestion Diff component', () => {
......@@ -70,17 +73,24 @@ describe('Suggestion Diff component', () => {
expect(wrapper.findAll(SuggestionDiffRow)).toHaveLength(3);
});
it('emits apply event on sugestion diff header apply', () => {
wrapper.find(SuggestionDiffHeader).vm.$emit('apply', 'test-event');
it.each`
event | childArgs | args
${'apply'} | ${['test-event']} | ${[{ callback: 'test-event', suggestionId }]}
${'applyBatch'} | ${[]} | ${[]}
${'addToBatch'} | ${[]} | ${[suggestionId]}
${'removeFromBatch'} | ${[]} | ${[suggestionId]}
`('emits $event event on sugestion diff header $event', ({ event, childArgs, args }) => {
wrapper.find(SuggestionDiffHeader).vm.$emit(event, ...childArgs);
expect(wrapper.emitted('apply')).toBeDefined();
expect(wrapper.emitted('apply')).toEqual([
[
{
callback: 'test-event',
suggestionId: 1,
},
],
]);
expect(wrapper.emitted(event)).toBeDefined();
expect(wrapper.emitted(event)).toEqual([args]);
});
it('passes suggestion batch props to suggestion diff header', () => {
expect(wrapper.find(SuggestionDiffHeader).props()).toMatchObject({
batchSuggestionsCount: 1,
isBatched: true,
isApplyingBatch: MOCK_DATA.suggestion.is_applying_batch,
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Suggestions::CommitMessage do
def create_suggestion(file_path, new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
old_line: nil,
new_line: new_line,
diff_refs: merge_request.diff_refs)
diff_note = create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
create(:suggestion,
:content_from_repo,
note: diff_note,
to_content: to_content)
end
let_it_be(:user) do
create(:user, :commit_email, name: 'Test User', username: 'test.user')
end
let_it_be(:project) do
create(:project, :repository, path: 'project-1', name: 'Project_1')
end
let_it_be(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
let_it_be(:suggestion_set) do
suggestion1 = create_suggestion('files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***')
suggestion2 = create_suggestion('files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***')
suggestion3 = create_suggestion('files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***')
Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3])
end
describe '#message' do
before do
# Updating the suggestion_commit_message on a project shared across specs
# avoids recreating the repository for each spec.
project.update!(suggestion_commit_message: message)
end
context 'when a custom commit message is not specified' do
let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' }
context 'and is nil' do
let(:message) { nil }
it 'uses the default commit message' do
expect(described_class
.new(user, suggestion_set)
.message).to eq(expected_message)
end
end
context 'and is an empty string' do
let(:message) { '' }
it 'uses the default commit message' do
expect(described_class
.new(user, suggestion_set)
.message).to eq(expected_message)
end
end
end
context 'is specified and includes all placeholders' do
let(:message) do
'*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***'
end
it 'generates a custom commit message' do
expect(Gitlab::Suggestions::CommitMessage
.new(user, suggestion_set)
.message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Suggestions::FileSuggestion do
def create_suggestion(new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
old_line: nil,
new_line: new_line,
diff_refs: merge_request.diff_refs)
diff_note = create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
create(:suggestion,
:content_from_repo,
note: diff_note,
to_content: to_content)
end
let_it_be(:user) { create(:user) }
let_it_be(:file_path) { 'files/ruby/popen.rb'}
let_it_be(:project) { create(:project, :repository) }
let_it_be(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
let_it_be(:suggestion1) do
create_suggestion(9, " *** SUGGESTION 1 ***\n")
end
let_it_be(:suggestion2) do
create_suggestion(15, " *** SUGGESTION 2 ***\n")
end
let(:file_suggestion) { described_class.new }
describe '#add_suggestion' do
it 'succeeds when adding a suggestion for the same file as the original' do
file_suggestion.add_suggestion(suggestion1)
expect { file_suggestion.add_suggestion(suggestion2) }.not_to raise_error
end
it 'raises an error when adding a suggestion for a different file' do
allow(suggestion2)
.to(receive_message_chain(:diff_file, :file_path)
.and_return('path/to/different/file'))
file_suggestion.add_suggestion(suggestion1)
expect { file_suggestion.add_suggestion(suggestion2) }.to(
raise_error(described_class::SuggestionForDifferentFileError)
)
end
end
describe '#line_conflict' do
def stub_suggestions(line_index_spans)
fake_suggestions = line_index_spans.map do |span|
double("Suggestion",
from_line_index: span[:from_line_index],
to_line_index: span[:to_line_index])
end
allow(file_suggestion).to(receive(:suggestions).and_return(fake_suggestions))
end
context 'when line ranges do not overlap' do
it 'return false' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 11,
to_line_index: 20
}
]
)
expect(file_suggestion.line_conflict?).to be(false)
end
end
context 'when line ranges are identical' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 0,
to_line_index: 10
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when one range starts, and the other ends, on the same line' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 10,
to_line_index: 20
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when one line range contains the other' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 5,
to_line_index: 7
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when line ranges overlap' do
it 'returns true' do
stub_suggestions(
[
{
from_line_index: 0,
to_line_index: 10
},
{
from_line_index: 8,
to_line_index: 15
}
]
)
expect(file_suggestion.line_conflict?).to be(true)
end
end
context 'when no suggestions have been added' do
it 'returns false' do
expect(file_suggestion.line_conflict?).to be(false)
end
end
end
describe '#new_content' do
it 'returns a blob with the suggestions applied to it' do
file_suggestion.add_suggestion(suggestion1)
file_suggestion.add_suggestion(suggestion2)
expected_content = <<-CONTENT.strip_heredoc
require 'fileutils'
require 'open3'
module Popen
extend self
def popen(cmd, path=nil)
unless cmd.is_a?(Array)
*** SUGGESTION 1 ***
end
path ||= Dir.pwd
vars = {
*** SUGGESTION 2 ***
}
options = {
chdir: path
}
unless File.directory?(path)
FileUtils.mkdir_p(path)
end
@cmd_output = ""
@cmd_status = 0
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
@cmd_output << stdout.read
@cmd_output << stderr.read
@cmd_status = wait_thr.value.exitstatus
end
return @cmd_output, @cmd_status
end
end
CONTENT
expect(file_suggestion.new_content).to eq(expected_content)
end
it 'returns an empty string when no suggestions have been added' do
expect(file_suggestion.new_content).to eq('')
end
end
describe '#file_path' do
it 'returns the path of the file associated with the suggestions' do
file_suggestion.add_suggestion(suggestion1)
expect(file_suggestion.file_path).to eq(file_path)
end
it 'returns nil if no suggestions have been added' do
expect(file_suggestion.file_path).to be(nil)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Suggestions::SuggestionSet do
def create_suggestion(file_path, new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
old_line: nil,
new_line: new_line,
diff_refs: merge_request.diff_refs)
diff_note = create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
create(:suggestion,
:content_from_repo,
note: diff_note,
to_content: to_content)
end
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
let_it_be(:suggestion) { create(:suggestion)}
let_it_be(:suggestion2) do
create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***")
end
let_it_be(:suggestion3) do
create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***")
end
let_it_be(:unappliable_suggestion) { create(:suggestion, :unappliable) }
let(:suggestion_set) { described_class.new([suggestion]) }
describe '#project' do
it 'returns the project associated with the suggestions' do
expected_project = suggestion.project
expect(suggestion_set.project).to be(expected_project)
end
end
describe '#branch' do
it 'returns the branch associated with the suggestions' do
expected_branch = suggestion.branch
expect(suggestion_set.branch).to be(expected_branch)
end
end
describe '#valid?' do
it 'returns true if no errors are found' do
expect(suggestion_set.valid?).to be(true)
end
it 'returns false if an error is found' do
suggestion_set = described_class.new([unappliable_suggestion])
expect(suggestion_set.valid?).to be(false)
end
end
describe '#error_message' do
it 'returns an error message if an error is found' do
suggestion_set = described_class.new([unappliable_suggestion])
expect(suggestion_set.error_message).to be_a(String)
end
it 'returns nil if no errors are found' do
expect(suggestion_set.error_message).to be(nil)
end
end
describe '#actions' do
it 'returns an array of hashes with proper key/value pairs' do
first_action = suggestion_set.actions.first
file_path, file_suggestion = suggestion_set
.send(:suggestions_per_file).first
expect(first_action[:action]).to be('update')
expect(first_action[:file_path]).to eq(file_path)
expect(first_action[:content]).to eq(file_suggestion.new_content)
end
end
describe '#file_paths' do
it 'returns an array of unique file paths associated with the suggestions' do
suggestion_set = described_class.new([suggestion, suggestion2, suggestion3])
expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb)
actual_paths = suggestion_set.file_paths
expect(actual_paths.sort).to eq(expected_paths)
end
end
end
......@@ -7,8 +7,7 @@ describe API::Suggestions do
let(:user) { create(:user) }
let(:merge_request) do
create(:merge_request, source_project: project,
target_project: project)
create(:merge_request, source_project: project, target_project: project)
end
let(:position) do
......@@ -19,26 +18,45 @@ describe API::Suggestions do
diff_refs: merge_request.diff_refs)
end
let(:position2) do
Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
new_path: "files/ruby/popen.rb",
old_line: nil,
new_line: 15,
diff_refs: merge_request.diff_refs)
end
let(:diff_note) do
create(:diff_note_on_merge_request,
noteable: merge_request,
position: position,
project: project)
end
let(:diff_note2) do
create(:diff_note_on_merge_request, noteable: merge_request,
position: position,
project: project)
position: position2,
project: project)
end
let(:suggestion) do
create(:suggestion, note: diff_note,
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
end
let(:unappliable_suggestion) do
create(:suggestion, :unappliable, note: diff_note2)
end
describe "PUT /suggestions/:id/apply" do
let(:url) { "/suggestions/#{suggestion.id}/apply" }
context 'when successfully applies patch' do
let(:suggestion) do
create(:suggestion, note: diff_note,
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
end
it 'returns 200 with json content' do
it 'renders an ok response and returns json content' do
project.add_maintainer(user)
put api(url, user), params: { id: suggestion.id }
put api(url, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response)
......@@ -48,31 +66,105 @@ describe API::Suggestions do
end
context 'when not able to apply patch' do
let(:suggestion) do
create(:suggestion, :unappliable, note: diff_note)
end
let(:url) { "/suggestions/#{unappliable_suggestion.id}/apply" }
it 'returns 400 with json content' do
it 'renders a bad request error and returns json content' do
project.add_maintainer(user)
put api(url, user), params: { id: suggestion.id }
put api(url, user)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to eq({ 'message' => 'Suggestion is not appliable' })
expect(json_response).to eq({ 'message' => 'A suggestion is not applicable.' })
end
end
context 'when suggestion is not found' do
let(:url) { "/suggestions/foo-123/apply" }
it 'renders a not found error and returns json content' do
project.add_maintainer(user)
put api(url, user)
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response).to eq({ 'message' => 'Suggestion is not applicable as the suggestion was not found.' })
end
end
context 'when unauthorized' do
let(:suggestion) do
create(:suggestion, note: diff_note,
from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
it 'renders a forbidden error and returns json content' do
project.add_reporter(user)
put api(url, user)
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response).to eq({ 'message' => '403 Forbidden' })
end
end
end
describe "PUT /suggestions/batch_apply" do
let(:suggestion2) do
create(:suggestion, note: diff_note2,
from_content: " \"PWD\" => path\n",
to_content: " *** FOO ***\n")
end
it 'returns 403 with json content' do
let(:url) { "/suggestions/batch_apply" }
context 'when successfully applies multiple patches as a batch' do
it 'renders an ok response and returns json content' do
project.add_maintainer(user)
put api(url, user), params: { ids: [suggestion.id, suggestion2.id] }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to all(include('id', 'from_line', 'to_line',
'appliable', 'applied',
'from_content', 'to_content'))
end
end
context 'when not able to apply one or more of the patches' do
it 'renders a bad request error and returns json content' do
project.add_maintainer(user)
put api(url, user),
params: { ids: [suggestion.id, unappliable_suggestion.id] }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to eq({ 'message' => 'A suggestion is not applicable.' })
end
end
context 'with missing suggestions' do
it 'renders a not found error and returns json content if any suggestion is not found' do
project.add_maintainer(user)
put api(url, user), params: { ids: [suggestion.id, 'foo-123'] }
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response)
.to eq({ 'message' => 'Suggestions are not applicable as one or more suggestions were not found.' })
end
it 'renders a bad request error and returns json content when no suggestions are provided' do
project.add_maintainer(user)
put api(url, user), params: {}
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response)
.to eq({ 'error' => "ids is missing" })
end
end
context 'when unauthorized' do
it 'renders a forbidden error and returns json content' do
project.add_reporter(user)
put api(url, user), params: { id: suggestion.id }
put api(url, user),
params: { ids: [suggestion.id, suggestion2.id] }
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response).to eq({ 'message' => '403 Forbidden' })
......
......@@ -32,26 +32,28 @@ describe 'projects/edit' do
it 'displays all possible variables' do
render
expect(rendered).to have_content('%{project_path}')
expect(rendered).to have_content('%{project_name}')
expect(rendered).to have_content('%{file_path}')
expect(rendered).to have_content('%{branch_name}')
expect(rendered).to have_content('%{username}')
expect(rendered).to have_content('%{files_count}')
expect(rendered).to have_content('%{file_paths}')
expect(rendered).to have_content('%{project_name}')
expect(rendered).to have_content('%{project_path}')
expect(rendered).to have_content('%{user_full_name}')
expect(rendered).to have_content('%{username}')
expect(rendered).to have_content('%{suggestions_count}')
end
it 'displays a placeholder if none is set' do
render
expect(rendered).to have_field('project[suggestion_commit_message]', placeholder: 'Apply suggestion to %{file_path}')
expect(rendered).to have_field('project[suggestion_commit_message]', placeholder: "Apply %{suggestions_count} suggestion(s) to %{files_count} file(s)")
end
it 'displays the user entered value' do
project.update!(suggestion_commit_message: 'refactor: changed %{file_path}')
project.update!(suggestion_commit_message: 'refactor: changed %{file_paths}')
render
expect(rendered).to have_field('project[suggestion_commit_message]', with: 'refactor: changed %{file_path}')
expect(rendered).to have_field('project[suggestion_commit_message]', with: 'refactor: changed %{file_paths}')
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