Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
d48cbe1786
commit
7258040618
|
|
@ -41,7 +41,6 @@
|
|||
/ee/app/finders/ @gitlab-org/maintainers/database
|
||||
|
||||
# Feature specific owners
|
||||
/ee/lib/gitlab/code_owners/ @reprazent @kerrizor
|
||||
/ee/lib/ee/gitlab/auth/ldap/ @dblessing @mkozono
|
||||
/lib/gitlab/auth/ldap/ @dblessing @mkozono
|
||||
/lib/gitlab/ci/templates/ @nolith @zj
|
||||
|
|
@ -50,6 +49,11 @@
|
|||
/ee/app/models/project_alias.rb @patrickbajao
|
||||
/ee/lib/api/project_aliases.rb @patrickbajao
|
||||
|
||||
# Code Owners
|
||||
#
|
||||
/ee/lib/gitlab/code_owners/ @reprazent @kerrizor @garyh
|
||||
/doc/user/project/code_owners.md @reprazent @kerrizor @garyh
|
||||
|
||||
# Quality owned files
|
||||
/qa/ @gl-quality
|
||||
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -72,10 +72,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(['fetchClusters', 'setPage']),
|
||||
statusClass(status) {
|
||||
const iconClass = STATUSES[status] || STATUSES.default;
|
||||
return iconClass.className;
|
||||
},
|
||||
|
||||
statusTitle(status) {
|
||||
const iconTitle = STATUSES[status] || STATUSES.default;
|
||||
return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false);
|
||||
|
|
@ -96,19 +93,12 @@ export default {
|
|||
</gl-link>
|
||||
|
||||
<gl-loading-icon
|
||||
v-if="item.status === 'deleting'"
|
||||
v-if="item.status === 'deleting' || item.status === 'creating'"
|
||||
v-tooltip
|
||||
:title="statusTitle(item.status)"
|
||||
size="sm"
|
||||
class="mr-2 ml-md-2"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip
|
||||
class="cluster-status-indicator rounded-circle align-self-center gl-w-4 gl-h-4 mr-2 ml-md-2"
|
||||
:class="statusClass(item.status)"
|
||||
:title="statusTitle(item.status)"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ export const STATUSES = {
|
|||
unreachable: { className: 'bg-danger', title: __('Unreachable') },
|
||||
authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') },
|
||||
deleting: { title: __('Deleting') },
|
||||
creating: { title: __('Creating') },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
# blocked: boolean
|
||||
# external: boolean
|
||||
# without_projects: boolean
|
||||
# sort: string
|
||||
# id: integer
|
||||
#
|
||||
class UsersFinder
|
||||
include CreatedAtFilter
|
||||
|
|
@ -30,6 +32,7 @@ class UsersFinder
|
|||
def execute
|
||||
users = User.all.order_id_desc
|
||||
users = by_username(users)
|
||||
users = by_id(users)
|
||||
users = by_search(users)
|
||||
users = by_blocked(users)
|
||||
users = by_active(users)
|
||||
|
|
@ -40,7 +43,7 @@ class UsersFinder
|
|||
users = by_without_projects(users)
|
||||
users = by_custom_attributes(users)
|
||||
|
||||
users
|
||||
order(users)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -51,6 +54,12 @@ class UsersFinder
|
|||
users.by_username(params[:username])
|
||||
end
|
||||
|
||||
def by_id(users)
|
||||
return users unless params[:id]
|
||||
|
||||
users.id_in(params[:id])
|
||||
end
|
||||
|
||||
def by_search(users)
|
||||
return users unless params[:search].present?
|
||||
|
||||
|
|
@ -102,6 +111,14 @@ class UsersFinder
|
|||
|
||||
users.without_projects
|
||||
end
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def order(users)
|
||||
return users unless params[:sort]
|
||||
|
||||
users.order_by(params[:sort])
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
end
|
||||
|
||||
UsersFinder.prepend_if_ee('EE::UsersFinder')
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ module Mutations
|
|||
|
||||
return unless project
|
||||
|
||||
resolver = Resolvers::AlertManagementAlertResolver.single.new(object: project, context: context, field: nil)
|
||||
resolver = Resolvers::AlertManagement::AlertResolver.single.new(object: project, context: context, field: nil)
|
||||
resolver.resolve(iid: iid)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ module Mutations
|
|||
def prepare_response(result)
|
||||
{
|
||||
alert: result.payload[:alert],
|
||||
errors: result.error? ? [result.message] : []
|
||||
errors: result.errors
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ module Mutations
|
|||
|
||||
{
|
||||
container_expiration_policy: result.payload[:container_expiration_policy],
|
||||
errors: result.error? ? [result.message] : []
|
||||
errors: result.errors
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -23,29 +23,21 @@ module Mutations
|
|||
description: 'Project name of the importer Jira project'
|
||||
|
||||
def resolve(project_path:, jira_project_key:)
|
||||
project = find_project!(project_path: project_path)
|
||||
|
||||
raise_resource_not_available_error! unless project
|
||||
project = authorized_find!(full_path: project_path)
|
||||
|
||||
service_response = ::JiraImport::StartImportService
|
||||
.new(context[:current_user], project, jira_project_key)
|
||||
.execute
|
||||
jira_import = service_response.success? ? service_response.payload[:import_data] : nil
|
||||
errors = service_response.error? ? [service_response.message] : []
|
||||
|
||||
{
|
||||
jira_import: jira_import,
|
||||
errors: errors
|
||||
errors: service_response.errors
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_project!(project_path:)
|
||||
return unless project_path.present?
|
||||
|
||||
authorized_find!(full_path: project_path)
|
||||
end
|
||||
|
||||
def find_object(full_path:)
|
||||
resolve_project(full_path: full_path)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
module AlertManagement
|
||||
class AlertResolver < BaseResolver
|
||||
argument :iid, GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: 'IID of the alert. For example, "1"'
|
||||
|
||||
argument :statuses, [Types::AlertManagement::StatusEnum],
|
||||
as: :status,
|
||||
required: false,
|
||||
description: 'Alerts with the specified statues. For example, [TRIGGERED]'
|
||||
|
||||
argument :sort, Types::AlertManagement::AlertSortEnum,
|
||||
description: 'Sort alerts by this criteria',
|
||||
required: false
|
||||
|
||||
argument :search, GraphQL::STRING_TYPE,
|
||||
description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
|
||||
required: false
|
||||
|
||||
type Types::AlertManagement::AlertType, null: true
|
||||
|
||||
def resolve(**args)
|
||||
parent = object.respond_to?(:sync) ? object.sync : object
|
||||
return ::AlertManagement::Alert.none if parent.nil?
|
||||
|
||||
::AlertManagement::AlertsFinder.new(context[:current_user], parent, args).execute
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
class AlertManagementAlertResolver < BaseResolver
|
||||
argument :iid, GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: 'IID of the alert. For example, "1"'
|
||||
|
||||
argument :statuses, [Types::AlertManagement::StatusEnum],
|
||||
as: :status,
|
||||
required: false,
|
||||
description: 'Alerts with the specified statues. For example, [TRIGGERED]'
|
||||
|
||||
argument :sort, Types::AlertManagement::AlertSortEnum,
|
||||
description: 'Sort alerts by this criteria',
|
||||
required: false
|
||||
|
||||
argument :search, GraphQL::STRING_TYPE,
|
||||
description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.',
|
||||
required: false
|
||||
|
||||
type Types::AlertManagement::AlertType, null: true
|
||||
|
||||
def resolve(**args)
|
||||
parent = object.respond_to?(:sync) ? object.sync : object
|
||||
return ::AlertManagement::Alert.none if parent.nil?
|
||||
|
||||
::AlertManagement::AlertsFinder.new(context[:current_user], parent, args).execute
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -16,7 +16,11 @@ module Resolvers
|
|||
response, start_cursor, end_cursor = jira_projects(name: name, **compute_pagination_params(args))
|
||||
end_cursor = nil if !!response.payload[:is_last]
|
||||
|
||||
response.success? ? Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects]) : nil
|
||||
if response.success?
|
||||
Gitlab::Graphql::ExternallyPaginatedArray.new(start_cursor, end_cursor, *response.payload[:projects])
|
||||
else
|
||||
raise Gitlab::Graphql::Errors::BaseError, response.message
|
||||
end
|
||||
end
|
||||
|
||||
def authorized_resource?(project)
|
||||
|
|
@ -58,6 +62,9 @@ module Resolvers
|
|||
args = { query: name, start_at: start_at, limit: limit }.compact
|
||||
|
||||
response = Jira::Requests::Projects.new(project.jira_service, args).execute
|
||||
|
||||
return [response, nil, nil] if response.error?
|
||||
|
||||
projects = response.payload[:projects]
|
||||
start_cursor = start_at == 0 ? nil : Base64.encode64((start_at - 1).to_s)
|
||||
end_cursor = Base64.encode64((start_at + projects.size - 1).to_s)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
class UsersResolver < BaseResolver
|
||||
include Gitlab::Graphql::Authorize::AuthorizeResource
|
||||
|
||||
description 'Find Users'
|
||||
|
||||
argument :ids, [GraphQL::ID_TYPE],
|
||||
required: false,
|
||||
description: 'List of user Global IDs'
|
||||
|
||||
argument :usernames, [GraphQL::STRING_TYPE], required: false,
|
||||
description: 'List of usernames'
|
||||
|
||||
argument :sort, Types::SortEnum,
|
||||
description: 'Sort users by this criteria',
|
||||
required: false,
|
||||
default_value: 'created_desc'
|
||||
|
||||
def resolve(ids: nil, usernames: nil, sort: nil)
|
||||
authorize!
|
||||
|
||||
::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort)).execute
|
||||
end
|
||||
|
||||
def ready?(**args)
|
||||
args = { ids: nil, usernames: nil }.merge!(args)
|
||||
|
||||
return super if args.values.compact.blank?
|
||||
|
||||
if args.values.all?
|
||||
raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a list of usernames or ids'
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def authorize!
|
||||
Ability.allowed?(context[:current_user], :read_users_list) || raise_resource_not_available_error!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finder_params(ids, usernames, sort)
|
||||
params = {}
|
||||
params[:sort] = sort if sort
|
||||
params[:username] = usernames if usernames
|
||||
params[:id] = parse_gids(ids) if ids
|
||||
params
|
||||
end
|
||||
|
||||
def parse_gids(gids)
|
||||
gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::User).model_id }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -216,13 +216,13 @@ module Types
|
|||
Types::AlertManagement::AlertType.connection_type,
|
||||
null: true,
|
||||
description: 'Alert Management alerts of the project',
|
||||
resolver: Resolvers::AlertManagementAlertResolver
|
||||
resolver: Resolvers::AlertManagement::AlertResolver
|
||||
|
||||
field :alert_management_alert,
|
||||
Types::AlertManagement::AlertType,
|
||||
null: true,
|
||||
description: 'A single Alert Management alert of the project',
|
||||
resolver: Resolvers::AlertManagementAlertResolver.single
|
||||
resolver: Resolvers::AlertManagement::AlertResolver.single
|
||||
|
||||
field :alert_management_alert_status_counts,
|
||||
Types::AlertManagement::AlertStatusCountsType,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ module Types
|
|||
description: 'Find a user',
|
||||
resolver: Resolvers::UserResolver
|
||||
|
||||
field :users, Types::UserType.connection_type,
|
||||
null: true,
|
||||
description: 'Find users',
|
||||
resolver: Resolvers::UsersResolver
|
||||
|
||||
field :echo, GraphQL::STRING_TYPE, null: false,
|
||||
description: 'Text to echo back',
|
||||
resolver: Resolvers::EchoResolver
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ module Jira
|
|||
response = client.get(url)
|
||||
build_service_response(response)
|
||||
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
|
||||
error_message = error.message
|
||||
error_message = "Jira request error: #{error.message}"
|
||||
log_error("Error sending message", client_url: client.options[:site], error: error_message)
|
||||
ServiceResponse.error(message: error_message)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
def execute
|
||||
if suggestion_set.valid?
|
||||
result
|
||||
else
|
||||
error(suggestion_set.error_message)
|
||||
end
|
||||
|
||||
unless latest_source_head?(suggestion)
|
||||
return error('The file has been changed')
|
||||
end
|
||||
|
||||
diff_file = suggestion.diff_file
|
||||
|
||||
unless diff_file
|
||||
return error('The file was not found')
|
||||
end
|
||||
|
||||
params = file_update_params(suggestion, diff_file)
|
||||
result = ::Files::UpdateService.new(suggestion.project, current_user, params).execute
|
||||
|
||||
if result[:status] == :success
|
||||
suggestion.update(commit_id: result[:result], applied: true)
|
||||
end
|
||||
|
||||
result
|
||||
rescue Files::UpdateService::FileChangedError
|
||||
error('The file has been changed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# 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
|
||||
attr_reader :current_user, :suggestion_set
|
||||
|
||||
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,
|
||||
commit_message: commit_message,
|
||||
file_content: file_content,
|
||||
last_commit_sha: file_last_commit&.id
|
||||
}
|
||||
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
|
||||
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)
|
||||
def result
|
||||
multi_service.execute.tap do |result|
|
||||
update_suggestions(result)
|
||||
end
|
||||
end
|
||||
|
||||
def update_suggestions(result)
|
||||
return unless result[:status] == :success
|
||||
|
||||
Suggestion.id_in(suggestion_set.suggestions)
|
||||
.update_all(commit_id: result[:result], applied: true)
|
||||
end
|
||||
|
||||
def multi_service
|
||||
params = {
|
||||
commit_message: commit_message,
|
||||
branch_name: suggestion_set.branch,
|
||||
start_branch: suggestion_set.branch,
|
||||
actions: suggestion_set.actions
|
||||
}
|
||||
|
||||
::Files::MultiService.new(suggestion_set.project, current_user, params)
|
||||
end
|
||||
|
||||
def commit_message
|
||||
Gitlab::Suggestions::CommitMessage.new(current_user, suggestion_set).message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@
|
|||
|
||||
= render_if_exists 'namespaces/shared_runner_status', namespace: @group
|
||||
|
||||
= render 'shared/custom_attributes', custom_attributes: @group.custom_attributes
|
||||
|
||||
= render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group
|
||||
|
||||
.card
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@
|
|||
= visibility_level_icon(@project.visibility_level)
|
||||
= visibility_level_label(@project.visibility_level)
|
||||
|
||||
= render 'shared/custom_attributes', custom_attributes: @project.custom_attributes
|
||||
|
||||
= render_if_exists 'admin/projects/geo_status_widget', locals: { project: @project }
|
||||
|
||||
.card
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@
|
|||
|
||||
= render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace
|
||||
|
||||
= render 'shared/custom_attributes', custom_attributes: @user.custom_attributes
|
||||
|
||||
.col-md-6
|
||||
- unless @user == current_user
|
||||
- unless @user.confirmed?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
- return unless custom_attributes.present?
|
||||
|
||||
.card
|
||||
.card-header
|
||||
= link_to(_('Custom Attributes'), help_page_path('api/custom_attributes.md'))
|
||||
%ul.content-list
|
||||
- custom_attributes.each do |custom_attribute|
|
||||
%li
|
||||
%span.light
|
||||
= custom_attribute.key
|
||||
%strong
|
||||
= custom_attribute.value
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add root users query to GraphQL API
|
||||
merge_request: 33195
|
||||
author:
|
||||
type: added
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Resolve Fix Incomplete Kubernetes Cluster Status List
|
||||
merge_request: 33344
|
||||
author:
|
||||
type: fixed
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: User can apply multiple suggestions at the same time.
|
||||
merge_request: 22439
|
||||
author: Jesse Hall
|
||||
type: added
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Show custom attributes within Admin Pages
|
||||
merge_request: 34017
|
||||
author: Roger Meier
|
||||
type: added
|
||||
|
|
@ -60,6 +60,7 @@ The GraphQL API includes the following queries at the root level:
|
|||
1. `user` : Information about a particular user.
|
||||
1. `namespace` : Within a namespace it is also possible to fetch `projects`.
|
||||
1. `currentUser`: Information about the currently logged in user.
|
||||
1. `users`: Information about a collection of users.
|
||||
1. `metaData`: Metadata about GitLab and the GraphQL API.
|
||||
1. `snippets`: Snippets visible to the currently logged in user.
|
||||
|
||||
|
|
|
|||
|
|
@ -9635,6 +9635,46 @@ type Query {
|
|||
username: String
|
||||
): User
|
||||
|
||||
"""
|
||||
Find users
|
||||
"""
|
||||
users(
|
||||
"""
|
||||
Returns the elements in the list that come after the specified cursor.
|
||||
"""
|
||||
after: String
|
||||
|
||||
"""
|
||||
Returns the elements in the list that come before the specified cursor.
|
||||
"""
|
||||
before: String
|
||||
|
||||
"""
|
||||
Returns the first _n_ elements from the list.
|
||||
"""
|
||||
first: Int
|
||||
|
||||
"""
|
||||
List of user Global IDs
|
||||
"""
|
||||
ids: [ID!]
|
||||
|
||||
"""
|
||||
Returns the last _n_ elements from the list.
|
||||
"""
|
||||
last: Int
|
||||
|
||||
"""
|
||||
Sort users by this criteria
|
||||
"""
|
||||
sort: Sort = created_desc
|
||||
|
||||
"""
|
||||
List of usernames
|
||||
"""
|
||||
usernames: [String!]
|
||||
): UserConnection
|
||||
|
||||
"""
|
||||
Vulnerabilities reported on projects on the current user's instance security dashboard
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -28307,6 +28307,105 @@
|
|||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Find users",
|
||||
"args": [
|
||||
{
|
||||
"name": "ids",
|
||||
"description": "List of user Global IDs",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "usernames",
|
||||
"description": "List of usernames",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": "Sort users by this criteria",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "Sort",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": "created_desc"
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"description": "Returns the elements in the list that come after the specified cursor.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"description": "Returns the elements in the list that come before the specified cursor.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "first",
|
||||
"description": "Returns the first _n_ elements from the list.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "last",
|
||||
"description": "Returns the last _n_ elements from the list.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "UserConnection",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "vulnerabilities",
|
||||
"description": "Vulnerabilities reported on projects on the current user's instance security dashboard",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
|
|
@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
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**:
|
||||
|
||||

|
||||
|
||||
1. Add as many additional suggestions to the batch as you wish:
|
||||
|
||||

|
||||
|
||||
1. To remove suggestions, click **Remove from batch**:
|
||||
|
||||

|
||||
|
||||
1. Having added all the suggestions to your liking, when ready, click **Apply 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
|
||||
|
||||
result = ::Suggestions::ApplyService.new(current_user).execute(suggestion)
|
||||
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)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,16 +9,12 @@ module Gitlab
|
|||
def instrument(_type, field)
|
||||
service = AuthorizeFieldService.new(field)
|
||||
|
||||
if service.authorizations? && !resolver_skips_authorizations?(field)
|
||||
if service.authorizations?
|
||||
field.redefine { resolve(service.authorized_resolve) }
|
||||
else
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
def resolver_skips_authorizations?(field)
|
||||
field.metadata[:resolver].try(:skip_authorizations?)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Graphql
|
||||
class FilterableArray < Array
|
||||
attr_reader :filter_callback
|
||||
|
||||
def initialize(filter_callback, *args)
|
||||
super(args)
|
||||
@filter_callback = filter_callback
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,10 +9,6 @@ module Gitlab
|
|||
ActiveRecord::Relation,
|
||||
Gitlab::Graphql::Pagination::Keyset::Connection)
|
||||
|
||||
schema.connections.add(
|
||||
Gitlab::Graphql::FilterableArray,
|
||||
Gitlab::Graphql::Pagination::FilterableArrayConnection)
|
||||
|
||||
schema.connections.add(
|
||||
Gitlab::Graphql::ExternallyPaginatedArray,
|
||||
Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection)
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Graphql
|
||||
module Pagination
|
||||
# FilterableArrayConnection is useful especially for lazy-loaded values.
|
||||
# It allows us to call a callback only on the slice of array being
|
||||
# rendered in the "after loaded" phase. For example we can check
|
||||
# permissions only on a small subset of items.
|
||||
class FilterableArrayConnection < GraphQL::Pagination::ArrayConnection
|
||||
def nodes
|
||||
@nodes ||= items.filter_callback.call(super)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -6618,6 +6636,9 @@ msgstr ""
|
|||
msgid "Creates branch '%{branch_name}' and a merge request to resolve this issue."
|
||||
msgstr ""
|
||||
|
||||
msgid "Creating"
|
||||
msgstr ""
|
||||
|
||||
msgid "Creating epic"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -6684,6 +6705,9 @@ msgstr ""
|
|||
msgid "CurrentUser|Upgrade"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Attributes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom CI configuration path"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -18317,6 +18341,9 @@ msgstr ""
|
|||
msgid "Remove fork relationship"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from batch"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from board"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -20563,6 +20590,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 ""
|
||||
|
||||
|
|
@ -21520,6 +21550,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 ""
|
||||
|
||||
|
|
@ -22489,7 +22531,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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ describe UsersFinder do
|
|||
expect(users).to contain_exactly(normal_user)
|
||||
end
|
||||
|
||||
it 'filters by id' do
|
||||
users = described_class.new(user, id: normal_user.id).execute
|
||||
|
||||
expect(users).to contain_exactly(normal_user)
|
||||
end
|
||||
|
||||
it 'filters by username (case insensitive)' do
|
||||
users = described_class.new(user, username: 'joHNdoE').execute
|
||||
|
||||
|
|
@ -70,6 +76,12 @@ describe UsersFinder do
|
|||
|
||||
expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user)
|
||||
end
|
||||
|
||||
it 'orders returned results' do
|
||||
users = described_class.new(user, sort: 'id_asc').execute
|
||||
|
||||
expect(users).to eq([normal_user, blocked_user, omniauth_user, user])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an admin user' do
|
||||
|
|
|
|||
|
|
@ -83,24 +83,21 @@ describe('Clusters', () => {
|
|||
|
||||
describe('cluster status', () => {
|
||||
it.each`
|
||||
statusName | className | lineNumber
|
||||
${'disabled'} | ${'disabled'} | ${0}
|
||||
${'unreachable'} | ${'bg-danger'} | ${1}
|
||||
${'authentication_failure'} | ${'bg-warning'} | ${2}
|
||||
${'deleting'} | ${null} | ${3}
|
||||
${'created'} | ${'bg-success'} | ${4}
|
||||
${'default'} | ${'bg-white'} | ${5}
|
||||
`('renders a status for each cluster', ({ statusName, className, lineNumber }) => {
|
||||
const statuses = findStatuses();
|
||||
const status = statuses.at(lineNumber);
|
||||
if (statusName !== 'deleting') {
|
||||
const statusIndicator = status.find('.cluster-status-indicator');
|
||||
expect(statusIndicator.exists()).toBe(true);
|
||||
expect(statusIndicator.classes()).toContain(className);
|
||||
} else {
|
||||
expect(status.find(GlLoadingIcon).exists()).toBe(true);
|
||||
}
|
||||
});
|
||||
statusName | lineNumber | result
|
||||
${'creating'} | ${0} | ${true}
|
||||
${null} | ${1} | ${false}
|
||||
${null} | ${2} | ${false}
|
||||
${'deleting'} | ${3} | ${true}
|
||||
${null} | ${4} | ${false}
|
||||
${null} | ${5} | ${false}
|
||||
`(
|
||||
'renders $result when status=$statusName and lineNumber=$lineNumber',
|
||||
({ lineNumber, result }) => {
|
||||
const statuses = findStatuses();
|
||||
const status = statuses.at(lineNumber);
|
||||
expect(status.find(GlLoadingIcon).exists()).toBe(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('nodes present', () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export const clusterList = [
|
|||
name: 'My Cluster 1',
|
||||
environment_scope: '*',
|
||||
cluster_type: 'group_type',
|
||||
status: 'disabled',
|
||||
status: 'creating',
|
||||
nodes: null,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,12 +46,6 @@ describe GitlabSchema do
|
|||
expect(connection).to eq(Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection)
|
||||
end
|
||||
|
||||
it 'paginates FilterableArray using `Pagination::FilterableArrayConnection`' do
|
||||
connection = connections[Gitlab::Graphql::FilterableArray]
|
||||
|
||||
expect(connection).to eq(Gitlab::Graphql::Pagination::FilterableArrayConnection)
|
||||
end
|
||||
|
||||
describe '.execute' do
|
||||
context 'for different types of users' do
|
||||
context 'when no context' do
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe Mutations::AlertManagement::UpdateAlertStatus do
|
|||
context 'error occurs when updating' do
|
||||
it 'returns the alert with errors' do
|
||||
# Stub an error on the alert
|
||||
allow_next_instance_of(Resolvers::AlertManagementAlertResolver) do |resolver|
|
||||
allow_next_instance_of(Resolvers::AlertManagement::AlertResolver) do |resolver|
|
||||
allow(resolver).to receive(:resolve).and_return(alert)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Resolvers::AlertManagementAlertResolver do
|
||||
describe Resolvers::AlertManagement::AlertResolver do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:current_user) { create(:user) }
|
||||
|
|
@ -41,22 +41,35 @@ describe Resolvers::Projects::JiraProjectsResolver do
|
|||
end
|
||||
|
||||
context 'when user is a maintainer' do
|
||||
include_context 'jira projects request context'
|
||||
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it 'returns jira projects' do
|
||||
jira_projects = resolve_jira_projects
|
||||
project_keys = jira_projects.map(&:key)
|
||||
project_names = jira_projects.map(&:name)
|
||||
project_ids = jira_projects.map(&:id)
|
||||
context 'when Jira connection is valid' do
|
||||
include_context 'jira projects request context'
|
||||
|
||||
expect(jira_projects.size).to eq 2
|
||||
expect(project_keys).to eq(%w(EX ABC))
|
||||
expect(project_names).to eq(%w(Example Alphabetical))
|
||||
expect(project_ids).to eq(%w(10000 10001))
|
||||
it 'returns jira projects' do
|
||||
jira_projects = resolve_jira_projects
|
||||
project_keys = jira_projects.map(&:key)
|
||||
project_names = jira_projects.map(&:name)
|
||||
project_ids = jira_projects.map(&:id)
|
||||
|
||||
expect(jira_projects.size).to eq 2
|
||||
expect(project_keys).to eq(%w(EX ABC))
|
||||
expect(project_names).to eq(%w(Example Alphabetical))
|
||||
expect(project_ids).to eq(%w(10000 10001))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Jira connection is not valid' do
|
||||
before do
|
||||
WebMock.stub_request(:get, 'https://jira.example.com/rest/api/2/project/search?maxResults=50&query=&startAt=0')
|
||||
.to_raise(JIRA::HTTPError.new(double(message: 'Some failure.')))
|
||||
end
|
||||
|
||||
it 'raises failure error' do
|
||||
expect { resolve_jira_projects }.to raise_error('Jira request error: Some failure.')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Resolvers::UsersResolver do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:user1) { create(:user) }
|
||||
let_it_be(:user2) { create(:user) }
|
||||
|
||||
describe '#resolve' do
|
||||
it 'raises an error when read_users_list is not authorized' do
|
||||
expect(Ability).to receive(:allowed?).with(nil, :read_users_list).and_return(false)
|
||||
|
||||
expect { resolve_users }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
|
||||
end
|
||||
|
||||
context 'when no arguments are passed' do
|
||||
it 'returns all users' do
|
||||
expect(resolve_users).to contain_exactly(user1, user2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both ids and usernames are passed ' do
|
||||
it 'raises an error' do
|
||||
expect { resolve_users(ids: [user1.to_global_id.to_s], usernames: [user1.username]) }
|
||||
.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a set of IDs is passed' do
|
||||
it 'returns those users' do
|
||||
expect(
|
||||
resolve_users(ids: [user1.to_global_id.to_s, user2.to_global_id.to_s])
|
||||
).to contain_exactly(user1, user2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a set of usernames is passed' do
|
||||
it 'returns those users' do
|
||||
expect(
|
||||
resolve_users(usernames: [user1.username, user2.username])
|
||||
).to contain_exactly(user1, user2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_users(args = {})
|
||||
resolve(described_class, args: args)
|
||||
end
|
||||
end
|
||||
|
|
@ -18,6 +18,7 @@ describe GitlabSchema.types['Query'] do
|
|||
snippets
|
||||
design_management
|
||||
user
|
||||
users
|
||||
]
|
||||
|
||||
expect(described_class).to have_graphql_fields(*expected_fields).at_least
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::Graphql::Pagination::FilterableArrayConnection do
|
||||
let(:callback) { proc { |nodes| nodes } }
|
||||
let(:all_nodes) { Gitlab::Graphql::FilterableArray.new(callback, 1, 2, 3, 4, 5) }
|
||||
let(:arguments) { {} }
|
||||
|
||||
subject(:connection) do
|
||||
described_class.new(all_nodes, { max_page_size: 3 }.merge(arguments))
|
||||
end
|
||||
|
||||
describe '#nodes' do
|
||||
let(:paged_nodes) { subject.nodes }
|
||||
|
||||
it_behaves_like 'connection with paged nodes' do
|
||||
let(:paged_nodes_size) { 3 }
|
||||
end
|
||||
|
||||
context 'when callback filters some nodes' do
|
||||
let(:callback) { proc { |nodes| nodes[1..-1] } }
|
||||
|
||||
it 'does not return filtered elements' do
|
||||
expect(subject.nodes).to contain_exactly(all_nodes[1], all_nodes[2])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe 'Users' do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:current_user) { create(:user, created_at: 1.day.ago) }
|
||||
let_it_be(:user1) { create(:user, created_at: 2.days.ago) }
|
||||
let_it_be(:user2) { create(:user, created_at: 3.days.ago) }
|
||||
let_it_be(:user3) { create(:user, created_at: 4.days.ago) }
|
||||
|
||||
describe '.users' do
|
||||
shared_examples 'a working users query' do
|
||||
it_behaves_like 'a working graphql query' do
|
||||
before do
|
||||
post_graphql(query, current_user: current_user)
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes a list of users' do
|
||||
post_graphql(query)
|
||||
|
||||
expect(graphql_data.dig('users', 'nodes')).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no arguments' do
|
||||
let_it_be(:query) { graphql_query_for(:users, { usernames: [user1.username] }, 'nodes { id }') }
|
||||
|
||||
it_behaves_like 'a working users query'
|
||||
end
|
||||
|
||||
context 'with a list of usernames' do
|
||||
let(:query) { graphql_query_for(:users, { usernames: [user1.username] }, 'nodes { id }') }
|
||||
|
||||
it_behaves_like 'a working users query'
|
||||
end
|
||||
|
||||
context 'with a list of IDs' do
|
||||
let(:query) { graphql_query_for(:users, { ids: [user1.to_global_id.to_s] }, 'nodes { id }') }
|
||||
|
||||
it_behaves_like 'a working users query'
|
||||
end
|
||||
|
||||
context 'when usernames and ids parameter are used' do
|
||||
let_it_be(:query) { graphql_query_for(:users, { ids: user1.to_global_id.to_s, usernames: user1.username }, 'nodes { id }') }
|
||||
|
||||
it 'displays an error' do
|
||||
post_graphql(query)
|
||||
|
||||
expect(graphql_errors).to include(
|
||||
a_hash_including('message' => a_string_matching(%r{Provide either a list of usernames or ids}))
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sorting and pagination' do
|
||||
let_it_be(:data_path) { [:users] }
|
||||
|
||||
def pagination_query(params, page_info)
|
||||
graphql_query_for("users", params, "#{page_info} edges { node { id } }")
|
||||
end
|
||||
|
||||
def pagination_results_data(data)
|
||||
data.map { |user| user.dig('node', 'id') }
|
||||
end
|
||||
|
||||
context 'when sorting by created_at' do
|
||||
let_it_be(:ascending_users) { [user3, user2, user1, current_user].map(&:to_global_id).map(&:to_s) }
|
||||
|
||||
context 'when ascending' do
|
||||
it_behaves_like 'sorted paginated query' do
|
||||
let(:sort_param) { 'created_asc' }
|
||||
let(:first_param) { 1 }
|
||||
let(:expected_results) { ascending_users }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when descending' do
|
||||
it_behaves_like 'sorted paginated query' do
|
||||
let(:sort_param) { 'created_desc' }
|
||||
let(:first_param) { 1 }
|
||||
let(:expected_results) { ascending_users.reverse }
|
||||
end
|
||||
end
|
||||
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?")
|
||||
end
|
||||
|
||||
it 'returns 403 with json content' 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)
|
||||
|
||||
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
|
||||
|
||||
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: { ids: [suggestion.id, suggestion2.id] }
|
||||
|
||||
expect(response).to have_gitlab_http_status(:forbidden)
|
||||
expect(json_response).to eq({ 'message' => '403 Forbidden' })
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe Jira::Requests::Projects do
|
|||
|
||||
it 'returns an error response' do
|
||||
expect(subject.error?).to be_truthy
|
||||
expect(subject.message).to eq('Timeout::Error')
|
||||
expect(subject.message).to eq('Jira request error: Timeout::Error')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -5,41 +5,66 @@ require 'spec_helper'
|
|||
describe Suggestions::ApplyService do
|
||||
include ProjectForksHelper
|
||||
|
||||
def build_position(args = {})
|
||||
default_args = { old_path: "files/ruby/popen.rb",
|
||||
new_path: "files/ruby/popen.rb",
|
||||
old_line: nil,
|
||||
new_line: 9,
|
||||
diff_refs: merge_request.diff_refs }
|
||||
def build_position(**optional_args)
|
||||
args = { old_path: "files/ruby/popen.rb",
|
||||
new_path: "files/ruby/popen.rb",
|
||||
old_line: nil,
|
||||
new_line: 9,
|
||||
diff_refs: merge_request.diff_refs,
|
||||
**optional_args }
|
||||
|
||||
Gitlab::Diff::Position.new(default_args.merge(args))
|
||||
Gitlab::Diff::Position.new(args)
|
||||
end
|
||||
|
||||
shared_examples 'successfully creates commit and updates suggestion' do
|
||||
def apply(suggestion)
|
||||
result = subject.execute(suggestion)
|
||||
expect(result[:status]).to eq(:success)
|
||||
end
|
||||
def create_suggestion(args)
|
||||
position_args = args.slice(:old_path, :new_path, :old_line, :new_line)
|
||||
content_args = args.slice(:from_content, :to_content)
|
||||
|
||||
it 'updates the file with the new contents' do
|
||||
apply(suggestion)
|
||||
position = build_position(position_args)
|
||||
|
||||
blob = project.repository.blob_at_branch(merge_request.source_branch,
|
||||
position.new_path)
|
||||
diff_note = create(:diff_note_on_merge_request,
|
||||
noteable: merge_request,
|
||||
position: position,
|
||||
project: project)
|
||||
|
||||
expect(blob.data).to eq(expected_content)
|
||||
suggestion_args = { note: diff_note }.merge(content_args)
|
||||
|
||||
create(:suggestion, :content_from_repo, suggestion_args)
|
||||
end
|
||||
|
||||
def apply(suggestions)
|
||||
result = apply_service.new(user, *suggestions).execute
|
||||
|
||||
suggestions.map { |suggestion| suggestion.reload }
|
||||
|
||||
expect(result[:status]).to eq(:success)
|
||||
end
|
||||
|
||||
shared_examples 'successfully creates commit and updates suggestions' do
|
||||
it 'updates the files with the new content' do
|
||||
apply(suggestions)
|
||||
|
||||
suggestions.each do |suggestion|
|
||||
path = suggestion.diff_file.file_path
|
||||
blob = project.repository.blob_at_branch(merge_request.source_branch,
|
||||
path)
|
||||
|
||||
expect(blob.data).to eq(expected_content_by_path[path.to_sym])
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates suggestion applied and commit_id columns' do
|
||||
expect { apply(suggestion) }
|
||||
.to change(suggestion, :applied)
|
||||
.from(false).to(true)
|
||||
.and change(suggestion, :commit_id)
|
||||
.from(nil)
|
||||
expect(suggestions.map(&:applied)).to all(be false)
|
||||
expect(suggestions.map(&:commit_id)).to all(be nil)
|
||||
|
||||
apply(suggestions)
|
||||
|
||||
expect(suggestions.map(&:applied)).to all(be true)
|
||||
expect(suggestions.map(&:commit_id)).to all(be_present)
|
||||
end
|
||||
|
||||
it 'created commit has users email and name' do
|
||||
apply(suggestion)
|
||||
apply(suggestions)
|
||||
|
||||
commit = project.repository.commit
|
||||
|
||||
|
|
@ -53,125 +78,214 @@ describe Suggestions::ApplyService do
|
|||
before do
|
||||
project.update!(suggestion_commit_message: message)
|
||||
|
||||
apply(suggestion)
|
||||
apply(suggestions)
|
||||
end
|
||||
|
||||
context 'is not specified' do
|
||||
let(:expected_value) { "Apply suggestion to files/ruby/popen.rb" }
|
||||
let(:message) { '' }
|
||||
|
||||
context 'is nil' do
|
||||
let(:message) { nil }
|
||||
|
||||
it 'sets default commit message' do
|
||||
expect(project.repository.commit.message).to eq(expected_value)
|
||||
end
|
||||
end
|
||||
|
||||
context 'is an empty string' do
|
||||
let(:message) { '' }
|
||||
|
||||
it 'sets default commit message' do
|
||||
expect(project.repository.commit.message).to eq(expected_value)
|
||||
end
|
||||
it 'uses the default commit message' do
|
||||
expect(project.repository.commit.message).to(
|
||||
match(/\AApply #{suggestions.size} suggestion\(s\) to \d+ file\(s\)\z/)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'is specified' do
|
||||
let(:message) { 'refactor: %{project_path} %{project_name} %{file_path} %{branch_name} %{username} %{user_full_name}' }
|
||||
let(:message) do
|
||||
'refactor: %{project_name} %{branch_name} %{username}'
|
||||
end
|
||||
|
||||
it 'sets custom commit message' do
|
||||
expect(project.repository.commit.message).to eq("refactor: project-1 Project_1 files/ruby/popen.rb master test.user Test User")
|
||||
it 'generates a custom commit message' do
|
||||
expect(project.repository.commit.message).to(
|
||||
eq("refactor: Project_1 master test.user")
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:project) { create(:project, :repository, path: 'project-1', name: 'Project_1') }
|
||||
let(:user) { create(:user, :commit_email, name: 'Test User', username: 'test.user') }
|
||||
subject(:apply_service) { described_class }
|
||||
|
||||
let_it_be(:user) do
|
||||
create(:user, :commit_email, name: 'Test User', username: 'test.user')
|
||||
end
|
||||
|
||||
let(:project) do
|
||||
create(:project, :repository, path: 'project-1', name: 'Project_1')
|
||||
end
|
||||
|
||||
let(:merge_request) do
|
||||
create(:merge_request, source_project: project,
|
||||
target_project: project,
|
||||
source_branch: 'master')
|
||||
end
|
||||
|
||||
let(:position) { build_position }
|
||||
|
||||
let(:diff_note) do
|
||||
create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project)
|
||||
create(:diff_note_on_merge_request, noteable: merge_request,
|
||||
position: position, project: project)
|
||||
end
|
||||
|
||||
let(:suggestion) do
|
||||
create(:suggestion, :content_from_repo, note: diff_note,
|
||||
to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
|
||||
to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
|
||||
end
|
||||
|
||||
subject { described_class.new(user) }
|
||||
let(:suggestion2) do
|
||||
create_suggestion(
|
||||
to_content: " *** SUGGESTION CHANGE ***\n",
|
||||
new_line: 15)
|
||||
end
|
||||
|
||||
let(:suggestion3) do
|
||||
create_suggestion(
|
||||
to_content: " *** ANOTHER SUGGESTION CHANGE ***\n",
|
||||
old_path: "files/ruby/regex.rb",
|
||||
new_path: "files/ruby/regex.rb",
|
||||
new_line: 22)
|
||||
end
|
||||
|
||||
let(:suggestions) { [suggestion, suggestion2, suggestion3] }
|
||||
|
||||
context 'patch is appliable' do
|
||||
let(:expected_content) do
|
||||
let(:popen_content) do
|
||||
<<-CONTENT.strip_heredoc
|
||||
require 'fileutils'
|
||||
require 'open3'
|
||||
require 'fileutils'
|
||||
require 'open3'
|
||||
|
||||
module Popen
|
||||
extend self
|
||||
module Popen
|
||||
extend self
|
||||
|
||||
def popen(cmd, path=nil)
|
||||
unless cmd.is_a?(Array)
|
||||
raise RuntimeError, 'Explosion'
|
||||
# explosion?
|
||||
end
|
||||
|
||||
path ||= Dir.pwd
|
||||
|
||||
vars = {
|
||||
"PWD" => path
|
||||
}
|
||||
|
||||
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
|
||||
def popen(cmd, path=nil)
|
||||
unless cmd.is_a?(Array)
|
||||
raise RuntimeError, 'Explosion'
|
||||
# explosion?
|
||||
end
|
||||
|
||||
path ||= Dir.pwd
|
||||
|
||||
vars = {
|
||||
*** SUGGESTION CHANGE ***
|
||||
}
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
context 'non-fork project' do
|
||||
let(:merge_request) do
|
||||
create(:merge_request, source_project: project,
|
||||
target_project: project,
|
||||
source_branch: 'master')
|
||||
end
|
||||
let(:regex_content) do
|
||||
<<-CONTENT.strip_heredoc
|
||||
module Gitlab
|
||||
module Regex
|
||||
extend self
|
||||
|
||||
def username_regex
|
||||
default_regex
|
||||
end
|
||||
|
||||
def project_name_regex
|
||||
/\\A[a-zA-Z0-9][a-zA-Z0-9_\\-\\. ]*\\z/
|
||||
end
|
||||
|
||||
def name_regex
|
||||
/\\A[a-zA-Z0-9_\\-\\. ]*\\z/
|
||||
end
|
||||
|
||||
def path_regex
|
||||
default_regex
|
||||
end
|
||||
|
||||
def archive_formats_regex
|
||||
*** ANOTHER SUGGESTION CHANGE ***
|
||||
end
|
||||
|
||||
def git_reference_regex
|
||||
# Valid git ref regex, see:
|
||||
# https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
|
||||
%r{
|
||||
(?!
|
||||
(?# doesn't begins with)
|
||||
\\/| (?# rule #6)
|
||||
(?# doesn't contain)
|
||||
.*(?:
|
||||
[\\\/.]\\\.| (?# rule #1,3)
|
||||
\\/\\/| (?# rule #6)
|
||||
@\\{| (?# rule #8)
|
||||
\\\\ (?# rule #9)
|
||||
)
|
||||
)
|
||||
[^\\000-\\040\\177~^:?*\\[]+ (?# rule #4-5)
|
||||
(?# doesn't end with)
|
||||
(?<!\\.lock) (?# rule #1)
|
||||
(?<![\\/.]) (?# rule #6-7)
|
||||
}x
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def default_regex
|
||||
/\\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\\-\\.]*(?<!\\.git)\\z/
|
||||
end
|
||||
end
|
||||
end
|
||||
CONTENT
|
||||
end
|
||||
|
||||
let(:expected_content_by_path) do
|
||||
{
|
||||
"files/ruby/popen.rb": popen_content,
|
||||
"files/ruby/regex.rb": regex_content
|
||||
}
|
||||
end
|
||||
|
||||
context 'non-fork project' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it_behaves_like 'successfully creates commit and updates suggestion'
|
||||
it_behaves_like 'successfully creates commit and updates suggestions'
|
||||
|
||||
context 'when it fails to apply because a file was changed' do
|
||||
before do
|
||||
params = {
|
||||
file_path: suggestion3.diff_file.file_path,
|
||||
start_branch: suggestion3.branch,
|
||||
branch_name: suggestion3.branch,
|
||||
commit_message: 'Update file',
|
||||
file_content: 'New content'
|
||||
}
|
||||
|
||||
# Reload the suggestion so it's memoized values get reset after the
|
||||
# file was changed.
|
||||
suggestion3.reload
|
||||
|
||||
Files::UpdateService.new(project, user, params).execute
|
||||
end
|
||||
|
||||
context 'when it fails to apply because the file was changed' do
|
||||
it 'returns error message' do
|
||||
service = instance_double(Files::UpdateService)
|
||||
result = apply_service.new(user, suggestion, suggestion3, suggestion2).execute
|
||||
|
||||
expect(Files::UpdateService).to receive(:new)
|
||||
.and_return(service)
|
||||
|
||||
allow(service).to receive(:execute)
|
||||
.and_raise(Files::UpdateService::FileChangedError)
|
||||
|
||||
result = subject.execute(suggestion)
|
||||
|
||||
expect(result).to eq(message: 'The file has been changed', status: :error)
|
||||
expect(result).to eq(message: 'A file has been changed.', status: :error)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -181,78 +295,20 @@ describe Suggestions::ApplyService do
|
|||
allow(suggestion.position).to receive(:head_sha) { 'old-sha' }
|
||||
allow(suggestion.noteable).to receive(:source_branch_sha) { 'new-sha' }
|
||||
|
||||
result = subject.execute(suggestion)
|
||||
result = apply_service.new(user, suggestion).execute
|
||||
|
||||
expect(result).to eq(message: 'The file has been changed', status: :error)
|
||||
expect(result).to eq(message: 'A file has been changed.', status: :error)
|
||||
end
|
||||
end
|
||||
|
||||
context 'multiple suggestions applied' do
|
||||
let(:expected_content) do
|
||||
<<-CONTENT.strip_heredoc
|
||||
require 'fileutils'
|
||||
require 'open3'
|
||||
|
||||
module Popen
|
||||
extend self
|
||||
|
||||
|
||||
def popen(cmd, path=nil)
|
||||
unless cmd.is_a?(Array)
|
||||
# v1 change
|
||||
end
|
||||
|
||||
path ||= Dir.pwd
|
||||
# v1 change
|
||||
vars = {
|
||||
"PWD" => path
|
||||
}
|
||||
|
||||
options = {
|
||||
chdir: path
|
||||
}
|
||||
# v2 change
|
||||
unless File.directory?(path)
|
||||
FileUtils.mkdir_p(path)
|
||||
end
|
||||
|
||||
@cmd_output = ""
|
||||
# v2 change
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
def create_suggestion(diff, old_line: nil, new_line: nil, from_content:, to_content:, path:)
|
||||
position = Gitlab::Diff::Position.new(old_path: path,
|
||||
new_path: path,
|
||||
old_line: old_line,
|
||||
new_line: new_line,
|
||||
diff_refs: diff.diff_refs)
|
||||
|
||||
suggestion_note = create(:diff_note_on_merge_request, noteable: merge_request,
|
||||
original_position: position,
|
||||
position: position,
|
||||
project: project)
|
||||
create(:suggestion, note: suggestion_note,
|
||||
from_content: from_content,
|
||||
to_content: to_content)
|
||||
end
|
||||
|
||||
context 'multiple suggestions applied sequentially' do
|
||||
def apply_suggestion(suggestion)
|
||||
suggestion.reload
|
||||
merge_request.reload
|
||||
merge_request.clear_memoized_shas
|
||||
|
||||
result = subject.execute(suggestion)
|
||||
result = apply_service.new(user, suggestion).execute
|
||||
suggestion.reload
|
||||
expect(result[:status]).to eq(:success)
|
||||
|
||||
refresh = MergeRequests::RefreshService.new(project, user)
|
||||
|
|
@ -264,34 +320,31 @@ describe Suggestions::ApplyService do
|
|||
end
|
||||
|
||||
def fetch_raw_diff(suggestion)
|
||||
project.reload.commit(suggestion.commit_id).diffs.diff_files.first.diff.diff
|
||||
project.reload.commit(suggestion.commit_id)
|
||||
.diffs.diff_files.first.diff.diff
|
||||
end
|
||||
|
||||
it 'applies multiple suggestions in subsequent versions correctly' do
|
||||
diff = merge_request.merge_request_diff
|
||||
path = 'files/ruby/popen.rb'
|
||||
suggestion1 = create_suggestion(
|
||||
from_content: "\n",
|
||||
to_content: "# v1 change\n",
|
||||
old_line: nil,
|
||||
new_line: 13)
|
||||
|
||||
suggestion_1_changes = { old_line: nil,
|
||||
new_line: 13,
|
||||
from_content: "\n",
|
||||
to_content: "# v1 change\n",
|
||||
path: path }
|
||||
suggestion2 = create_suggestion(
|
||||
from_content: " @cmd_output << stderr.read\n",
|
||||
to_content: "# v2 change\n",
|
||||
old_line: 24,
|
||||
new_line: 31)
|
||||
|
||||
suggestion_2_changes = { old_line: 24,
|
||||
new_line: 31,
|
||||
from_content: " @cmd_output << stderr.read\n",
|
||||
to_content: "# v2 change\n",
|
||||
path: path }
|
||||
apply_suggestion(suggestion1)
|
||||
apply_suggestion(suggestion2)
|
||||
|
||||
suggestion_1 = create_suggestion(diff, suggestion_1_changes)
|
||||
suggestion_2 = create_suggestion(diff, suggestion_2_changes)
|
||||
|
||||
apply_suggestion(suggestion_1)
|
||||
|
||||
suggestion_1_diff = fetch_raw_diff(suggestion_1)
|
||||
suggestion1_diff = fetch_raw_diff(suggestion1)
|
||||
suggestion2_diff = fetch_raw_diff(suggestion2)
|
||||
|
||||
# rubocop: disable Layout/TrailingWhitespace
|
||||
expected_suggestion_1_diff = <<-CONTENT.strip_heredoc
|
||||
expected_suggestion1_diff = <<-CONTENT.strip_heredoc
|
||||
@@ -10,7 +10,7 @@ module Popen
|
||||
end
|
||||
|
||||
|
|
@ -304,12 +357,8 @@ describe Suggestions::ApplyService do
|
|||
CONTENT
|
||||
# rubocop: enable Layout/TrailingWhitespace
|
||||
|
||||
apply_suggestion(suggestion_2)
|
||||
|
||||
suggestion_2_diff = fetch_raw_diff(suggestion_2)
|
||||
|
||||
# rubocop: disable Layout/TrailingWhitespace
|
||||
expected_suggestion_2_diff = <<-CONTENT.strip_heredoc
|
||||
expected_suggestion2_diff = <<-CONTENT.strip_heredoc
|
||||
@@ -28,7 +28,7 @@ module Popen
|
||||
|
||||
Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
|
||||
|
|
@ -321,14 +370,14 @@ describe Suggestions::ApplyService do
|
|||
CONTENT
|
||||
# rubocop: enable Layout/TrailingWhitespace
|
||||
|
||||
expect(suggestion_1_diff.strip).to eq(expected_suggestion_1_diff.strip)
|
||||
expect(suggestion_2_diff.strip).to eq(expected_suggestion_2_diff.strip)
|
||||
expect(suggestion1_diff.strip).to eq(expected_suggestion1_diff.strip)
|
||||
expect(suggestion2_diff.strip).to eq(expected_suggestion2_diff.strip)
|
||||
end
|
||||
end
|
||||
|
||||
context 'multi-line suggestion' do
|
||||
let(:expected_content) do
|
||||
<<~CONTENT
|
||||
let(:popen_content) do
|
||||
<<~CONTENT.strip_heredoc
|
||||
require 'fileutils'
|
||||
require 'open3'
|
||||
|
||||
|
|
@ -365,19 +414,27 @@ describe Suggestions::ApplyService do
|
|||
CONTENT
|
||||
end
|
||||
|
||||
let(:suggestion) do
|
||||
create(:suggestion, :content_from_repo, note: diff_note,
|
||||
lines_above: 2,
|
||||
lines_below: 3,
|
||||
to_content: "# multi\n# line\n")
|
||||
let(:expected_content_by_path) do
|
||||
{
|
||||
"files/ruby/popen.rb": popen_content
|
||||
}
|
||||
end
|
||||
|
||||
it_behaves_like 'successfully creates commit and updates suggestion'
|
||||
let(:suggestion) do
|
||||
create(:suggestion, :content_from_repo, note: diff_note,
|
||||
lines_above: 2,
|
||||
lines_below: 3,
|
||||
to_content: "# multi\n# line\n")
|
||||
end
|
||||
|
||||
let(:suggestions) { [suggestion] }
|
||||
|
||||
it_behaves_like 'successfully creates commit and updates suggestions'
|
||||
end
|
||||
|
||||
context 'remove an empty line suggestion' do
|
||||
let(:expected_content) do
|
||||
<<~CONTENT
|
||||
let(:popen_content) do
|
||||
<<~CONTENT.strip_heredoc
|
||||
require 'fileutils'
|
||||
require 'open3'
|
||||
|
||||
|
|
@ -417,12 +474,19 @@ describe Suggestions::ApplyService do
|
|||
CONTENT
|
||||
end
|
||||
|
||||
let(:position) { build_position(new_line: 13) }
|
||||
let(:suggestion) do
|
||||
create(:suggestion, :content_from_repo, note: diff_note, to_content: "")
|
||||
let(:expected_content_by_path) do
|
||||
{
|
||||
"files/ruby/popen.rb": popen_content
|
||||
}
|
||||
end
|
||||
|
||||
it_behaves_like 'successfully creates commit and updates suggestion'
|
||||
let(:suggestion) do
|
||||
create_suggestion( to_content: "", new_line: 13)
|
||||
end
|
||||
|
||||
let(:suggestions) { [suggestion] }
|
||||
|
||||
it_behaves_like 'successfully creates commit and updates suggestions'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -430,17 +494,23 @@ describe Suggestions::ApplyService do
|
|||
let(:project) { create(:project, :public, :repository) }
|
||||
|
||||
let(:forked_project) do
|
||||
fork_project_with_submodules(project, user, repository: project.repository)
|
||||
fork_project_with_submodules(project,
|
||||
user, repository: project.repository)
|
||||
end
|
||||
|
||||
let(:merge_request) do
|
||||
create(:merge_request,
|
||||
source_branch: 'conflict-resolvable-fork', source_project: forked_project,
|
||||
target_branch: 'conflict-start', target_project: project)
|
||||
source_branch: 'conflict-resolvable-fork',
|
||||
source_project: forked_project,
|
||||
target_branch: 'conflict-start',
|
||||
target_project: project)
|
||||
end
|
||||
|
||||
let!(:diff_note) do
|
||||
create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project)
|
||||
create(:diff_note_on_merge_request,
|
||||
noteable: merge_request,
|
||||
position: position,
|
||||
project: project)
|
||||
end
|
||||
|
||||
before do
|
||||
|
|
@ -448,11 +518,12 @@ describe Suggestions::ApplyService do
|
|||
end
|
||||
|
||||
it 'updates file in the source project' do
|
||||
expect(Files::UpdateService).to receive(:new)
|
||||
.with(merge_request.source_project, user, anything)
|
||||
.and_call_original
|
||||
expect(Files::MultiService).to receive(:new)
|
||||
.with(merge_request.source_project,
|
||||
user,
|
||||
anything).and_call_original
|
||||
|
||||
subject.execute(suggestion)
|
||||
apply_service.new(user, suggestion).execute
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -460,13 +531,13 @@ describe Suggestions::ApplyService do
|
|||
context 'no permission' do
|
||||
let(:merge_request) do
|
||||
create(:merge_request, source_project: project,
|
||||
target_project: project)
|
||||
target_project: project)
|
||||
end
|
||||
|
||||
let(:diff_note) do
|
||||
create(:diff_note_on_merge_request, noteable: merge_request,
|
||||
position: position,
|
||||
project: project)
|
||||
position: position,
|
||||
project: project)
|
||||
end
|
||||
|
||||
context 'user cannot write in project repo' do
|
||||
|
|
@ -475,7 +546,7 @@ describe Suggestions::ApplyService do
|
|||
end
|
||||
|
||||
it 'returns error' do
|
||||
result = subject.execute(suggestion)
|
||||
result = apply_service.new(user, suggestion).execute
|
||||
|
||||
expect(result).to eq(message: "You are not allowed to push into this branch",
|
||||
status: :error)
|
||||
|
|
@ -486,13 +557,13 @@ describe Suggestions::ApplyService do
|
|||
context 'patch is not appliable' do
|
||||
let(:merge_request) do
|
||||
create(:merge_request, source_project: project,
|
||||
target_project: project)
|
||||
target_project: project)
|
||||
end
|
||||
|
||||
let(:diff_note) do
|
||||
create(:diff_note_on_merge_request, noteable: merge_request,
|
||||
position: position,
|
||||
project: project)
|
||||
position: position,
|
||||
project: project)
|
||||
end
|
||||
|
||||
before do
|
||||
|
|
@ -503,9 +574,37 @@ describe Suggestions::ApplyService do
|
|||
it 'returns error message' do
|
||||
expect(suggestion.note).to receive(:latest_diff_file) { nil }
|
||||
|
||||
result = subject.execute(suggestion)
|
||||
result = apply_service.new(user, suggestion).execute
|
||||
|
||||
expect(result).to eq(message: 'Suggestion is not appliable',
|
||||
expect(result).to eq(message: 'A file was not found.',
|
||||
status: :error)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not all suggestions belong to the same branch' do
|
||||
it 'renders error message' do
|
||||
merge_request2 = create(:merge_request,
|
||||
:conflict,
|
||||
source_project: project,
|
||||
target_project: project)
|
||||
|
||||
position2 = 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_request2
|
||||
.diff_refs)
|
||||
|
||||
diff_note2 = create(:diff_note_on_merge_request,
|
||||
noteable: merge_request2,
|
||||
position: position2,
|
||||
project: project)
|
||||
|
||||
other_branch_suggestion = create(:suggestion, note: diff_note2)
|
||||
|
||||
result = apply_service.new(user, suggestion, other_branch_suggestion).execute
|
||||
|
||||
expect(result).to eq(message: 'Suggestions must all be on the same branch.',
|
||||
status: :error)
|
||||
end
|
||||
end
|
||||
|
|
@ -514,30 +613,22 @@ describe Suggestions::ApplyService do
|
|||
it 'returns error message' do
|
||||
expect(suggestion).to receive(:outdated?) { true }
|
||||
|
||||
result = subject.execute(suggestion)
|
||||
result = apply_service.new(user, suggestion).execute
|
||||
|
||||
expect(result).to eq(message: 'Suggestion is not appliable',
|
||||
expect(result).to eq(message: 'A suggestion is not applicable.',
|
||||
status: :error)
|
||||
end
|
||||
end
|
||||
|
||||
context 'suggestion was already applied' do
|
||||
it 'returns success status' do
|
||||
result = subject.execute(suggestion)
|
||||
|
||||
expect(result[:status]).to eq(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'note is outdated' do
|
||||
before do
|
||||
allow(diff_note).to receive(:active?) { false }
|
||||
end
|
||||
|
||||
it 'returns error message' do
|
||||
result = subject.execute(suggestion)
|
||||
result = apply_service.new(user, suggestion).execute
|
||||
|
||||
expect(result).to eq(message: 'Suggestion is not appliable',
|
||||
expect(result).to eq(message: 'A suggestion is not applicable.',
|
||||
status: :error)
|
||||
end
|
||||
end
|
||||
|
|
@ -548,9 +639,27 @@ describe Suggestions::ApplyService do
|
|||
end
|
||||
|
||||
it 'returns error message' do
|
||||
result = subject.execute(suggestion)
|
||||
result = apply_service.new(user, suggestion).execute
|
||||
|
||||
expect(result).to eq(message: 'Suggestion is not appliable',
|
||||
expect(result).to eq(message: 'A suggestion is not applicable.',
|
||||
status: :error)
|
||||
end
|
||||
end
|
||||
|
||||
context 'lines of suggestions overlap' do
|
||||
let(:suggestion) do
|
||||
create_suggestion(
|
||||
to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
|
||||
end
|
||||
|
||||
let(:overlapping_suggestion) do
|
||||
create_suggestion(to_content: "I Overlap!")
|
||||
end
|
||||
|
||||
it 'returns error message' do
|
||||
result = apply_service.new(user, suggestion, overlapping_suggestion).execute
|
||||
|
||||
expect(result).to eq(message: 'Suggestions are not applicable as their lines cannot overlap.',
|
||||
status: :error)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue