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