Add latest changes from gitlab-org/gitlab@master
							
								
								
									
										2
									
								
								Gemfile
								
								
								
								
							
							
						
						|  | @ -295,7 +295,7 @@ gem 'gon', '~> 6.4.0' | ||||||
| gem 'request_store', '~> 1.5' | gem 'request_store', '~> 1.5' | ||||||
| gem 'base32', '~> 0.3.0' | gem 'base32', '~> 0.3.0' | ||||||
| 
 | 
 | ||||||
| gem 'gitlab-license', '~> 1.5' | gem 'gitlab-license', '~> 2.0' | ||||||
| 
 | 
 | ||||||
| # Protect against bruteforcing | # Protect against bruteforcing | ||||||
| gem 'rack-attack', '~> 6.3.0' | gem 'rack-attack', '~> 6.3.0' | ||||||
|  |  | ||||||
|  | @ -490,7 +490,7 @@ GEM | ||||||
|       opentracing (~> 0.4) |       opentracing (~> 0.4) | ||||||
|       pg_query (~> 2.1) |       pg_query (~> 2.1) | ||||||
|       redis (> 3.0.0, < 5.0.0) |       redis (> 3.0.0, < 5.0.0) | ||||||
|     gitlab-license (1.5.0) |     gitlab-license (2.0.0) | ||||||
|     gitlab-mail_room (0.0.9) |     gitlab-mail_room (0.0.9) | ||||||
|     gitlab-markup (1.7.1) |     gitlab-markup (1.7.1) | ||||||
|     gitlab-net-dns (0.9.1) |     gitlab-net-dns (0.9.1) | ||||||
|  | @ -1489,7 +1489,7 @@ DEPENDENCIES | ||||||
|   gitlab-experiment (~> 0.6.1) |   gitlab-experiment (~> 0.6.1) | ||||||
|   gitlab-fog-azure-rm (~> 1.1.1) |   gitlab-fog-azure-rm (~> 1.1.1) | ||||||
|   gitlab-labkit (~> 0.20.0) |   gitlab-labkit (~> 0.20.0) | ||||||
|   gitlab-license (~> 1.5) |   gitlab-license (~> 2.0) | ||||||
|   gitlab-mail_room (~> 0.0.9) |   gitlab-mail_room (~> 0.0.9) | ||||||
|   gitlab-markup (~> 1.7.1) |   gitlab-markup (~> 1.7.1) | ||||||
|   gitlab-net-dns (~> 0.9.1) |   gitlab-net-dns (~> 0.9.1) | ||||||
|  |  | ||||||
|  | @ -227,7 +227,12 @@ export default { | ||||||
|           </template> |           </template> | ||||||
|         </gl-sprintf> |         </gl-sprintf> | ||||||
|       </span> |       </span> | ||||||
|       <gl-search-box-by-click class="gl-ml-auto" @submit="filter = $event" @clear="filter = ''" /> |       <gl-search-box-by-click | ||||||
|  |         class="gl-ml-auto" | ||||||
|  |         :placeholder="s__('BulkImport|Filter by source group')" | ||||||
|  |         @submit="filter = $event" | ||||||
|  |         @clear="filter = ''" | ||||||
|  |       /> | ||||||
|     </div> |     </div> | ||||||
|     <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> |     <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> | ||||||
|     <template v-else> |     <template v-else> | ||||||
|  |  | ||||||
|  | @ -225,11 +225,21 @@ export default { | ||||||
|           { |           { | ||||||
|             name: 'success', |             name: 'success', | ||||||
|             data: this.mergeLabelsAndValues(labels, success), |             data: this.mergeLabelsAndValues(labels, success), | ||||||
|  |             areaStyle: { | ||||||
|  |               color: this.$options.successColor, | ||||||
|  |             }, | ||||||
|  |             lineStyle: { | ||||||
|  |               color: this.$options.successColor, | ||||||
|  |             }, | ||||||
|  |             itemStyle: { | ||||||
|  |               color: this.$options.successColor, | ||||||
|  |             }, | ||||||
|           }, |           }, | ||||||
|         ], |         ], | ||||||
|       }; |       }; | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|  |   successColor: '#608b2f', | ||||||
|   chartContainerHeight: CHART_CONTAINER_HEIGHT, |   chartContainerHeight: CHART_CONTAINER_HEIGHT, | ||||||
|   timesChartOptions: { |   timesChartOptions: { | ||||||
|     height: INNER_CHART_HEIGHT, |     height: INNER_CHART_HEIGHT, | ||||||
|  |  | ||||||
|  | @ -3,6 +3,7 @@ import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; | ||||||
| import { uniqueId } from 'lodash'; | import { uniqueId } from 'lodash'; | ||||||
| import { sprintf, __ } from '~/locale'; | import { sprintf, __ } from '~/locale'; | ||||||
| import getRefMixin from '../mixins/get_ref'; | import getRefMixin from '../mixins/get_ref'; | ||||||
|  | import DeleteBlobModal from './delete_blob_modal.vue'; | ||||||
| import UploadBlobModal from './upload_blob_modal.vue'; | import UploadBlobModal from './upload_blob_modal.vue'; | ||||||
| 
 | 
 | ||||||
| export default { | export default { | ||||||
|  | @ -15,6 +16,7 @@ export default { | ||||||
|     GlButtonGroup, |     GlButtonGroup, | ||||||
|     GlButton, |     GlButton, | ||||||
|     UploadBlobModal, |     UploadBlobModal, | ||||||
|  |     DeleteBlobModal, | ||||||
|   }, |   }, | ||||||
|   directives: { |   directives: { | ||||||
|     GlModal: GlModalDirective, |     GlModal: GlModalDirective, | ||||||
|  | @ -41,10 +43,18 @@ export default { | ||||||
|       type: String, |       type: String, | ||||||
|       required: true, |       required: true, | ||||||
|     }, |     }, | ||||||
|  |     deletePath: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|     canPushCode: { |     canPushCode: { | ||||||
|       type: Boolean, |       type: Boolean, | ||||||
|       required: true, |       required: true, | ||||||
|     }, |     }, | ||||||
|  |     emptyRepo: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     replaceModalId() { |     replaceModalId() { | ||||||
|  | @ -53,6 +63,12 @@ export default { | ||||||
|     replaceModalTitle() { |     replaceModalTitle() { | ||||||
|       return sprintf(__('Replace %{name}'), { name: this.name }); |       return sprintf(__('Replace %{name}'), { name: this.name }); | ||||||
|     }, |     }, | ||||||
|  |     deleteModalId() { | ||||||
|  |       return uniqueId('delete-modal'); | ||||||
|  |     }, | ||||||
|  |     deleteModalTitle() { | ||||||
|  |       return sprintf(__('Delete %{name}'), { name: this.name }); | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|  | @ -63,7 +79,9 @@ export default { | ||||||
|       <gl-button v-gl-modal="replaceModalId"> |       <gl-button v-gl-modal="replaceModalId"> | ||||||
|         {{ $options.i18n.replace }} |         {{ $options.i18n.replace }} | ||||||
|       </gl-button> |       </gl-button> | ||||||
|       <gl-button>{{ $options.i18n.delete }}</gl-button> |       <gl-button v-gl-modal="deleteModalId"> | ||||||
|  |         {{ $options.i18n.delete }} | ||||||
|  |       </gl-button> | ||||||
|     </gl-button-group> |     </gl-button-group> | ||||||
|     <upload-blob-modal |     <upload-blob-modal | ||||||
|       :modal-id="replaceModalId" |       :modal-id="replaceModalId" | ||||||
|  | @ -76,5 +94,15 @@ export default { | ||||||
|       :replace-path="replacePath" |       :replace-path="replacePath" | ||||||
|       :primary-btn-text="$options.i18n.replacePrimaryBtnText" |       :primary-btn-text="$options.i18n.replacePrimaryBtnText" | ||||||
|     /> |     /> | ||||||
|  |     <delete-blob-modal | ||||||
|  |       :modal-id="deleteModalId" | ||||||
|  |       :modal-title="deleteModalTitle" | ||||||
|  |       :delete-path="deletePath" | ||||||
|  |       :commit-message="deleteModalTitle" | ||||||
|  |       :target-branch="targetBranch || ref" | ||||||
|  |       :original-branch="originalBranch || ref" | ||||||
|  |       :can-push-code="canPushCode" | ||||||
|  |       :empty-repo="emptyRepo" | ||||||
|  |     /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | @ -69,6 +69,7 @@ export default { | ||||||
|           pushCode: false, |           pushCode: false, | ||||||
|         }, |         }, | ||||||
|         repository: { |         repository: { | ||||||
|  |           empty: true, | ||||||
|           blobs: { |           blobs: { | ||||||
|             nodes: [ |             nodes: [ | ||||||
|               { |               { | ||||||
|  | @ -92,6 +93,7 @@ export default { | ||||||
|                 forkPath: '', |                 forkPath: '', | ||||||
|                 simpleViewer: {}, |                 simpleViewer: {}, | ||||||
|                 richViewer: null, |                 richViewer: null, | ||||||
|  |                 webPath: '', | ||||||
|               }, |               }, | ||||||
|             ], |             ], | ||||||
|           }, |           }, | ||||||
|  | @ -174,7 +176,9 @@ export default { | ||||||
|             :path="path" |             :path="path" | ||||||
|             :name="blobInfo.name" |             :name="blobInfo.name" | ||||||
|             :replace-path="blobInfo.replacePath" |             :replace-path="blobInfo.replacePath" | ||||||
|  |             :delete-path="blobInfo.webPath" | ||||||
|             :can-push-code="project.userPermissions.pushCode" |             :can-push-code="project.userPermissions.pushCode" | ||||||
|  |             :empty-repo="project.repository.empty" | ||||||
|           /> |           /> | ||||||
|         </template> |         </template> | ||||||
|       </blob-header> |       </blob-header> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,151 @@ | ||||||
|  | <script> | ||||||
|  | import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle } from '@gitlab/ui'; | ||||||
|  | import csrf from '~/lib/utils/csrf'; | ||||||
|  | import { __ } from '~/locale'; | ||||||
|  | import { | ||||||
|  |   SECONDARY_OPTIONS_TEXT, | ||||||
|  |   COMMIT_LABEL, | ||||||
|  |   TARGET_BRANCH_LABEL, | ||||||
|  |   TOGGLE_CREATE_MR_LABEL, | ||||||
|  | } from '../constants'; | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   csrf, | ||||||
|  |   components: { | ||||||
|  |     GlModal, | ||||||
|  |     GlFormGroup, | ||||||
|  |     GlFormInput, | ||||||
|  |     GlFormTextarea, | ||||||
|  |     GlToggle, | ||||||
|  |   }, | ||||||
|  |   i18n: { | ||||||
|  |     PRIMARY_OPTIONS_TEXT: __('Delete file'), | ||||||
|  |     SECONDARY_OPTIONS_TEXT, | ||||||
|  |     COMMIT_LABEL, | ||||||
|  |     TARGET_BRANCH_LABEL, | ||||||
|  |     TOGGLE_CREATE_MR_LABEL, | ||||||
|  |   }, | ||||||
|  |   props: { | ||||||
|  |     modalId: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     modalTitle: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     deletePath: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     commitMessage: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     targetBranch: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     originalBranch: { | ||||||
|  |       type: String, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     canPushCode: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     emptyRepo: { | ||||||
|  |       type: Boolean, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       loading: false, | ||||||
|  |       commit: this.commitMessage, | ||||||
|  |       target: this.targetBranch, | ||||||
|  |       createNewMr: true, | ||||||
|  |       error: '', | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     primaryOptions() { | ||||||
|  |       return { | ||||||
|  |         text: this.$options.i18n.PRIMARY_OPTIONS_TEXT, | ||||||
|  |         attributes: [ | ||||||
|  |           { | ||||||
|  |             variant: 'danger', | ||||||
|  |             loading: this.loading, | ||||||
|  |             disabled: !this.formCompleted || this.loading, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     cancelOptions() { | ||||||
|  |       return { | ||||||
|  |         text: this.$options.i18n.SECONDARY_OPTIONS_TEXT, | ||||||
|  |         attributes: [ | ||||||
|  |           { | ||||||
|  |             disabled: this.loading, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |     showCreateNewMrToggle() { | ||||||
|  |       return this.canPushCode && this.target !== this.originalBranch; | ||||||
|  |     }, | ||||||
|  |     formCompleted() { | ||||||
|  |       return this.commit && this.target; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     submitForm(e) { | ||||||
|  |       e.preventDefault(); // Prevent modal from closing | ||||||
|  |       this.loading = true; | ||||||
|  |       this.$refs.form.submit(); | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <gl-modal | ||||||
|  |     :modal-id="modalId" | ||||||
|  |     :title="modalTitle" | ||||||
|  |     :action-primary="primaryOptions" | ||||||
|  |     :action-cancel="cancelOptions" | ||||||
|  |     @primary="submitForm" | ||||||
|  |   > | ||||||
|  |     <form ref="form" :action="deletePath" method="post"> | ||||||
|  |       <input type="hidden" name="_method" value="delete" /> | ||||||
|  |       <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> | ||||||
|  |       <template v-if="emptyRepo"> | ||||||
|  |         <!-- Once "empty_repo_upload_experiment" is made available, will need to add class 'js-branch-name' | ||||||
|  |           Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335721 --> | ||||||
|  |         <input type="hidden" name="branch_name" :value="originalBranch" /> | ||||||
|  |       </template> | ||||||
|  |       <template v-else> | ||||||
|  |         <input type="hidden" name="original_branch" :value="originalBranch" /> | ||||||
|  |         <!-- Once "push to branch" permission is made available, will need to add to conditional | ||||||
|  |           Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 --> | ||||||
|  |         <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" /> | ||||||
|  |         <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message"> | ||||||
|  |           <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" /> | ||||||
|  |         </gl-form-group> | ||||||
|  |         <gl-form-group | ||||||
|  |           v-if="canPushCode" | ||||||
|  |           :label="$options.i18n.TARGET_BRANCH_LABEL" | ||||||
|  |           label-for="branch_name" | ||||||
|  |         > | ||||||
|  |           <gl-form-input v-model="target" :disabled="loading" name="branch_name" /> | ||||||
|  |         </gl-form-group> | ||||||
|  |         <gl-toggle | ||||||
|  |           v-if="showCreateNewMrToggle" | ||||||
|  |           v-model="createNewMr" | ||||||
|  |           :disabled="loading" | ||||||
|  |           :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </form> | ||||||
|  |   </gl-modal> | ||||||
|  | </template> | ||||||
|  | @ -1,3 +1,10 @@ | ||||||
|  | import { __ } from '~/locale'; | ||||||
|  | 
 | ||||||
| export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
 | export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
 | ||||||
| export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
 | export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
 | ||||||
| export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
 | export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
 | ||||||
|  | 
 | ||||||
|  | export const SECONDARY_OPTIONS_TEXT = __('Cancel'); | ||||||
|  | export const COMMIT_LABEL = __('Commit message'); | ||||||
|  | export const TARGET_BRANCH_LABEL = __('Target branch'); | ||||||
|  | export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { | ||||||
|       pushCode |       pushCode | ||||||
|     } |     } | ||||||
|     repository { |     repository { | ||||||
|  |       empty | ||||||
|       blobs(paths: [$filePath]) { |       blobs(paths: [$filePath]) { | ||||||
|         nodes { |         nodes { | ||||||
|           webPath |           webPath | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ | ||||||
|  * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter |  * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter | ||||||
|  * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action |  * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action | ||||||
|  * @param {string} defaultUpdateFn - the default function to dispatch |  * @param {string} defaultUpdateFn - the default function to dispatch | ||||||
|  * @param {string} root - the key of the state where to search fo they keys described in list |  * @param {string|function} root - the key of the state where to search for the keys described in list | ||||||
|  * @returns {Object} a dictionary with all the computed properties generated |  * @returns {Object} a dictionary with all the computed properties generated | ||||||
|  */ |  */ | ||||||
| export const mapComputed = (list, defaultUpdateFn, root) => { | export const mapComputed = (list, defaultUpdateFn, root) => { | ||||||
|  | @ -21,6 +21,10 @@ export const mapComputed = (list, defaultUpdateFn, root) => { | ||||||
|         if (getter) { |         if (getter) { | ||||||
|           return this.$store.getters[getter]; |           return this.$store.getters[getter]; | ||||||
|         } else if (root) { |         } else if (root) { | ||||||
|  |           if (typeof root === 'function') { | ||||||
|  |             return root(this.$store.state)[key]; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|           return this.$store.state[root][key]; |           return this.$store.state[root][key]; | ||||||
|         } |         } | ||||||
|         return this.$store.state[key]; |         return this.$store.state[key]; | ||||||
|  |  | ||||||
|  | @ -1673,7 +1673,7 @@ body.gl-dark .nav-sidebar .fly-out-top-item a, | ||||||
| body.gl-dark .nav-sidebar .fly-out-top-item.active a, | body.gl-dark .nav-sidebar .fly-out-top-item.active a, | ||||||
| body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container { | body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container { | ||||||
|   background-color: #2f2a6b; |   background-color: #2f2a6b; | ||||||
|   color: #333; |   color: var(--black, #333); | ||||||
| } | } | ||||||
| body.gl-dark .logo-text svg { | body.gl-dark .logo-text svg { | ||||||
|   fill: var(--gl-text-color); |   fill: var(--gl-text-color); | ||||||
|  |  | ||||||
|  | @ -185,7 +185,7 @@ | ||||||
|       &.active a, |       &.active a, | ||||||
|       .fly-out-top-item-container { |       .fly-out-top-item-container { | ||||||
|         background-color: $purple-900; |         background-color: $purple-900; | ||||||
|         color: $white; |         color: var(--black, $white); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -23,6 +23,10 @@ class Projects::BlobController < Projects::ApplicationController | ||||||
|   # We need to assign the blob vars before `authorize_edit_tree!` so we can |   # We need to assign the blob vars before `authorize_edit_tree!` so we can | ||||||
|   # validate access to a specific ref. |   # validate access to a specific ref. | ||||||
|   before_action :assign_blob_vars |   before_action :assign_blob_vars | ||||||
|  | 
 | ||||||
|  |   # Since BlobController doesn't use assign_ref_vars, we have to call this explicitly | ||||||
|  |   before_action :rectify_renamed_default_branch!, only: [:show] | ||||||
|  | 
 | ||||||
|   before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] |   before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] | ||||||
| 
 | 
 | ||||||
|   before_action :commit, except: [:new, :create] |   before_action :commit, except: [:new, :create] | ||||||
|  | @ -140,11 +144,15 @@ class Projects::BlobController < Projects::ApplicationController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def commit |   def commit | ||||||
|     @commit = @repository.commit(@ref) |     @commit ||= @repository.commit(@ref) | ||||||
| 
 | 
 | ||||||
|     return render_404 unless @commit |     return render_404 unless @commit | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def redirect_renamed_default_branch? | ||||||
|  |     action_name == 'show' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def assign_blob_vars |   def assign_blob_vars | ||||||
|     @id = params[:id] |     @id = params[:id] | ||||||
|     @ref, @path = extract_ref(@id) |     @ref, @path = extract_ref(@id) | ||||||
|  | @ -152,6 +160,12 @@ class Projects::BlobController < Projects::ApplicationController | ||||||
|     render_404 |     render_404 | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def rectify_renamed_default_branch! | ||||||
|  |     @commit ||= @repository.commit(@ref) | ||||||
|  | 
 | ||||||
|  |     super | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   # rubocop: disable CodeReuse/ActiveRecord |   # rubocop: disable CodeReuse/ActiveRecord | ||||||
|   def after_edit_path |   def after_edit_path | ||||||
|     from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:from_merge_request_iid]) |     from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:from_merge_request_iid]) | ||||||
|  |  | ||||||
|  | @ -39,6 +39,10 @@ class Projects::TreeController < Projects::ApplicationController | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|  |   def redirect_renamed_default_branch? | ||||||
|  |     action_name == 'show' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def assign_dir_vars |   def assign_dir_vars | ||||||
|     @branch_name = params[:branch_name] |     @branch_name = params[:branch_name] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -20,7 +20,11 @@ module ClustersHelper | ||||||
|     { |     { | ||||||
|       default_branch_name: clusterable_project.default_branch, |       default_branch_name: clusterable_project.default_branch, | ||||||
|       empty_state_image: image_path('illustrations/clusters_empty.svg'), |       empty_state_image: image_path('illustrations/clusters_empty.svg'), | ||||||
|       project_path: clusterable_project.full_path |       project_path: clusterable_project.full_path, | ||||||
|  |       agent_docs_url: help_page_path('user/clusters/agent/index'), | ||||||
|  |       install_docs_url: help_page_path('administration/clusters/kas'), | ||||||
|  |       get_started_docs_url: help_page_path('user/clusters/agent/index', anchor: 'define-a-configuration-repository'), | ||||||
|  |       integration_docs_url: help_page_path('user/clusters/agent/index', anchor: 'get-started-with-gitops-and-the-gitlab-agent') | ||||||
|     } |     } | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -131,7 +131,7 @@ module SearchHelper | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def search_sort_options |   def search_sort_options | ||||||
|     [ |     options = [ | ||||||
|       { |       { | ||||||
|         title: _('Created date'), |         title: _('Created date'), | ||||||
|         sortable: true, |         sortable: true, | ||||||
|  | @ -149,6 +149,19 @@ module SearchHelper | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     ] |     ] | ||||||
|  | 
 | ||||||
|  |     if search_service.scope == 'issues' && Feature.enabled?(:search_sort_issues_by_popularity) | ||||||
|  |       options << { | ||||||
|  |         title: _('Popularity'), | ||||||
|  |         sortable: true, | ||||||
|  |         sortParam: { | ||||||
|  |           asc: 'popularity_asc', | ||||||
|  |           desc: 'popularity_desc' | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     options | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  | @ -27,9 +27,6 @@ class AwardEmoji < ApplicationRecord | ||||||
|   after_save :expire_cache |   after_save :expire_cache | ||||||
|   after_destroy :expire_cache |   after_destroy :expire_cache | ||||||
| 
 | 
 | ||||||
|   after_save :update_awardable_upvotes_count |  | ||||||
|   after_destroy :update_awardable_upvotes_count |  | ||||||
| 
 |  | ||||||
|   class << self |   class << self | ||||||
|     def votes_for_collection(ids, type) |     def votes_for_collection(ids, type) | ||||||
|       select('name', 'awardable_id', 'COUNT(*) as count') |       select('name', 'awardable_id', 'COUNT(*) as count') | ||||||
|  | @ -66,15 +63,6 @@ class AwardEmoji < ApplicationRecord | ||||||
|   def expire_cache |   def expire_cache | ||||||
|     awardable.try(:bump_updated_at) |     awardable.try(:bump_updated_at) | ||||||
|     awardable.try(:expire_etag_cache) |     awardable.try(:expire_etag_cache) | ||||||
|   end |     awardable.try(:update_upvotes_count) if upvote? | ||||||
| 
 |  | ||||||
|   private |  | ||||||
| 
 |  | ||||||
|   def update_awardable_upvotes_count |  | ||||||
|     return unless upvote? && awardable.has_attribute?(:upvotes_count) |  | ||||||
| 
 |  | ||||||
|     awardable.update_column(:upvotes_count, awardable.upvotes) |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
| 
 |  | ||||||
| AwardEmoji.prepend_mod_with('AwardEmoji') |  | ||||||
|  |  | ||||||
|  | @ -520,6 +520,11 @@ class Issue < ApplicationRecord | ||||||
|     issue_assignees.pluck(:user_id) |     issue_assignees.pluck(:user_id) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def update_upvotes_count | ||||||
|  |     self.lock! | ||||||
|  |     self.update_column(:upvotes_count, self.upvotes) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|   def spammable_attribute_changed? |   def spammable_attribute_changed? | ||||||
|  |  | ||||||
|  | @ -1,14 +1,61 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class MergeRequest::CleanupSchedule < ApplicationRecord | class MergeRequest::CleanupSchedule < ApplicationRecord | ||||||
|  |   STATUSES = { | ||||||
|  |     unstarted: 0, | ||||||
|  |     running: 1, | ||||||
|  |     completed: 2, | ||||||
|  |     failed: 3 | ||||||
|  |   }.freeze | ||||||
|  | 
 | ||||||
|   belongs_to :merge_request, inverse_of: :cleanup_schedule |   belongs_to :merge_request, inverse_of: :cleanup_schedule | ||||||
| 
 | 
 | ||||||
|   validates :scheduled_at, presence: true |   validates :scheduled_at, presence: true | ||||||
| 
 | 
 | ||||||
|   def self.scheduled_merge_request_ids(limit) |   state_machine :status, initial: :unstarted do | ||||||
|     where('completed_at IS NULL AND scheduled_at <= NOW()') |     state :unstarted, value: STATUSES[:unstarted] | ||||||
|  |     state :running, value: STATUSES[:running] | ||||||
|  |     state :completed, value: STATUSES[:completed] | ||||||
|  |     state :failed, value: STATUSES[:failed] | ||||||
|  | 
 | ||||||
|  |     event :run do | ||||||
|  |       transition unstarted: :running | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     event :retry do | ||||||
|  |       transition running: :unstarted | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     event :complete do | ||||||
|  |       transition running: :completed | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     event :mark_as_failed do | ||||||
|  |       transition running: :failed | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     before_transition to: [:completed] do |cleanup_schedule, _transition| | ||||||
|  |       cleanup_schedule.completed_at = Time.current | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     before_transition from: :running, to: [:unstarted, :failed] do |cleanup_schedule, _transition| | ||||||
|  |       cleanup_schedule.failed_count += 1 | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   scope :scheduled_and_unstarted, -> { | ||||||
|  |     where('completed_at IS NULL AND scheduled_at <= NOW() AND status = ?', STATUSES[:unstarted]) | ||||||
|       .order('scheduled_at DESC') |       .order('scheduled_at DESC') | ||||||
|       .limit(limit) |   } | ||||||
|       .pluck(:merge_request_id) | 
 | ||||||
|  |   def self.start_next | ||||||
|  |     MergeRequest::CleanupSchedule.transaction do | ||||||
|  |       cleanup_schedule = scheduled_and_unstarted.lock('FOR UPDATE SKIP LOCKED').first | ||||||
|  | 
 | ||||||
|  |       next if cleanup_schedule.blank? | ||||||
|  | 
 | ||||||
|  |       cleanup_schedule.run! | ||||||
|  |       cleanup_schedule | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -416,6 +416,7 @@ class Project < ApplicationRecord | ||||||
|     prefix: :import, to: :import_state, allow_nil: true |     prefix: :import, to: :import_state, allow_nil: true | ||||||
|   delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting |   delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting | ||||||
|   delegate :squash_option, to: :project_setting |   delegate :squash_option, to: :project_setting | ||||||
|  |   delegate :previous_default_branch, :previous_default_branch=, to: :project_setting | ||||||
|   delegate :no_import?, to: :import_state, allow_nil: true |   delegate :no_import?, to: :import_state, allow_nil: true | ||||||
|   delegate :name, to: :owner, allow_nil: true, prefix: true |   delegate :name, to: :owner, allow_nil: true, prefix: true | ||||||
|   delegate :members, to: :team, prefix: true |   delegate :members, to: :team, prefix: true | ||||||
|  |  | ||||||
|  | @ -66,6 +66,8 @@ module Projects | ||||||
|       previous_default_branch = project.default_branch |       previous_default_branch = project.default_branch | ||||||
| 
 | 
 | ||||||
|       if project.change_head(params[:default_branch]) |       if project.change_head(params[:default_branch]) | ||||||
|  |         params[:previous_default_branch] = previous_default_branch | ||||||
|  | 
 | ||||||
|         after_default_branch_change(previous_default_branch) |         after_default_branch_change(previous_default_branch) | ||||||
|       else |       else | ||||||
|         raise ValidationError, s_("UpdateProject|Could not set the default branch") |         raise ValidationError, s_("UpdateProject|Could not set the default branch") | ||||||
|  |  | ||||||
|  | @ -31,8 +31,8 @@ | ||||||
|               .js-text.d-inline= _('Preview payload') |               .js-text.d-inline= _('Preview payload') | ||||||
|             %pre.service-data-payload-container.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } |             %pre.service-data-payload-container.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } | ||||||
|           - else |           - else | ||||||
|             = _('Service ping is disabled, and cannot be configured through this form.') |             = _('Service ping is disabled in your configuration file, and cannot be enabled through this form.') | ||||||
|             - deactivating_service_ping_path = help_page_path('development/service_ping/index.md', anchor: 'disable-service-ping') |             - deactivating_service_ping_path = help_page_path('development/service_ping/index.md', anchor: 'disable-service-ping-using-the-configuration-file') | ||||||
|             - deactivating_service_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_service_ping_path } |             - deactivating_service_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_service_ping_path } | ||||||
|             = s_('For more information, see the documentation on %{deactivating_service_ping_link_start}deactivating service ping%{deactivating_service_ping_link_end}.').html_safe % { deactivating_service_ping_link_start: deactivating_service_ping_link_start, deactivating_service_ping_link_end: '</a>'.html_safe } |             = s_('For more information, see the documentation on %{deactivating_service_ping_link_start}deactivating service ping%{deactivating_service_ping_link_end}.').html_safe % { deactivating_service_ping_link_start: deactivating_service_ping_link_start, deactivating_service_ping_link_end: '</a>'.html_safe } | ||||||
|     .form-group |     .form-group | ||||||
|  |  | ||||||
|  | @ -8,6 +8,6 @@ | ||||||
|       %h4 |       %h4 | ||||||
|         = _('Introducing Your DevOps Report') |         = _('Introducing Your DevOps Report') | ||||||
|       %p |       %p | ||||||
|         = _('Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.') |         = _('Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. Use it to view how you compare with other organizations.') | ||||||
|     .svg-container.devops |     .svg-container.devops | ||||||
|       = custom_icon('dev_ops_report_overview') |       = custom_icon('dev_ops_report_overview') | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ | ||||||
| .js-invite-members-trigger{ data: { variant: 'confirm', | .js-invite-members-trigger{ data: { variant: 'confirm', | ||||||
|   classes: 'gl-mb-8 gl-xs-w-full', |   classes: 'gl-mb-8 gl-xs-w-full', | ||||||
|   display_text: s_('InviteMember|Invite members'), |   display_text: s_('InviteMember|Invite members'), | ||||||
|  |   trigger_source: 'project-empty-page', | ||||||
|   event: 'click_button', |   event: 'click_button', | ||||||
|   label: 'invite_members_empty_project' } } |   label: 'invite_members_empty_project' } } | ||||||
| 
 | 
 | ||||||
|  | @ -7,7 +7,7 @@ | ||||||
| = render "home_panel" | = render "home_panel" | ||||||
| = render "archived_notice", project: @project | = render "archived_notice", project: @project | ||||||
| 
 | 
 | ||||||
| = render "invite_members" if can_import_members? | = render 'invite_members_empty_project' if can_import_members? | ||||||
| 
 | 
 | ||||||
| %h4.gl-mt-0.gl-mb-3 | %h4.gl-mt-0.gl-mb-3 | ||||||
|   = _('The repository for this project is empty') |   = _('The repository for this project is empty') | ||||||
|  |  | ||||||
|  | @ -1,14 +1,19 @@ | ||||||
| %div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' } | %div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-3! gl-mt-5 gl-mb-0!' } | ||||||
|   %span.gl-display-flex.gl-align-items-center |   .col-sm-9 | ||||||
|     %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable) |     %span.gl-display-flex.gl-align-items-center | ||||||
|     = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential? |       %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable) | ||||||
|     = link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do |       = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential? | ||||||
|       %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title |       = link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do | ||||||
|   .gl-text-gray-500.gl-my-3 |         %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title | ||||||
|     = issuable_project_reference(issuable) |     .gl-text-gray-500.gl-my-3 | ||||||
|     · |       = issuable_project_reference(issuable) | ||||||
|     = sprintf(s_('created %{issuable_created} by %{author}'), { issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe |       · | ||||||
|     · |       = sprintf(s_('created %{issuable_created} by %{author}'), { issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe | ||||||
|     = sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe |     .description.term.gl-px-0 | ||||||
|   .description.term.col-sm-10.gl-px-0 |       = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) | ||||||
|     = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) |   .col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right | ||||||
|  |     - if Feature.enabled?(:search_sort_issues_by_popularity) && issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0 | ||||||
|  |       %li.issuable-upvotes.gl-list-style-none.has-tooltip{ title: _('Upvotes') } | ||||||
|  |         = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") | ||||||
|  |         = issuable.upvotes_count | ||||||
|  |     %span.gl-text-gray-500= sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe | ||||||
|  |  | ||||||
|  | @ -2,6 +2,8 @@ | ||||||
| 
 | 
 | ||||||
| class MergeRequestCleanupRefsWorker | class MergeRequestCleanupRefsWorker | ||||||
|   include ApplicationWorker |   include ApplicationWorker | ||||||
|  |   include LimitedCapacity::Worker | ||||||
|  |   include Gitlab::Utils::StrongMemoize | ||||||
| 
 | 
 | ||||||
|   sidekiq_options retry: 3 |   sidekiq_options retry: 3 | ||||||
| 
 | 
 | ||||||
|  | @ -9,20 +11,60 @@ class MergeRequestCleanupRefsWorker | ||||||
|   tags :exclude_from_kubernetes |   tags :exclude_from_kubernetes | ||||||
|   idempotent! |   idempotent! | ||||||
| 
 | 
 | ||||||
|   def perform(merge_request_id) |   # Hard-coded to 4 for now. Will be configurable later on via application settings. | ||||||
|  |   # This means, there can only be 4 jobs running at the same time at maximum. | ||||||
|  |   MAX_RUNNING_JOBS = 4 | ||||||
|  |   FAILURE_THRESHOLD = 3 | ||||||
|  | 
 | ||||||
|  |   def perform_work | ||||||
|     return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) |     return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) | ||||||
| 
 | 
 | ||||||
|     merge_request = MergeRequest.find_by_id(merge_request_id) |  | ||||||
| 
 |  | ||||||
|     unless merge_request |     unless merge_request | ||||||
|       logger.error("Failed to find merge request with ID: #{merge_request_id}") |       logger.error('No existing merge request to be cleaned up.') | ||||||
|       return |       return | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     result = ::MergeRequests::CleanupRefsService.new(merge_request).execute |     log_extra_metadata_on_done(:merge_request_id, merge_request.id) | ||||||
| 
 | 
 | ||||||
|     return if result[:status] == :success |     result = MergeRequests::CleanupRefsService.new(merge_request).execute | ||||||
| 
 | 
 | ||||||
|     logger.error("Failed cleanup refs of merge request (#{merge_request_id}): #{result[:message]}") |     if result[:status] == :success | ||||||
|  |       merge_request_cleanup_schedule.complete! | ||||||
|  |     else | ||||||
|  |       if merge_request_cleanup_schedule.failed_count < FAILURE_THRESHOLD | ||||||
|  |         merge_request_cleanup_schedule.retry! | ||||||
|  |       else | ||||||
|  |         merge_request_cleanup_schedule.mark_as_failed! | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       log_extra_metadata_on_done(:message, result[:message]) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     log_extra_metadata_on_done(:status, merge_request_cleanup_schedule.status) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def remaining_work_count | ||||||
|  |     MergeRequest::CleanupSchedule | ||||||
|  |       .scheduled_and_unstarted | ||||||
|  |       .limit(max_running_jobs) | ||||||
|  |       .count | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def max_running_jobs | ||||||
|  |     MAX_RUNNING_JOBS | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def merge_request | ||||||
|  |     strong_memoize(:merge_request) do | ||||||
|  |       merge_request_cleanup_schedule&.merge_request | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def merge_request_cleanup_schedule | ||||||
|  |     strong_memoize(:merge_request_cleanup_schedule) do | ||||||
|  |       MergeRequest::CleanupSchedule.start_next | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -10,21 +10,10 @@ class ScheduleMergeRequestCleanupRefsWorker | ||||||
|   tags :exclude_from_kubernetes |   tags :exclude_from_kubernetes | ||||||
|   idempotent! |   idempotent! | ||||||
| 
 | 
 | ||||||
|   # Based on existing data, MergeRequestCleanupRefsWorker can run 3 jobs per |  | ||||||
|   # second. This means that 180 jobs can be performed but since there are some |  | ||||||
|   # spikes from time time, it's better to give it some allowance. |  | ||||||
|   LIMIT = 180 |  | ||||||
|   DELAY = 10.seconds |  | ||||||
|   BATCH_SIZE = 30 |  | ||||||
| 
 |  | ||||||
|   def perform |   def perform | ||||||
|     return if Gitlab::Database.read_only? |     return if Gitlab::Database.read_only? | ||||||
|     return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) |     return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) | ||||||
| 
 | 
 | ||||||
|     ids = MergeRequest::CleanupSchedule.scheduled_merge_request_ids(LIMIT).map { |id| [id] } |     MergeRequestCleanupRefsWorker.perform_with_capacity | ||||||
| 
 |  | ||||||
|     MergeRequestCleanupRefsWorker.bulk_perform_in(DELAY, ids, batch_size: BATCH_SIZE) # rubocop:disable Scalability/BulkPerformWithContext |  | ||||||
| 
 |  | ||||||
|     log_extra_metadata_on_done(:merge_requests_count, ids.size) |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| --- | --- | ||||||
| name: merge_request_discussion_cache | name: merge_request_discussion_cache | ||||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64688 | introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64688 | ||||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/332967 | rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/335799 | ||||||
| milestone: '14.1' | milestone: '14.1' | ||||||
| type: development | type: development | ||||||
| group: group::code review | group: group::code review | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| --- | --- | ||||||
| name: merge_request_refs_cleanup | name: merge_request_refs_cleanup | ||||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51558 | introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51558 | ||||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/296874 | rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/336070 | ||||||
| milestone: '13.8' | milestone: '13.8' | ||||||
| type: development | type: development | ||||||
| group: group::code review | group: group::code review | ||||||
|  |  | ||||||
|  | @ -0,0 +1,8 @@ | ||||||
|  | --- | ||||||
|  | name: search_sort_issues_by_popularity | ||||||
|  | introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65231 | ||||||
|  | rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334974 | ||||||
|  | milestone: '14.1' | ||||||
|  | type: development | ||||||
|  | group: group::global search | ||||||
|  | default_enabled: false | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class AddProjectSettingsPreviousDefaultBranch < ActiveRecord::Migration[6.1] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   # rubocop:disable Migration/AddLimitToTextColumns | ||||||
|  |   # limit is added in 20210707173645_add_project_settings_previous_default_branch_text_limit | ||||||
|  |   def up | ||||||
|  |     with_lock_retries do | ||||||
|  |       add_column :project_settings, :previous_default_branch, :text | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |   # rubocop:enable Migration/AddLimitToTextColumns | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     with_lock_retries do | ||||||
|  |       remove_column :project_settings, :previous_default_branch | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1,25 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class AddStatusToMergeRequestCleanupSchedules < ActiveRecord::Migration[6.1] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   INDEX_NAME = 'index_merge_request_cleanup_schedules_on_status' | ||||||
|  | 
 | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     unless column_exists?(:merge_request_cleanup_schedules, :status) | ||||||
|  |       add_column(:merge_request_cleanup_schedules, :status, :integer, limit: 2, default: 0, null: false) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     add_concurrent_index(:merge_request_cleanup_schedules, :status, name: INDEX_NAME) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_concurrent_index_by_name(:merge_request_cleanup_schedules, INDEX_NAME) | ||||||
|  | 
 | ||||||
|  |     if column_exists?(:merge_request_cleanup_schedules, :status) | ||||||
|  |       remove_column(:merge_request_cleanup_schedules, :status) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1,15 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class AddProjectSettingsPreviousDefaultBranchTextLimit < ActiveRecord::Migration[6.1] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     add_text_limit :project_settings, :previous_default_branch, 4096 | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_text_limit :project_settings, :previous_default_branch | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class AddFailedCountToMergeRequestCleanupSchedules < ActiveRecord::Migration[6.1] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   def change | ||||||
|  |     add_column :merge_request_cleanup_schedules, :failed_count, :integer, default: 0, null: false | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class UpdateMergeRequestCleanupSchedulesScheduledAtIndex < ActiveRecord::Migration[6.1] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   INDEX_NAME = 'index_mr_cleanup_schedules_timestamps_status' | ||||||
|  |   OLD_INDEX_NAME = 'index_mr_cleanup_schedules_timestamps' | ||||||
|  | 
 | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     add_concurrent_index(:merge_request_cleanup_schedules, :scheduled_at, where: 'completed_at IS NULL AND status = 0', name: INDEX_NAME) | ||||||
|  |     remove_concurrent_index_by_name(:merge_request_cleanup_schedules, OLD_INDEX_NAME) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_concurrent_index_by_name(:merge_request_cleanup_schedules, INDEX_NAME) | ||||||
|  |     add_concurrent_index(:merge_request_cleanup_schedules, :scheduled_at, where: 'completed_at IS NULL', name: OLD_INDEX_NAME) | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class AddUpvotesCountIndexToIssues < ActiveRecord::Migration[6.1] | ||||||
|  |   include Gitlab::Database::MigrationHelpers | ||||||
|  | 
 | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   INDEX_NAME = 'index_issues_on_project_id_and_upvotes_count' | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     add_concurrent_index :issues, [:project_id, :upvotes_count], name: INDEX_NAME | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_concurrent_index :issues, [:project_id, :upvotes_count], name: INDEX_NAME | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | 02aea8fe759614bc3aa751e023aa508963f8183366f6d6f518bbccc2d85ec1a1 | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | ac150e706b115849aa3802ae7b8e07d983e89eb637c48582c64948cbc7d7163d | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | 98d4deaf0564119c1ee44d76d3a30bff1a0fceb7cab67c5dbef576faef62ddf5 | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | e440dac0e14df7309c84e72b98ed6373c712901dc66310a474979e0fce7dc59c | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | 77f6db1d2aeebdefd76c96966da6c9e4ce5da2c92a42f6ac2398b35fa21c680f | ||||||
|  | @ -0,0 +1 @@ | ||||||
|  | 2899d954a199fa52bf6ab4beca5f22dcb9f9f0312e658f1307d1a7355394f1bb | ||||||
|  | @ -14711,7 +14711,9 @@ CREATE TABLE merge_request_cleanup_schedules ( | ||||||
|     scheduled_at timestamp with time zone NOT NULL, |     scheduled_at timestamp with time zone NOT NULL, | ||||||
|     completed_at timestamp with time zone, |     completed_at timestamp with time zone, | ||||||
|     created_at timestamp with time zone NOT NULL, |     created_at timestamp with time zone NOT NULL, | ||||||
|     updated_at timestamp with time zone NOT NULL |     updated_at timestamp with time zone NOT NULL, | ||||||
|  |     status smallint DEFAULT 0 NOT NULL, | ||||||
|  |     failed_count integer DEFAULT 0 NOT NULL | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| CREATE SEQUENCE merge_request_cleanup_schedules_merge_request_id_seq | CREATE SEQUENCE merge_request_cleanup_schedules_merge_request_id_seq | ||||||
|  | @ -17082,6 +17084,8 @@ CREATE TABLE project_settings ( | ||||||
|     prevent_merge_without_jira_issue boolean DEFAULT false NOT NULL, |     prevent_merge_without_jira_issue boolean DEFAULT false NOT NULL, | ||||||
|     cve_id_request_enabled boolean DEFAULT true NOT NULL, |     cve_id_request_enabled boolean DEFAULT true NOT NULL, | ||||||
|     mr_default_target_self boolean DEFAULT false NOT NULL, |     mr_default_target_self boolean DEFAULT false NOT NULL, | ||||||
|  |     previous_default_branch text, | ||||||
|  |     CONSTRAINT check_3a03e7557a CHECK ((char_length(previous_default_branch) <= 4096)), | ||||||
|     CONSTRAINT check_bde223416c CHECK ((show_default_award_emojis IS NOT NULL)) |     CONSTRAINT check_bde223416c CHECK ((show_default_award_emojis IS NOT NULL)) | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  | @ -23866,6 +23870,8 @@ CREATE UNIQUE INDEX index_issues_on_project_id_and_external_key ON issues USING | ||||||
| 
 | 
 | ||||||
| CREATE UNIQUE INDEX index_issues_on_project_id_and_iid ON issues USING btree (project_id, iid); | CREATE UNIQUE INDEX index_issues_on_project_id_and_iid ON issues USING btree (project_id, iid); | ||||||
| 
 | 
 | ||||||
|  | CREATE INDEX index_issues_on_project_id_and_upvotes_count ON issues USING btree (project_id, upvotes_count); | ||||||
|  | 
 | ||||||
| CREATE INDEX index_issues_on_promoted_to_epic_id ON issues USING btree (promoted_to_epic_id) WHERE (promoted_to_epic_id IS NOT NULL); | CREATE INDEX index_issues_on_promoted_to_epic_id ON issues USING btree (promoted_to_epic_id) WHERE (promoted_to_epic_id IS NOT NULL); | ||||||
| 
 | 
 | ||||||
| CREATE INDEX index_issues_on_sprint_id ON issues USING btree (sprint_id); | CREATE INDEX index_issues_on_sprint_id ON issues USING btree (sprint_id); | ||||||
|  | @ -23988,6 +23994,8 @@ CREATE INDEX index_merge_request_blocks_on_blocked_merge_request_id ON merge_req | ||||||
| 
 | 
 | ||||||
| CREATE UNIQUE INDEX index_merge_request_cleanup_schedules_on_merge_request_id ON merge_request_cleanup_schedules USING btree (merge_request_id); | CREATE UNIQUE INDEX index_merge_request_cleanup_schedules_on_merge_request_id ON merge_request_cleanup_schedules USING btree (merge_request_id); | ||||||
| 
 | 
 | ||||||
|  | CREATE INDEX index_merge_request_cleanup_schedules_on_status ON merge_request_cleanup_schedules USING btree (status); | ||||||
|  | 
 | ||||||
| CREATE UNIQUE INDEX index_merge_request_diff_commit_users_on_name_and_email ON merge_request_diff_commit_users USING btree (name, email); | CREATE UNIQUE INDEX index_merge_request_diff_commit_users_on_name_and_email ON merge_request_diff_commit_users USING btree (name, email); | ||||||
| 
 | 
 | ||||||
| CREATE INDEX index_merge_request_diff_commits_on_sha ON merge_request_diff_commits USING btree (sha); | CREATE INDEX index_merge_request_diff_commits_on_sha ON merge_request_diff_commits USING btree (sha); | ||||||
|  | @ -24120,7 +24128,7 @@ CREATE INDEX index_mirror_data_non_scheduled_or_started ON project_mirror_data U | ||||||
| 
 | 
 | ||||||
| CREATE UNIQUE INDEX index_mr_blocks_on_blocking_and_blocked_mr_ids ON merge_request_blocks USING btree (blocking_merge_request_id, blocked_merge_request_id); | CREATE UNIQUE INDEX index_mr_blocks_on_blocking_and_blocked_mr_ids ON merge_request_blocks USING btree (blocking_merge_request_id, blocked_merge_request_id); | ||||||
| 
 | 
 | ||||||
| CREATE INDEX index_mr_cleanup_schedules_timestamps ON merge_request_cleanup_schedules USING btree (scheduled_at) WHERE (completed_at IS NULL); | CREATE INDEX index_mr_cleanup_schedules_timestamps_status ON merge_request_cleanup_schedules USING btree (scheduled_at) WHERE ((completed_at IS NULL) AND (status = 0)); | ||||||
| 
 | 
 | ||||||
| CREATE UNIQUE INDEX index_mr_context_commits_on_merge_request_id_and_sha ON merge_request_context_commits USING btree (merge_request_id, sha); | CREATE UNIQUE INDEX index_mr_context_commits_on_merge_request_id_and_sha ON merge_request_context_commits USING btree (merge_request_id, sha); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
| After Width: | Height: | Size: 114 KiB | 
|  | @ -241,6 +241,26 @@ you can view a graph or download a CSV file with this data. From your project: | ||||||
| 
 | 
 | ||||||
| Code coverage data is also [available at the group level](../../user/group/repositories_analytics/index.md). | Code coverage data is also [available at the group level](../../user/group/repositories_analytics/index.md). | ||||||
| 
 | 
 | ||||||
|  | ### Coverage check approval rule **(PREMIUM)** | ||||||
|  | 
 | ||||||
|  | > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15765) in GitLab 14.0. | ||||||
|  | > - [Made configurable in Project Settings](https://gitlab.com/gitlab-org/gitlab/-/issues/331001) in GitLab 14.1. | ||||||
|  | 
 | ||||||
|  | You can implement merge request approvals to require approval by selected users or a group | ||||||
|  | when merging a merge request would cause the project's test coverage to decline. | ||||||
|  | 
 | ||||||
|  | Follow these steps to enable the `Coverage-Check` MR approval rule: | ||||||
|  | 
 | ||||||
|  | 1. Go to your project and select **Settings > General**. | ||||||
|  | 1. Expand **Merge request approvals**. | ||||||
|  | 1. Select **Enable** next to the `Coverage-Check` approval rule. | ||||||
|  | 1. Select the **Target branch**. | ||||||
|  | 1. Set the number of **Approvals required** to greater than zero. | ||||||
|  | 1. Select the users or groups to provide approval. | ||||||
|  | 1. Select **Add approval rule**. | ||||||
|  | 
 | ||||||
|  |  | ||||||
|  | 
 | ||||||
| ### Removing color codes | ### Removing color codes | ||||||
| 
 | 
 | ||||||
| Some test coverage tools output with ANSI color codes that aren't | Some test coverage tools output with ANSI color codes that aren't | ||||||
|  |  | ||||||
|  | @ -540,11 +540,11 @@ export default { | ||||||
|     foo: '' |     foo: '' | ||||||
|   }, |   }, | ||||||
|   actions: { |   actions: { | ||||||
|     updateBar() {...} |     updateBar() {...}, | ||||||
|     updateAll() {...} |     updateAll() {...}, | ||||||
|   }, |   }, | ||||||
|   getters: { |   getters: { | ||||||
|     getFoo() {...} |     getFoo() {...}, | ||||||
|   } |   } | ||||||
| } | } | ||||||
| ``` | ``` | ||||||
|  | @ -559,13 +559,13 @@ export default { | ||||||
|      * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter |      * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter | ||||||
|      * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action |      * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action | ||||||
|      * @param {string} defaultUpdateFn - the default function to dispatch |      * @param {string} defaultUpdateFn - the default function to dispatch | ||||||
|      * @param {string} root - optional key of the state where to search fo they keys described in list |      * @param {string|function} root - optional key of the state where to search for they keys described in list | ||||||
|      * @returns {Object} a dictionary with all the computed properties generated |      * @returns {Object} a dictionary with all the computed properties generated | ||||||
|     */ |     */ | ||||||
|     ...mapComputed( |     ...mapComputed( | ||||||
|       [ |       [ | ||||||
|         'baz', |         'baz', | ||||||
|         { key: 'bar', updateFn: 'updateBar' } |         { key: 'bar', updateFn: 'updateBar' }, | ||||||
|         { key: 'foo', getter: 'getFoo' }, |         { key: 'foo', getter: 'getFoo' }, | ||||||
|       ], |       ], | ||||||
|       'updateAll', |       'updateAll', | ||||||
|  | @ -575,3 +575,48 @@ export default { | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| `mapComputed` then generates the appropriate computed properties that get the data from the store and dispatch the correct action when updated. | `mapComputed` then generates the appropriate computed properties that get the data from the store and dispatch the correct action when updated. | ||||||
|  | 
 | ||||||
|  | In the event that the `root` of the key is more than one-level deep you can use a function to retrieve the relevant state object. | ||||||
|  | 
 | ||||||
|  | For instance, with a store like: | ||||||
|  | 
 | ||||||
|  | ```javascript | ||||||
|  | // this store is non-functional and only used to give context to the example | ||||||
|  | export default { | ||||||
|  |   state: { | ||||||
|  |     foo: { | ||||||
|  |       qux: { | ||||||
|  |         baz: '', | ||||||
|  |         bar: '', | ||||||
|  |         foo: '', | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   actions: { | ||||||
|  |     updateBar() {...}, | ||||||
|  |     updateAll() {...}, | ||||||
|  |   }, | ||||||
|  |   getters: { | ||||||
|  |     getFoo() {...}, | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | The `root` could be: | ||||||
|  | 
 | ||||||
|  | ```javascript | ||||||
|  | import { mapComputed } from '~/vuex_shared/bindings' | ||||||
|  | export default { | ||||||
|  |   computed: { | ||||||
|  |     ...mapComputed( | ||||||
|  |       [ | ||||||
|  |         'baz', | ||||||
|  |         { key: 'bar', updateFn: 'updateBar' }, | ||||||
|  |         { key: 'foo', getter: 'getFoo' }, | ||||||
|  |       ], | ||||||
|  |       'updateAll', | ||||||
|  |       (state) => state.foo.qux, | ||||||
|  |     ), | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | @ -4354,6 +4354,8 @@ The total count of Helm packages that have been published. | ||||||
| 
 | 
 | ||||||
| Group: `group::package` | Group: `group::package` | ||||||
| 
 | 
 | ||||||
|  | Data Category: `Optional` | ||||||
|  | 
 | ||||||
| Status: `implemented` | Status: `implemented` | ||||||
| 
 | 
 | ||||||
| Tiers: `free`, `premium`, `ultimate` | Tiers: `free`, `premium`, `ultimate` | ||||||
|  |  | ||||||
|  | @ -20,21 +20,22 @@ To see DevOps Report: | ||||||
| ## DevOps Score | ## DevOps Score | ||||||
| 
 | 
 | ||||||
| NOTE: | NOTE: | ||||||
| Your GitLab instance's [Service Ping](../settings/usage_statistics.md#service-ping) must be activated in order to use this feature. | To see the DevOps score, you must activate your GitLab instance's [Service Ping](../settings/usage_statistics.md#service-ping). | ||||||
|  | 
 | ||||||
|  | You can use the DevOps score to compare your DevOps status to other organizations. | ||||||
| 
 | 
 | ||||||
| The DevOps Score tab displays the usage of major GitLab features on your instance over | The DevOps Score tab displays the usage of major GitLab features on your instance over | ||||||
| the last 30 days, averaged over the number of billable users in that time period. It also | the last 30 days, averaged over the number of billable users in that time period. | ||||||
| provides a Lead score per feature, which is calculated based on GitLab analysis | You can also see the Leader usage score, calculated from top-performing instances based on | ||||||
| of top-performing instances based on [Service Ping data](../settings/usage_statistics.md#service-ping) that GitLab has | [Service Ping data](../settings/usage_statistics.md#service-ping) that GitLab has collected. | ||||||
| collected. Your score is compared to the lead score of each feature and then expressed as a percentage at the bottom of said feature. | Your score is compared to the lead score of each feature and then expressed | ||||||
| Your overall **DevOps Score** is an average of your feature scores. You can use this score to compare your DevOps status to other organizations. | as a percentage at the bottom of said feature. Your overall **DevOps Score** is an average of your | ||||||
| 
 | feature scores. | ||||||
| The page also provides helpful links to articles and GitLab docs, to help you |  | ||||||
| improve your scores. |  | ||||||
| 
 | 
 | ||||||
| Service Ping data is aggregated on GitLab servers for analysis. Your usage | Service Ping data is aggregated on GitLab servers for analysis. Your usage | ||||||
| information is **not sent** to any other GitLab instances. If you have just started using GitLab, it may take a few weeks for data to be | information is **not sent** to any other GitLab instances. | ||||||
| collected before this feature is available. | If you have just started using GitLab, it might take a few weeks for data to be collected before this | ||||||
|  | feature is available. | ||||||
| 
 | 
 | ||||||
| ## DevOps Adoption **(ULTIMATE SELF)** | ## DevOps Adoption **(ULTIMATE SELF)** | ||||||
| 
 | 
 | ||||||
|  | @ -46,7 +47,7 @@ collected before this feature is available. | ||||||
| > - The Overview tab [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/330401) in GitLab 14.1. | > - The Overview tab [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/330401) in GitLab 14.1. | ||||||
| > - DAST and SAST metrics [added](https://gitlab.com/gitlab-org/gitlab/-/issues/328033) in GitLab 14.1. | > - DAST and SAST metrics [added](https://gitlab.com/gitlab-org/gitlab/-/issues/328033) in GitLab 14.1. | ||||||
| 
 | 
 | ||||||
| DevOps Adoption shows you which groups within your organization are using the most essential features of GitLab: | DevOps Adoption shows you which groups in your organization are using the most essential features of GitLab: | ||||||
| 
 | 
 | ||||||
| - Dev | - Dev | ||||||
|   - Approvals |   - Approvals | ||||||
|  | @ -62,8 +63,7 @@ DevOps Adoption shows you which groups within your organization are using the mo | ||||||
|   - Pipelines |   - Pipelines | ||||||
|   - Runners |   - Runners | ||||||
| 
 | 
 | ||||||
| When managing groups in the UI, you can add your groups with the **Add group to table** | To add your groups, in the top right-hand section the page, select **Add group to table**. | ||||||
| button, in the top right hand section the page. |  | ||||||
| 
 | 
 | ||||||
| DevOps Adoption allows you to: | DevOps Adoption allows you to: | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -25,10 +25,6 @@ The Compliance Dashboard shows only the latest MR on each project. | ||||||
| ## Merge request drawer | ## Merge request drawer | ||||||
| 
 | 
 | ||||||
| > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299357) in GitLab 14.1. | > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299357) in GitLab 14.1. | ||||||
| > - It's [deployed behind a feature flag](../../feature_flags.md), disabled by default. |  | ||||||
| > - It's disabled on GitLab.com. |  | ||||||
| > - It's not recommended for production use. |  | ||||||
| > - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-merge-request-drawer). |  | ||||||
| 
 | 
 | ||||||
| When you click on a row, a drawer is shown that provides further details about the merge | When you click on a row, a drawer is shown that provides further details about the merge | ||||||
| request: | request: | ||||||
|  | @ -104,28 +100,3 @@ the dropdown next to the **List of all merge commits** button at the top of the | ||||||
| NOTE: | NOTE: | ||||||
| The Chain of Custody report download is a CSV file, with a maximum size of 15 MB. | The Chain of Custody report download is a CSV file, with a maximum size of 15 MB. | ||||||
| The remaining records are truncated when this limit is reached. | The remaining records are truncated when this limit is reached. | ||||||
| 
 |  | ||||||
| ## Enable or disable merge request drawer **(ULTIMATE SELF)** |  | ||||||
| 
 |  | ||||||
| The merge request drawer is under development and not ready for production use. It is |  | ||||||
| deployed behind a feature flag that is **disabled by default**. |  | ||||||
| [GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) |  | ||||||
| can enable it. |  | ||||||
| 
 |  | ||||||
| To enable it: |  | ||||||
| 
 |  | ||||||
| ```ruby |  | ||||||
| # For the instance |  | ||||||
| Feature.enable(:compliance_dashboard_drawer) |  | ||||||
| # For a single group |  | ||||||
| Feature.enable(:compliance_dashboard_drawer, Group.find(<group id>)) |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| To disable it: |  | ||||||
| 
 |  | ||||||
| ```ruby |  | ||||||
| # For the instance |  | ||||||
| Feature.disable(:compliance_dashboard_drawer) |  | ||||||
| # For a single group |  | ||||||
| Feature.disable(:compliance_dashboard_drawer, Group.find(<group id>) |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| Before Width: | Height: | Size: 22 KiB | 
| After Width: | Height: | Size: 24 KiB | 
| Before Width: | Height: | Size: 38 KiB | 
| After Width: | Height: | Size: 42 KiB | 
|  | @ -110,7 +110,7 @@ on an existing group's page. | ||||||
| 
 | 
 | ||||||
| 1. On the New Group page, select **Import group**. | 1. On the New Group page, select **Import group**. | ||||||
| 
 | 
 | ||||||
|     |     | ||||||
| 
 | 
 | ||||||
| 1. Fill in source URL of your GitLab. | 1. Fill in source URL of your GitLab. | ||||||
| 1. Fill in [personal access token](../../../user/profile/personal_access_tokens.md) for remote GitLab instance. | 1. Fill in [personal access token](../../../user/profile/personal_access_tokens.md) for remote GitLab instance. | ||||||
|  | @ -129,4 +129,4 @@ Migration importer page. Listed are the remote GitLab groups to which you have t | ||||||
| 
 | 
 | ||||||
| 1. Once a group has been imported, click its GitLab path to open its GitLab URL. | 1. Once a group has been imported, click its GitLab path to open its GitLab URL. | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  |  | ||||||
| Before Width: | Height: | Size: 23 KiB | 
| After Width: | Height: | Size: 42 KiB | 
|  | @ -93,9 +93,9 @@ on an existing group's page. | ||||||
| 
 | 
 | ||||||
|     |     | ||||||
| 
 | 
 | ||||||
| 1. On the New Group page, select the **Import group** tab. | 1. On the New Group page, select the **Import group**. | ||||||
| 
 | 
 | ||||||
|     |     | ||||||
| 
 | 
 | ||||||
| 1. Enter your group name. | 1. Enter your group name. | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -104,6 +104,7 @@ Without the approvals, the work cannot merge. Required approvals enable multiple | ||||||
|   database, for all proposed code changes. |   database, for all proposed code changes. | ||||||
| - Use the [code owners of changed files](rules.md#code-owners-as-eligible-approvers), | - Use the [code owners of changed files](rules.md#code-owners-as-eligible-approvers), | ||||||
|   to determine who should review the work. |   to determine who should review the work. | ||||||
|  | - Require an [approval before merging code that causes test coverage to decline](../../../../ci/pipelines/settings.md#coverage-check-approval-rule) | ||||||
| - [Require approval from a security team](../../../application_security/index.md#security-approvals-in-merge-requests) | - [Require approval from a security team](../../../application_security/index.md#security-approvals-in-merge-requests) | ||||||
|   before merging code that could introduce a vulnerability. **(ULTIMATE)** |   before merging code that could introduce a vulnerability. **(ULTIMATE)** | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -152,6 +152,20 @@ renames a Git repository's (`example`) default branch. | ||||||
| 1. Update references to the old branch name in related code and scripts that reside outside | 1. Update references to the old branch name in related code and scripts that reside outside | ||||||
|    your repository, such as helper utilities and integrations. |    your repository, such as helper utilities and integrations. | ||||||
| 
 | 
 | ||||||
|  | ## Default branch rename redirect | ||||||
|  | 
 | ||||||
|  | > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/329100) in GitLab 14.1 | ||||||
|  | 
 | ||||||
|  | URLs for specific files or directories in a project embed the project's default | ||||||
|  | branch name, and are often found in documentation or browser bookmarks. When you | ||||||
|  | [update the default branch name in your repository](#update-the-default-branch-name-in-your-repository), | ||||||
|  | these URLs change, and must be updated. | ||||||
|  | 
 | ||||||
|  | To ease the transition period, whenever the default branch for a project is | ||||||
|  | changed, GitLab records the name of the old default branch. If that branch is | ||||||
|  | deleted, attempts to view a file or directory on it are redirected to the | ||||||
|  | current default branch, instead of displaying the "not found" page. | ||||||
|  | 
 | ||||||
| ## Resources | ## Resources | ||||||
| 
 | 
 | ||||||
| - [Discussion of default branch renaming](https://lore.kernel.org/git/pull.656.v4.git.1593009996.gitgitgadget@gmail.com/) | - [Discussion of default branch renaming](https://lore.kernel.org/git/pull.656.v4.git.1593009996.gitgitgadget@gmail.com/) | ||||||
|  |  | ||||||
|  | @ -26,17 +26,17 @@ module ExtractsPath | ||||||
|   # Automatically renders `not_found!` if a valid tree path could not be |   # Automatically renders `not_found!` if a valid tree path could not be | ||||||
|   # resolved (e.g., when a user inserts an invalid path or ref). |   # resolved (e.g., when a user inserts an invalid path or ref). | ||||||
|   # |   # | ||||||
|  |   # Automatically redirects to the current default branch if the ref matches a | ||||||
|  |   # previous default branch that has subsequently been deleted. | ||||||
|  |   # | ||||||
|   # rubocop:disable Gitlab/ModuleWithInstanceVariables |   # rubocop:disable Gitlab/ModuleWithInstanceVariables | ||||||
|   override :assign_ref_vars |   override :assign_ref_vars | ||||||
|   def assign_ref_vars |   def assign_ref_vars | ||||||
|     super |     super | ||||||
| 
 | 
 | ||||||
|     if @path.empty? && !@commit && @id.ends_with?('.atom') |     rectify_atom! | ||||||
|       @id = @ref = extract_ref_without_atom(@id) |  | ||||||
|       @commit = @repo.commit(@ref) |  | ||||||
| 
 | 
 | ||||||
|       request.format = :atom if @commit |     rectify_renamed_default_branch! && return | ||||||
|     end |  | ||||||
| 
 | 
 | ||||||
|     raise InvalidPathError unless @commit |     raise InvalidPathError unless @commit | ||||||
| 
 | 
 | ||||||
|  | @ -59,6 +59,42 @@ module ExtractsPath | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|  |   # Override in controllers to determine which actions are subject to the redirect | ||||||
|  |   def redirect_renamed_default_branch? | ||||||
|  |     false | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   # rubocop:disable Gitlab/ModuleWithInstanceVariables | ||||||
|  |   def rectify_atom! | ||||||
|  |     return if @commit | ||||||
|  |     return unless @id.ends_with?('.atom') | ||||||
|  |     return unless @path.empty? | ||||||
|  | 
 | ||||||
|  |     @id = @ref = extract_ref_without_atom(@id) | ||||||
|  |     @commit = @repo.commit(@ref) | ||||||
|  | 
 | ||||||
|  |     request.format = :atom if @commit | ||||||
|  |   end | ||||||
|  |   # rubocop:enable Gitlab/ModuleWithInstanceVariables | ||||||
|  | 
 | ||||||
|  |   # For GET/HEAD requests, if the ref doesn't exist in the repository, check | ||||||
|  |   # whether we're trying to access a renamed default branch. If we are, we can | ||||||
|  |   # redirect to the current default branch instead of rendering a 404. | ||||||
|  |   # rubocop:disable Gitlab/ModuleWithInstanceVariables | ||||||
|  |   def rectify_renamed_default_branch! | ||||||
|  |     return unless redirect_renamed_default_branch? | ||||||
|  |     return if @commit | ||||||
|  |     return unless @id && @ref && repository_container.respond_to?(:previous_default_branch) | ||||||
|  |     return unless repository_container.previous_default_branch == @ref | ||||||
|  |     return unless request.get? || request.head? | ||||||
|  | 
 | ||||||
|  |     flash[:notice] = _('The default branch for this project has been changed. Please update your bookmarks.') | ||||||
|  |     redirect_to url_for(id: @id.sub(/\A#{Regexp.escape(@ref)}/, repository_container.default_branch)) | ||||||
|  | 
 | ||||||
|  |     true | ||||||
|  |   end | ||||||
|  |   # rubocop:enable Gitlab/ModuleWithInstanceVariables | ||||||
|  | 
 | ||||||
|   override :repository_container |   override :repository_container | ||||||
|   def repository_container |   def repository_container | ||||||
|     @project |     @project | ||||||
|  |  | ||||||
|  | @ -15,6 +15,10 @@ module Gitlab | ||||||
|           :updated_at_asc |           :updated_at_asc | ||||||
|         when %w[updated_at desc], [nil, 'updated_desc'] |         when %w[updated_at desc], [nil, 'updated_desc'] | ||||||
|           :updated_at_desc |           :updated_at_desc | ||||||
|  |         when %w[popularity asc], [nil, 'popularity_asc'] | ||||||
|  |           :popularity_asc | ||||||
|  |         when %w[popularity desc], [nil, 'popularity_desc'] | ||||||
|  |           :popularity_desc | ||||||
|         else |         else | ||||||
|           :unknown |           :unknown | ||||||
|         end |         end | ||||||
|  |  | ||||||
|  | @ -7,6 +7,11 @@ module Gitlab | ||||||
|     DEFAULT_PAGE = 1 |     DEFAULT_PAGE = 1 | ||||||
|     DEFAULT_PER_PAGE = 20 |     DEFAULT_PER_PAGE = 20 | ||||||
| 
 | 
 | ||||||
|  |     SCOPE_ONLY_SORT = { | ||||||
|  |       popularity_asc: %w[issues], | ||||||
|  |       popularity_desc: %w[issues] | ||||||
|  |     }.freeze | ||||||
|  | 
 | ||||||
|     attr_reader :current_user, :query, :order_by, :sort, :filters |     attr_reader :current_user, :query, :order_by, :sort, :filters | ||||||
| 
 | 
 | ||||||
|     # Limit search results by passed projects |     # Limit search results by passed projects | ||||||
|  | @ -128,20 +133,29 @@ module Gitlab | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     # rubocop: disable CodeReuse/ActiveRecord |     # rubocop: disable CodeReuse/ActiveRecord | ||||||
|     def apply_sort(scope) |     def apply_sort(results, scope: nil) | ||||||
|       # Due to different uses of sort param we prefer order_by when |       # Due to different uses of sort param we prefer order_by when | ||||||
|       # present |       # present | ||||||
|       case ::Gitlab::Search::SortOptions.sort_and_direction(order_by, sort) |       sort_by = ::Gitlab::Search::SortOptions.sort_and_direction(order_by, sort) | ||||||
|  | 
 | ||||||
|  |       # Reset sort to default if the chosen one is not supported by scope | ||||||
|  |       sort_by = nil if SCOPE_ONLY_SORT[sort_by] && !SCOPE_ONLY_SORT[sort_by].include?(scope) | ||||||
|  | 
 | ||||||
|  |       case sort_by | ||||||
|       when :created_at_asc |       when :created_at_asc | ||||||
|         scope.reorder('created_at ASC') |         results.reorder('created_at ASC') | ||||||
|       when :created_at_desc |       when :created_at_desc | ||||||
|         scope.reorder('created_at DESC') |         results.reorder('created_at DESC') | ||||||
|       when :updated_at_asc |       when :updated_at_asc | ||||||
|         scope.reorder('updated_at ASC') |         results.reorder('updated_at ASC') | ||||||
|       when :updated_at_desc |       when :updated_at_desc | ||||||
|         scope.reorder('updated_at DESC') |         results.reorder('updated_at DESC') | ||||||
|  |       when :popularity_asc | ||||||
|  |         results.reorder('upvotes_count ASC') | ||||||
|  |       when :popularity_desc | ||||||
|  |         results.reorder('upvotes_count DESC') | ||||||
|       else |       else | ||||||
|         scope.reorder('created_at DESC') |         results.reorder('created_at DESC') | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|     # rubocop: enable CodeReuse/ActiveRecord |     # rubocop: enable CodeReuse/ActiveRecord | ||||||
|  | @ -157,7 +171,7 @@ module Gitlab | ||||||
|         issues = issues.where(project_id: project_ids_relation) # rubocop: disable CodeReuse/ActiveRecord |         issues = issues.where(project_id: project_ids_relation) # rubocop: disable CodeReuse/ActiveRecord | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       apply_sort(issues) |       apply_sort(issues, scope: 'issues') | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     # rubocop: disable CodeReuse/ActiveRecord |     # rubocop: disable CodeReuse/ActiveRecord | ||||||
|  | @ -177,7 +191,7 @@ module Gitlab | ||||||
|         merge_requests = merge_requests.in_projects(project_ids_relation) |         merge_requests = merge_requests.in_projects(project_ids_relation) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       apply_sort(merge_requests) |       apply_sort(merge_requests, scope: 'merge_requests') | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def default_scope |     def default_scope | ||||||
|  |  | ||||||
|  | @ -1459,12 +1459,6 @@ msgstr "" | ||||||
| msgid "A member of the abuse team will review your report as soon as possible." | msgid "A member of the abuse team will review your report as soon as possible." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "A merge request approval is required when a security report contains a new vulnerability of high, critical, or unknown severity." |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "A merge request approval is required when the license compliance report contains a denied license." |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "A merge request hasn't yet been merged" | msgid "A merge request hasn't yet been merged" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -5681,6 +5675,9 @@ msgstr "" | ||||||
| msgid "BulkImport|Existing groups" | msgid "BulkImport|Existing groups" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "BulkImport|Filter by source group" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "BulkImport|From source group" | msgid "BulkImport|From source group" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -19290,15 +19287,9 @@ msgstr "" | ||||||
| msgid "Learn more about Auto DevOps" | msgid "Learn more about Auto DevOps" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Learn more about License-Check" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "Learn more about Needs relationships" | msgid "Learn more about Needs relationships" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Learn more about Vulnerability-Check" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "Learn more about Web Terminal" | msgid "Learn more about Web Terminal" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -19494,9 +19485,6 @@ msgstr "" | ||||||
| msgid "License overview" | msgid "License overview" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "License-Check" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are active" | msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are active" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -28931,18 +28919,51 @@ msgstr "" | ||||||
| msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})" | msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "SecurityApprovals|A merge request approval is required when a security report contains a new vulnerability of high, critical, or unknown severity." | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | msgid "SecurityApprovals|A merge request approval is required when test coverage declines." | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | msgid "SecurityApprovals|A merge request approval is required when the license compliance report contains a denied license." | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "SecurityApprovals|Configurable if security scanners are enabled. %{linkStart}Learn more.%{linkEnd}" | msgid "SecurityApprovals|Configurable if security scanners are enabled. %{linkStart}Learn more.%{linkEnd}" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "SecurityApprovals|Coverage-Check" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | msgid "SecurityApprovals|Learn more about Coverage-Check" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | msgid "SecurityApprovals|Learn more about License-Check" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | msgid "SecurityApprovals|Learn more about Vulnerability-Check" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "SecurityApprovals|License Scanning must be enabled. %{linkStart}Learn more%{linkEnd}." | msgid "SecurityApprovals|License Scanning must be enabled. %{linkStart}Learn more%{linkEnd}." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "SecurityApprovals|License-Check" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "SecurityApprovals|Requires approval for Denied licenses. %{linkStart}More information%{linkEnd}" | msgid "SecurityApprovals|Requires approval for Denied licenses. %{linkStart}More information%{linkEnd}" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "SecurityApprovals|Requires approval for decreases in test coverage. %{linkStart}More information%{linkEnd}" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "SecurityApprovals|Requires approval for vulnerabilities of Critical, High, or Unknown severity. %{linkStart}Learn more.%{linkEnd}" | msgid "SecurityApprovals|Requires approval for vulnerabilities of Critical, High, or Unknown severity. %{linkStart}Learn more.%{linkEnd}" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "SecurityApprovals|Test coverage must be enabled. %{linkStart}Learn more%{linkEnd}." | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
|  | msgid "SecurityApprovals|Vulnerability-Check" | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "SecurityConfiguration|%{featureName} merge request creation mutation failed" | msgid "SecurityConfiguration|%{featureName} merge request creation mutation failed" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -29765,7 +29786,7 @@ msgstr "" | ||||||
| msgid "Service URL" | msgid "Service URL" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Service ping is disabled, and cannot be configured through this form." | msgid "Service ping is disabled in your configuration file, and cannot be enabled through this form." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "ServiceDesk|Enable Service Desk" | msgid "ServiceDesk|Enable Service Desk" | ||||||
|  | @ -32554,6 +32575,9 @@ msgstr "" | ||||||
| msgid "The default CI/CD configuration file and path for new projects." | msgid "The default CI/CD configuration file and path for new projects." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | msgid "The default branch for this project has been changed. Please update your bookmarks." | ||||||
|  | msgstr "" | ||||||
|  | 
 | ||||||
| msgid "The dependency list details information about the components used within your project." | msgid "The dependency list details information about the components used within your project." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -36400,9 +36424,6 @@ msgstr "" | ||||||
| msgid "Vulnerability resolved in the default branch" | msgid "Vulnerability resolved in the default branch" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Vulnerability-Check" |  | ||||||
| msgstr "" |  | ||||||
| 
 |  | ||||||
| msgid "VulnerabilityChart|%{formattedStartDate} to today" | msgid "VulnerabilityChart|%{formattedStartDate} to today" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
|  | @ -37923,7 +37944,7 @@ msgstr "" | ||||||
| msgid "Your CSV import for project" | msgid "Your CSV import for project" | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers." | msgid "Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. Use it to view how you compare with other organizations." | ||||||
| msgstr "" | msgstr "" | ||||||
| 
 | 
 | ||||||
| msgid "Your GPG keys (%{count})" | msgid "Your GPG keys (%{count})" | ||||||
|  |  | ||||||
|  | @ -5,7 +5,8 @@ require 'spec_helper' | ||||||
| RSpec.describe Projects::BlobController do | RSpec.describe Projects::BlobController do | ||||||
|   include ProjectForksHelper |   include ProjectForksHelper | ||||||
| 
 | 
 | ||||||
|   let(:project) { create(:project, :public, :repository) } |   let(:project) { create(:project, :public, :repository, previous_default_branch: previous_default_branch) } | ||||||
|  |   let(:previous_default_branch) { nil } | ||||||
| 
 | 
 | ||||||
|   describe "GET show" do |   describe "GET show" do | ||||||
|     def request |     def request | ||||||
|  | @ -42,6 +43,20 @@ RSpec.describe Projects::BlobController do | ||||||
|         it { is_expected.to respond_with(:not_found) } |         it { is_expected.to respond_with(:not_found) } | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  |       context "renamed default branch, valid file" do | ||||||
|  |         let(:id) { 'old-default-branch/README.md' } | ||||||
|  |         let(:previous_default_branch) { 'old-default-branch' } | ||||||
|  | 
 | ||||||
|  |         it { is_expected.to redirect_to("/#{project.full_path}/-/blob/#{project.default_branch}/README.md") } | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context "renamed default branch, invalid file" do | ||||||
|  |         let(:id) { 'old-default-branch/invalid-path.rb' } | ||||||
|  |         let(:previous_default_branch) { 'old-default-branch' } | ||||||
|  | 
 | ||||||
|  |         it { is_expected.to redirect_to("/#{project.full_path}/-/blob/#{project.default_branch}/invalid-path.rb") } | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|       context "binary file" do |       context "binary file" do | ||||||
|         let(:id) { 'binary-encoding/encoding/binary-1.bin' } |         let(:id) { 'binary-encoding/encoding/binary-1.bin' } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3,8 +3,9 @@ | ||||||
| require 'spec_helper' | require 'spec_helper' | ||||||
| 
 | 
 | ||||||
| RSpec.describe Projects::TreeController do | RSpec.describe Projects::TreeController do | ||||||
|   let(:project) { create(:project, :repository) } |   let(:project) { create(:project, :repository, previous_default_branch: previous_default_branch) } | ||||||
|   let(:user)    { create(:user) } |   let(:previous_default_branch) { nil } | ||||||
|  |   let(:user) { create(:user) } | ||||||
| 
 | 
 | ||||||
|   before do |   before do | ||||||
|     sign_in(user) |     sign_in(user) | ||||||
|  | @ -55,6 +56,20 @@ RSpec.describe Projects::TreeController do | ||||||
|       it { is_expected.to respond_with(:not_found) } |       it { is_expected.to respond_with(:not_found) } | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     context "renamed default branch, valid file" do | ||||||
|  |       let(:id) { 'old-default-branch/encoding/' } | ||||||
|  |       let(:previous_default_branch) { 'old-default-branch' } | ||||||
|  | 
 | ||||||
|  |       it { is_expected.to redirect_to("/#{project.full_path}/-/tree/#{project.default_branch}/encoding/") } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context "renamed default branch, invalid file" do | ||||||
|  |       let(:id) { 'old-default-branch/invalid-path/' } | ||||||
|  |       let(:previous_default_branch) { 'old-default-branch' } | ||||||
|  | 
 | ||||||
|  |       it { is_expected.to redirect_to("/#{project.full_path}/-/tree/#{project.default_branch}/invalid-path/") } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     context "valid empty branch, invalid path" do |     context "valid empty branch, invalid path" do | ||||||
|       let(:id) { 'empty-branch/invalid-path/' } |       let(:id) { 'empty-branch/invalid-path/' } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3,6 +3,19 @@ | ||||||
| FactoryBot.define do | FactoryBot.define do | ||||||
|   factory :merge_request_cleanup_schedule, class: 'MergeRequest::CleanupSchedule' do |   factory :merge_request_cleanup_schedule, class: 'MergeRequest::CleanupSchedule' do | ||||||
|     merge_request |     merge_request | ||||||
|     scheduled_at { Time.current } |     scheduled_at { 1.day.ago } | ||||||
|  | 
 | ||||||
|  |     trait :running do | ||||||
|  |       status { MergeRequest::CleanupSchedule::STATUSES[:running] } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     trait :completed do | ||||||
|  |       status { MergeRequest::CleanupSchedule::STATUSES[:completed] } | ||||||
|  |       completed_at { Time.current } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     trait :failed do | ||||||
|  |       status { MergeRequest::CleanupSchedule::STATUSES[:failed] } | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -12,7 +12,7 @@ milestone: "13.9" | ||||||
| introduced_by_url: | introduced_by_url: | ||||||
| time_frame: 7d | time_frame: 7d | ||||||
| data_source: | data_source: | ||||||
| data_category: Operational | data_category: Optional | ||||||
| distribution: | distribution: | ||||||
| - ee | - ee | ||||||
| tier: | tier: | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ milestone: "13.9" | ||||||
| introduced_by_url: | introduced_by_url: | ||||||
| time_frame: 7d | time_frame: 7d | ||||||
| data_source: | data_source: | ||||||
| data_category: Operational | data_category: Optional | ||||||
| distribution: | distribution: | ||||||
| - ce | - ce | ||||||
| - ee | - ee | ||||||
|  |  | ||||||
|  | @ -1,8 +1,8 @@ | ||||||
| import { GlButton } from '@gitlab/ui'; | import { GlButton } from '@gitlab/ui'; | ||||||
| import { shallowMount } from '@vue/test-utils'; | import { shallowMount } from '@vue/test-utils'; | ||||||
| import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; | import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; | ||||||
| 
 |  | ||||||
| import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; | import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; | ||||||
|  | import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; | ||||||
| import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; | import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; | ||||||
| 
 | 
 | ||||||
| const DEFAULT_PROPS = { | const DEFAULT_PROPS = { | ||||||
|  | @ -10,6 +10,8 @@ const DEFAULT_PROPS = { | ||||||
|   path: 'some/path', |   path: 'some/path', | ||||||
|   canPushCode: true, |   canPushCode: true, | ||||||
|   replacePath: 'some/replace/path', |   replacePath: 'some/replace/path', | ||||||
|  |   deletePath: 'some/delete/path', | ||||||
|  |   emptyRepo: false, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const DEFAULT_INJECT = { | const DEFAULT_INJECT = { | ||||||
|  | @ -39,6 +41,7 @@ describe('BlobButtonGroup component', () => { | ||||||
|     wrapper.destroy(); |     wrapper.destroy(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |   const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); | ||||||
|   const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); |   const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); | ||||||
|   const findReplaceButton = () => wrapper.findAll(GlButton).at(0); |   const findReplaceButton = () => wrapper.findAll(GlButton).at(0); | ||||||
| 
 | 
 | ||||||
|  | @ -93,4 +96,22 @@ describe('BlobButtonGroup component', () => { | ||||||
|       primaryBtnText: 'Replace file', |       primaryBtnText: 'Replace file', | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  | 
 | ||||||
|  |   it('renders DeleteBlobModel', () => { | ||||||
|  |     createComponent(); | ||||||
|  | 
 | ||||||
|  |     const { targetBranch, originalBranch } = DEFAULT_INJECT; | ||||||
|  |     const { name, canPushCode, deletePath, emptyRepo } = DEFAULT_PROPS; | ||||||
|  |     const title = `Delete ${name}`; | ||||||
|  | 
 | ||||||
|  |     expect(findDeleteBlobModal().props()).toMatchObject({ | ||||||
|  |       modalTitle: title, | ||||||
|  |       commitMessage: title, | ||||||
|  |       targetBranch, | ||||||
|  |       originalBranch, | ||||||
|  |       canPushCode, | ||||||
|  |       deletePath, | ||||||
|  |       emptyRepo, | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -58,23 +58,36 @@ const richMockData = { | ||||||
|     renderError: null, |     renderError: null, | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| const userPermissionsMockData = { | 
 | ||||||
|  | const projectMockData = { | ||||||
|   userPermissions: { |   userPermissions: { | ||||||
|     pushCode: true, |     pushCode: true, | ||||||
|   }, |   }, | ||||||
|  |   repository: { | ||||||
|  |     empty: false, | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const localVue = createLocalVue(); | const localVue = createLocalVue(); | ||||||
| const mockAxios = new MockAdapter(axios); | const mockAxios = new MockAdapter(axios); | ||||||
| 
 | 
 | ||||||
| const createComponentWithApollo = (mockData, mockPermissionData = true) => { | const createComponentWithApollo = (mockData = {}) => { | ||||||
|   localVue.use(VueApollo); |   localVue.use(VueApollo); | ||||||
| 
 | 
 | ||||||
|  |   const defaultPushCode = projectMockData.userPermissions.pushCode; | ||||||
|  |   const defaultEmptyRepo = projectMockData.repository.empty; | ||||||
|  |   const { blobs, emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode } = mockData; | ||||||
|  | 
 | ||||||
|   const mockResolver = jest.fn().mockResolvedValue({ |   const mockResolver = jest.fn().mockResolvedValue({ | ||||||
|     data: { |     data: { | ||||||
|       project: { |       project: { | ||||||
|         userPermissions: { pushCode: mockPermissionData }, |         userPermissions: { pushCode: canPushCode }, | ||||||
|         repository: { blobs: { nodes: [mockData] } }, |         repository: { | ||||||
|  |           empty: emptyRepo, | ||||||
|  |           blobs: { | ||||||
|  |             nodes: [blobs], | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  | @ -209,14 +222,14 @@ describe('Blob content viewer component', () => { | ||||||
| 
 | 
 | ||||||
|   describe('legacy viewers', () => { |   describe('legacy viewers', () => { | ||||||
|     it('does not load a legacy viewer when a rich viewer is not available', async () => { |     it('does not load a legacy viewer when a rich viewer is not available', async () => { | ||||||
|       createComponentWithApollo(simpleMockData); |       createComponentWithApollo({ blobs: simpleMockData }); | ||||||
|       await waitForPromises(); |       await waitForPromises(); | ||||||
| 
 | 
 | ||||||
|       expect(mockAxios.history.get).toHaveLength(0); |       expect(mockAxios.history.get).toHaveLength(0); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('loads a legacy viewer when a rich viewer is available', async () => { |     it('loads a legacy viewer when a rich viewer is available', async () => { | ||||||
|       createComponentWithApollo(richMockData); |       createComponentWithApollo({ blobs: richMockData }); | ||||||
|       await waitForPromises(); |       await waitForPromises(); | ||||||
| 
 | 
 | ||||||
|       expect(mockAxios.history.get).toHaveLength(1); |       expect(mockAxios.history.get).toHaveLength(1); | ||||||
|  | @ -320,16 +333,20 @@ describe('Blob content viewer component', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     describe('BlobButtonGroup', () => { |     describe('BlobButtonGroup', () => { | ||||||
|       const { name, path, replacePath } = simpleMockData; |       const { name, path, replacePath, webPath } = simpleMockData; | ||||||
|       const { |       const { | ||||||
|         userPermissions: { pushCode }, |         userPermissions: { pushCode }, | ||||||
|       } = userPermissionsMockData; |         repository: { empty }, | ||||||
|  |       } = projectMockData; | ||||||
| 
 | 
 | ||||||
|       it('renders component', async () => { |       it('renders component', async () => { | ||||||
|         window.gon.current_user_id = 1; |         window.gon.current_user_id = 1; | ||||||
| 
 | 
 | ||||||
|         fullFactory({ |         fullFactory({ | ||||||
|           mockData: { blobInfo: simpleMockData, project: userPermissionsMockData }, |           mockData: { | ||||||
|  |             blobInfo: simpleMockData, | ||||||
|  |             project: { userPermissions: { pushCode }, repository: { empty } }, | ||||||
|  |           }, | ||||||
|           stubs: { |           stubs: { | ||||||
|             BlobContent: true, |             BlobContent: true, | ||||||
|             BlobButtonGroup: true, |             BlobButtonGroup: true, | ||||||
|  | @ -342,7 +359,9 @@ describe('Blob content viewer component', () => { | ||||||
|           name, |           name, | ||||||
|           path, |           path, | ||||||
|           replacePath, |           replacePath, | ||||||
|  |           deletePath: webPath, | ||||||
|           canPushCode: pushCode, |           canPushCode: pushCode, | ||||||
|  |           emptyRepo: empty, | ||||||
|         }); |         }); | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,130 @@ | ||||||
|  | import { GlFormTextarea, GlModal, GlFormInput, GlToggle } from '@gitlab/ui'; | ||||||
|  | import { shallowMount } from '@vue/test-utils'; | ||||||
|  | import { nextTick } from 'vue'; | ||||||
|  | import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; | ||||||
|  | 
 | ||||||
|  | jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); | ||||||
|  | 
 | ||||||
|  | const initialProps = { | ||||||
|  |   modalId: 'Delete-blob', | ||||||
|  |   modalTitle: 'Delete File', | ||||||
|  |   deletePath: 'some/path', | ||||||
|  |   commitMessage: 'Delete File', | ||||||
|  |   targetBranch: 'some-target-branch', | ||||||
|  |   originalBranch: 'main', | ||||||
|  |   canPushCode: true, | ||||||
|  |   emptyRepo: false, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | describe('DeleteBlobModal', () => { | ||||||
|  |   let wrapper; | ||||||
|  | 
 | ||||||
|  |   const createComponent = (props = {}) => { | ||||||
|  |     wrapper = shallowMount(DeleteBlobModal, { | ||||||
|  |       propsData: { | ||||||
|  |         ...initialProps, | ||||||
|  |         ...props, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const findModal = () => wrapper.findComponent(GlModal); | ||||||
|  |   const findForm = () => wrapper.findComponent({ ref: 'form' }); | ||||||
|  | 
 | ||||||
|  |   afterEach(() => { | ||||||
|  |     wrapper.destroy(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('renders Modal component', () => { | ||||||
|  |     createComponent(); | ||||||
|  | 
 | ||||||
|  |     const { modalTitle: title } = initialProps; | ||||||
|  | 
 | ||||||
|  |     expect(findModal().props()).toMatchObject({ | ||||||
|  |       title, | ||||||
|  |       size: 'md', | ||||||
|  |       actionPrimary: { | ||||||
|  |         text: 'Delete file', | ||||||
|  |       }, | ||||||
|  |       actionCancel: { | ||||||
|  |         text: 'Cancel', | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('form', () => { | ||||||
|  |     it('gets passed the path for action attribute', () => { | ||||||
|  |       createComponent(); | ||||||
|  |       expect(findForm().attributes('action')).toBe(initialProps.deletePath); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('submits the form', async () => { | ||||||
|  |       createComponent(); | ||||||
|  | 
 | ||||||
|  |       const submitSpy = jest.spyOn(findForm().element, 'submit'); | ||||||
|  |       findModal().vm.$emit('primary', { preventDefault: () => {} }); | ||||||
|  |       await nextTick(); | ||||||
|  | 
 | ||||||
|  |       expect(submitSpy).toHaveBeenCalled(); | ||||||
|  |       submitSpy.mockRestore(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it.each` | ||||||
|  |       component         | defaultValue                  | canPushCode | targetBranch                 | originalBranch                 | exist | ||||||
|  |       ${GlFormTextarea} | ${initialProps.commitMessage} | ${true}     | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} | ||||||
|  |       ${GlFormInput}    | ${initialProps.targetBranch}  | ${true}     | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} | ||||||
|  |       ${GlFormInput}    | ${undefined}                  | ${false}    | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${false} | ||||||
|  |       ${GlToggle}       | ${'true'}                     | ${true}     | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} | ||||||
|  |       ${GlToggle}       | ${undefined}                  | ${true}     | ${'same-branch'}             | ${'same-branch'}               | ${false} | ||||||
|  |     `(
 | ||||||
|  |       'has the correct form fields ', | ||||||
|  |       ({ component, defaultValue, canPushCode, targetBranch, originalBranch, exist }) => { | ||||||
|  |         createComponent({ | ||||||
|  |           canPushCode, | ||||||
|  |           targetBranch, | ||||||
|  |           originalBranch, | ||||||
|  |         }); | ||||||
|  |         const formField = wrapper.findComponent(component); | ||||||
|  | 
 | ||||||
|  |         if (!exist) { | ||||||
|  |           expect(formField.exists()).toBe(false); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         expect(formField.exists()).toBe(true); | ||||||
|  |         expect(formField.attributes('value')).toBe(defaultValue); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     it.each` | ||||||
|  |       input                     | value                          | emptyRepo | canPushCode | exist | ||||||
|  |       ${'authenticity_token'}   | ${'mock-csrf-token'}           | ${false}  | ${true}     | ${true} | ||||||
|  |       ${'authenticity_token'}   | ${'mock-csrf-token'}           | ${true}   | ${false}    | ${true} | ||||||
|  |       ${'_method'}              | ${'delete'}                    | ${false}  | ${true}     | ${true} | ||||||
|  |       ${'_method'}              | ${'delete'}                    | ${true}   | ${false}    | ${true} | ||||||
|  |       ${'original_branch'}      | ${initialProps.originalBranch} | ${false}  | ${true}     | ${true} | ||||||
|  |       ${'original_branch'}      | ${undefined}                   | ${true}   | ${true}     | ${false} | ||||||
|  |       ${'create_merge_request'} | ${'1'}                         | ${false}  | ${false}    | ${true} | ||||||
|  |       ${'create_merge_request'} | ${'1'}                         | ${false}  | ${true}     | ${true} | ||||||
|  |       ${'create_merge_request'} | ${undefined}                   | ${true}   | ${false}    | ${false} | ||||||
|  |     `(
 | ||||||
|  |       'passes $input as a hidden input with the correct value', | ||||||
|  |       ({ input, value, emptyRepo, canPushCode, exist }) => { | ||||||
|  |         createComponent({ | ||||||
|  |           emptyRepo, | ||||||
|  |           canPushCode, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         const inputMethod = findForm().find(`input[name="${input}"]`); | ||||||
|  | 
 | ||||||
|  |         if (!exist) { | ||||||
|  |           expect(inputMethod.exists()).toBe(false); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         expect(inputMethod.attributes('type')).toBe('hidden'); | ||||||
|  |         expect(inputMethod.attributes('value')).toBe(value); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | @ -3,7 +3,7 @@ import { mapComputed } from '~/vuex_shared/bindings'; | ||||||
| 
 | 
 | ||||||
| describe('Binding utils', () => { | describe('Binding utils', () => { | ||||||
|   describe('mapComputed', () => { |   describe('mapComputed', () => { | ||||||
|     const defaultArgs = [['baz'], 'bar', 'foo']; |     const defaultArgs = [['baz'], 'bar', 'foo', 'qux']; | ||||||
| 
 | 
 | ||||||
|     const createDummy = (mapComputedArgs = defaultArgs) => ({ |     const createDummy = (mapComputedArgs = defaultArgs) => ({ | ||||||
|       computed: { |       computed: { | ||||||
|  | @ -29,12 +29,18 @@ describe('Binding utils', () => { | ||||||
|       }, |       }, | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     it('returns an object with keys equal to the first fn parameter ', () => { |     it('returns an object with keys equal to the first fn parameter', () => { | ||||||
|       const keyList = ['foo1', 'foo2']; |       const keyList = ['foo1', 'foo2']; | ||||||
|       const result = mapComputed(keyList, 'foo', 'bar'); |       const result = mapComputed(keyList, 'foo', 'bar'); | ||||||
|       expect(Object.keys(result)).toEqual(keyList); |       expect(Object.keys(result)).toEqual(keyList); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     it('returns an object with keys equal to the first fn parameter when the root is a function', () => { | ||||||
|  |       const keyList = ['foo1', 'foo2']; | ||||||
|  |       const result = mapComputed(keyList, 'foo', (state) => state.bar); | ||||||
|  |       expect(Object.keys(result)).toEqual(keyList); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it('returned object has set and get function', () => { |     it('returned object has set and get function', () => { | ||||||
|       const result = mapComputed(['baz'], 'foo', 'bar'); |       const result = mapComputed(['baz'], 'foo', 'bar'); | ||||||
|       expect(result.baz.set).toBeDefined(); |       expect(result.baz.set).toBeDefined(); | ||||||
|  |  | ||||||
|  | @ -75,6 +75,13 @@ RSpec.describe ClustersHelper do | ||||||
|     it 'displays project path' do |     it 'displays project path' do | ||||||
|       expect(subject[:project_path]).to eq(project.full_path) |       expect(subject[:project_path]).to eq(project.full_path) | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     it 'generates docs urls' do | ||||||
|  |       expect(subject[:agent_docs_url]).to eq(help_page_path('user/clusters/agent/index')) | ||||||
|  |       expect(subject[:install_docs_url]).to eq(help_page_path('administration/clusters/kas')) | ||||||
|  |       expect(subject[:get_started_docs_url]).to eq(help_page_path('user/clusters/agent/index', anchor: 'define-a-configuration-repository')) | ||||||
|  |       expect(subject[:integration_docs_url]).to eq(help_page_path('user/clusters/agent/index', anchor: 'get-started-with-gitops-and-the-gitlab-agent')) | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '#js_clusters_list_data' do |   describe '#js_clusters_list_data' do | ||||||
|  |  | ||||||
|  | @ -7,10 +7,17 @@ RSpec.describe ExtractsPath do | ||||||
|   include RepoHelpers |   include RepoHelpers | ||||||
|   include Gitlab::Routing |   include Gitlab::Routing | ||||||
| 
 | 
 | ||||||
|  |   # Make url_for work | ||||||
|  |   def default_url_options | ||||||
|  |     { controller: 'projects/blob', action: 'show', namespace_id: @project.namespace.path, project_id: @project.path } | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   let_it_be(:owner) { create(:user) } |   let_it_be(:owner) { create(:user) } | ||||||
|   let_it_be(:container) { create(:project, :repository, creator: owner) } |   let_it_be(:container) { create(:project, :repository, creator: owner) } | ||||||
| 
 | 
 | ||||||
|   let(:request) { double('request') } |   let(:request) { double('request') } | ||||||
|  |   let(:flash) { {} } | ||||||
|  |   let(:redirect_renamed_default_branch?) { true } | ||||||
| 
 | 
 | ||||||
|   before do |   before do | ||||||
|     @project = container |     @project = container | ||||||
|  | @ -18,11 +25,14 @@ RSpec.describe ExtractsPath do | ||||||
| 
 | 
 | ||||||
|     allow(container.repository).to receive(:ref_names).and_return(ref_names) |     allow(container.repository).to receive(:ref_names).and_return(ref_names) | ||||||
|     allow(request).to receive(:format=) |     allow(request).to receive(:format=) | ||||||
|  |     allow(request).to receive(:get?) | ||||||
|  |     allow(request).to receive(:head?) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '#assign_ref_vars' do |   describe '#assign_ref_vars' do | ||||||
|     let(:ref) { sample_commit[:id] } |     let(:ref) { sample_commit[:id] } | ||||||
|     let(:params) { { path: sample_commit[:line_code_path], ref: ref } } |     let(:path) { sample_commit[:line_code_path] } | ||||||
|  |     let(:params) { { path: path, ref: ref } } | ||||||
| 
 | 
 | ||||||
|     it_behaves_like 'assigns ref vars' |     it_behaves_like 'assigns ref vars' | ||||||
| 
 | 
 | ||||||
|  | @ -126,6 +136,66 @@ RSpec.describe ExtractsPath do | ||||||
|         expect(@commit).to be_nil |         expect(@commit).to be_nil | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     context 'ref points to a previous default branch' do | ||||||
|  |       let(:ref) { 'develop' } | ||||||
|  | 
 | ||||||
|  |       before do | ||||||
|  |         @project.update!(previous_default_branch: ref) | ||||||
|  | 
 | ||||||
|  |         allow(@project).to receive(:default_branch).and_return('foo') | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'redirects to the new default branch for a GET request' do | ||||||
|  |         allow(request).to receive(:get?).and_return(true) | ||||||
|  | 
 | ||||||
|  |         expect(self).to receive(:redirect_to).with("http://localhost/#{@project.full_path}/-/blob/foo/#{path}") | ||||||
|  |         expect(self).not_to receive(:render_404) | ||||||
|  | 
 | ||||||
|  |         assign_ref_vars | ||||||
|  | 
 | ||||||
|  |         expect(@commit).to be_nil | ||||||
|  |         expect(flash[:notice]).to match(/default branch/) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'redirects to the new default branch for a HEAD request' do | ||||||
|  |         allow(request).to receive(:head?).and_return(true) | ||||||
|  | 
 | ||||||
|  |         expect(self).to receive(:redirect_to).with("http://localhost/#{@project.full_path}/-/blob/foo/#{path}") | ||||||
|  |         expect(self).not_to receive(:render_404) | ||||||
|  | 
 | ||||||
|  |         assign_ref_vars | ||||||
|  | 
 | ||||||
|  |         expect(@commit).to be_nil | ||||||
|  |         expect(flash[:notice]).to match(/default branch/) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'returns 404 for any other request type' do | ||||||
|  |         expect(self).not_to receive(:redirect_to) | ||||||
|  |         expect(self).to receive(:render_404) | ||||||
|  | 
 | ||||||
|  |         assign_ref_vars | ||||||
|  | 
 | ||||||
|  |         expect(@commit).to be_nil | ||||||
|  |         expect(flash).to be_empty | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'redirect behaviour is disabled' do | ||||||
|  |         let(:redirect_renamed_default_branch?) { false } | ||||||
|  | 
 | ||||||
|  |         it 'returns 404 for a GET request' do | ||||||
|  |           allow(request).to receive(:get?).and_return(true) | ||||||
|  | 
 | ||||||
|  |           expect(self).not_to receive(:redirect_to) | ||||||
|  |           expect(self).to receive(:render_404) | ||||||
|  | 
 | ||||||
|  |           assign_ref_vars | ||||||
|  | 
 | ||||||
|  |           expect(@commit).to be_nil | ||||||
|  |           expect(flash).to be_empty | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   it_behaves_like 'extracts refs' |   it_behaves_like 'extracts refs' | ||||||
|  |  | ||||||
|  | @ -229,10 +229,18 @@ RSpec.describe Gitlab::SearchResults do | ||||||
|         let!(:new_updated) { create(:issue, project: project, title: 'updated recent', updated_at: 1.day.ago) } |         let!(:new_updated) { create(:issue, project: project, title: 'updated recent', updated_at: 1.day.ago) } | ||||||
|         let!(:very_old_updated) { create(:issue, project: project, title: 'updated very old', updated_at: 1.year.ago) } |         let!(:very_old_updated) { create(:issue, project: project, title: 'updated very old', updated_at: 1.year.ago) } | ||||||
| 
 | 
 | ||||||
|  |         let!(:less_popular_result) { create(:issue, project: project, title: 'less popular', upvotes_count: 10) } | ||||||
|  |         let!(:popular_result) { create(:issue, project: project, title: 'popular', upvotes_count: 100) } | ||||||
|  |         let!(:non_popular_result) { create(:issue, project: project, title: 'non popular', upvotes_count: 1) } | ||||||
|  | 
 | ||||||
|         include_examples 'search results sorted' do |         include_examples 'search results sorted' do | ||||||
|           let(:results_created) { described_class.new(user, 'sorted', Project.order(:id), sort: sort, filters: filters) } |           let(:results_created) { described_class.new(user, 'sorted', Project.order(:id), sort: sort, filters: filters) } | ||||||
|           let(:results_updated) { described_class.new(user, 'updated', Project.order(:id), sort: sort, filters: filters) } |           let(:results_updated) { described_class.new(user, 'updated', Project.order(:id), sort: sort, filters: filters) } | ||||||
|         end |         end | ||||||
|  | 
 | ||||||
|  |         include_examples 'search results sorted by popularity' do | ||||||
|  |           let(:results_popular) { described_class.new(user, 'popular', Project.order(:id), sort: sort, filters: filters) } | ||||||
|  |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,22 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'spec_helper' | ||||||
|  | require_migration! | ||||||
|  | 
 | ||||||
|  | RSpec.describe AddUpvotesCountIndexToIssues do | ||||||
|  |   let(:migration_instance) { described_class.new } | ||||||
|  | 
 | ||||||
|  |   describe '#up' do | ||||||
|  |     it 'adds index' do | ||||||
|  |       expect { migrate! }.to change { migration_instance.index_exists?(:issues, [:project_id, :upvotes_count], name: described_class::INDEX_NAME) }.from(false).to(true) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '#down' do | ||||||
|  |     it 'removes index' do | ||||||
|  |       migrate! | ||||||
|  | 
 | ||||||
|  |       expect { schema_migrate_down! }.to change { migration_instance.index_exists?(:issues, [:project_id, :upvotes_count], name: described_class::INDEX_NAME) }.from(true).to(false) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -11,22 +11,125 @@ RSpec.describe MergeRequest::CleanupSchedule do | ||||||
|     it { is_expected.to validate_presence_of(:scheduled_at) } |     it { is_expected.to validate_presence_of(:scheduled_at) } | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '.scheduled_merge_request_ids' do |   describe 'state machine transitions' do | ||||||
|     let_it_be(:mr_cleanup_schedule_1) { create(:merge_request_cleanup_schedule, scheduled_at: 2.days.ago) } |     let(:cleanup_schedule) { create(:merge_request_cleanup_schedule) } | ||||||
|     let_it_be(:mr_cleanup_schedule_2) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.ago) } |  | ||||||
|     let_it_be(:mr_cleanup_schedule_3) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.ago, completed_at: Time.current) } |  | ||||||
|     let_it_be(:mr_cleanup_schedule_4) { create(:merge_request_cleanup_schedule, scheduled_at: 4.days.ago) } |  | ||||||
|     let_it_be(:mr_cleanup_schedule_5) { create(:merge_request_cleanup_schedule, scheduled_at: 3.days.ago) } |  | ||||||
|     let_it_be(:mr_cleanup_schedule_6) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.from_now) } |  | ||||||
|     let_it_be(:mr_cleanup_schedule_7) { create(:merge_request_cleanup_schedule, scheduled_at: 5.days.ago) } |  | ||||||
| 
 | 
 | ||||||
|     it 'only includes incomplete schedule within the specified limit' do |     it 'sets status to unstarted by default' do | ||||||
|       expect(described_class.scheduled_merge_request_ids(4)).to eq([ |       expect(cleanup_schedule).to be_unstarted | ||||||
|         mr_cleanup_schedule_2.merge_request_id, |     end | ||||||
|         mr_cleanup_schedule_1.merge_request_id, | 
 | ||||||
|         mr_cleanup_schedule_5.merge_request_id, |     describe '#run' do | ||||||
|         mr_cleanup_schedule_4.merge_request_id |       it 'sets the status to running' do | ||||||
|  |         cleanup_schedule.run | ||||||
|  | 
 | ||||||
|  |         expect(cleanup_schedule.reload).to be_running | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when previous status is not unstarted' do | ||||||
|  |         let(:cleanup_schedule) { create(:merge_request_cleanup_schedule, :running) } | ||||||
|  | 
 | ||||||
|  |         it 'does not change status' do | ||||||
|  |           expect { cleanup_schedule.run }.not_to change(cleanup_schedule, :status) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     describe '#retry' do | ||||||
|  |       let(:cleanup_schedule) { create(:merge_request_cleanup_schedule, :running) } | ||||||
|  | 
 | ||||||
|  |       it 'sets the status to unstarted' do | ||||||
|  |         cleanup_schedule.retry | ||||||
|  | 
 | ||||||
|  |         expect(cleanup_schedule.reload).to be_unstarted | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'increments failed_count' do | ||||||
|  |         expect { cleanup_schedule.retry }.to change(cleanup_schedule, :failed_count).by(1) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when previous status is not running' do | ||||||
|  |         let(:cleanup_schedule) { create(:merge_request_cleanup_schedule) } | ||||||
|  | 
 | ||||||
|  |         it 'does not change status' do | ||||||
|  |           expect { cleanup_schedule.retry }.not_to change(cleanup_schedule, :status) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     describe '#complete' do | ||||||
|  |       let(:cleanup_schedule) { create(:merge_request_cleanup_schedule, :running) } | ||||||
|  | 
 | ||||||
|  |       it 'sets the status to completed' do | ||||||
|  |         cleanup_schedule.complete | ||||||
|  | 
 | ||||||
|  |         expect(cleanup_schedule.reload).to be_completed | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'sets the completed_at' do | ||||||
|  |         expect { cleanup_schedule.complete }.to change(cleanup_schedule, :completed_at) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when previous status is not running' do | ||||||
|  |         let(:cleanup_schedule) { create(:merge_request_cleanup_schedule, :completed) } | ||||||
|  | 
 | ||||||
|  |         it 'does not change status' do | ||||||
|  |           expect { cleanup_schedule.complete }.not_to change(cleanup_schedule, :status) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     describe '#mark_as_failed' do | ||||||
|  |       let(:cleanup_schedule) { create(:merge_request_cleanup_schedule, :running) } | ||||||
|  | 
 | ||||||
|  |       it 'sets the status to failed' do | ||||||
|  |         cleanup_schedule.mark_as_failed | ||||||
|  | 
 | ||||||
|  |         expect(cleanup_schedule.reload).to be_failed | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'increments failed_count' do | ||||||
|  |         expect { cleanup_schedule.mark_as_failed }.to change(cleanup_schedule, :failed_count).by(1) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when previous status is not running' do | ||||||
|  |         let(:cleanup_schedule) { create(:merge_request_cleanup_schedule, :failed) } | ||||||
|  | 
 | ||||||
|  |         it 'does not change status' do | ||||||
|  |           expect { cleanup_schedule.mark_as_failed }.not_to change(cleanup_schedule, :status) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '.scheduled_and_unstarted' do | ||||||
|  |     let!(:cleanup_schedule_1) { create(:merge_request_cleanup_schedule, scheduled_at: 2.days.ago) } | ||||||
|  |     let!(:cleanup_schedule_2) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.ago) } | ||||||
|  |     let!(:cleanup_schedule_3) { create(:merge_request_cleanup_schedule, :completed, scheduled_at: 1.day.ago) } | ||||||
|  |     let!(:cleanup_schedule_4) { create(:merge_request_cleanup_schedule, scheduled_at: 4.days.ago) } | ||||||
|  |     let!(:cleanup_schedule_5) { create(:merge_request_cleanup_schedule, scheduled_at: 3.days.ago) } | ||||||
|  |     let!(:cleanup_schedule_6) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.from_now) } | ||||||
|  |     let!(:cleanup_schedule_7) { create(:merge_request_cleanup_schedule, :failed, scheduled_at: 5.days.ago) } | ||||||
|  | 
 | ||||||
|  |     it 'returns records that are scheduled before or on current time and unstarted (ordered by scheduled first)' do | ||||||
|  |       expect(described_class.scheduled_and_unstarted).to eq([ | ||||||
|  |         cleanup_schedule_2, | ||||||
|  |         cleanup_schedule_1, | ||||||
|  |         cleanup_schedule_5, | ||||||
|  |         cleanup_schedule_4 | ||||||
|       ]) |       ]) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   describe '.start_next' do | ||||||
|  |     let!(:cleanup_schedule_1) { create(:merge_request_cleanup_schedule, :completed, scheduled_at: 1.day.ago) } | ||||||
|  |     let!(:cleanup_schedule_2) { create(:merge_request_cleanup_schedule, scheduled_at: 2.days.ago) } | ||||||
|  |     let!(:cleanup_schedule_3) { create(:merge_request_cleanup_schedule, :running, scheduled_at: 1.day.ago) } | ||||||
|  |     let!(:cleanup_schedule_4) { create(:merge_request_cleanup_schedule, scheduled_at: 3.days.ago) } | ||||||
|  |     let!(:cleanup_schedule_5) { create(:merge_request_cleanup_schedule, :failed, scheduled_at: 3.days.ago) } | ||||||
|  | 
 | ||||||
|  |     it 'finds the next scheduled and unstarted then marked it as running' do | ||||||
|  |       expect(described_class.start_next).to eq(cleanup_schedule_2) | ||||||
|  |       expect(cleanup_schedule_2.reload).to be_running | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -137,6 +137,7 @@ project_setting: | ||||||
|     - has_confluence |     - has_confluence | ||||||
|     - has_vulnerabilities |     - has_vulnerabilities | ||||||
|     - prevent_merge_without_jira_issue |     - prevent_merge_without_jira_issue | ||||||
|  |     - previous_default_branch | ||||||
|     - project_id |     - project_id | ||||||
|     - push_rule_id |     - push_rule_id | ||||||
|     - show_default_award_emojis |     - show_default_award_emojis | ||||||
|  |  | ||||||
|  | @ -200,17 +200,32 @@ RSpec.describe Projects::UpdateService do | ||||||
|     context 'when updating a default branch' do |     context 'when updating a default branch' do | ||||||
|       let(:project) { create(:project, :repository) } |       let(:project) { create(:project, :repository) } | ||||||
| 
 | 
 | ||||||
|       it 'changes a default branch' do |       it 'changes default branch, tracking the previous branch' do | ||||||
|  |         previous_default_branch = project.default_branch | ||||||
|  | 
 | ||||||
|         update_project(project, admin, default_branch: 'feature') |         update_project(project, admin, default_branch: 'feature') | ||||||
| 
 | 
 | ||||||
|         expect(Project.find(project.id).default_branch).to eq 'feature' |         project.reload | ||||||
|  | 
 | ||||||
|  |         expect(project.default_branch).to eq('feature') | ||||||
|  |         expect(project.previous_default_branch).to eq(previous_default_branch) | ||||||
|  | 
 | ||||||
|  |         update_project(project, admin, default_branch: previous_default_branch) | ||||||
|  | 
 | ||||||
|  |         project.reload | ||||||
|  | 
 | ||||||
|  |         expect(project.default_branch).to eq(previous_default_branch) | ||||||
|  |         expect(project.previous_default_branch).to eq('feature') | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'does not change a default branch' do |       it 'does not change a default branch' do | ||||||
|         # The branch 'unexisted-branch' does not exist. |         # The branch 'unexisted-branch' does not exist. | ||||||
|         update_project(project, admin, default_branch: 'unexisted-branch') |         update_project(project, admin, default_branch: 'unexisted-branch') | ||||||
| 
 | 
 | ||||||
|         expect(Project.find(project.id).default_branch).to eq 'master' |         project.reload | ||||||
|  | 
 | ||||||
|  |         expect(project.default_branch).to eq 'master' | ||||||
|  |         expect(project.previous_default_branch).to be_nil | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -33,3 +33,21 @@ RSpec.shared_examples 'search results sorted' do | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | 
 | ||||||
|  | RSpec.shared_examples 'search results sorted by popularity' do | ||||||
|  |   context 'sort: popularity_desc' do | ||||||
|  |     let(:sort) { 'popularity_desc' } | ||||||
|  | 
 | ||||||
|  |     it 'sorts results by upvotes' do | ||||||
|  |       expect(results_popular.objects(scope).map(&:id)).to eq([popular_result.id, less_popular_result.id, non_popular_result.id]) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   context 'sort: popularity_asc' do | ||||||
|  |     let(:sort) { 'popularity_asc' } | ||||||
|  | 
 | ||||||
|  |     it 'sorts results by created_at' do | ||||||
|  |       expect(results_popular.objects(scope).map(&:id)).to eq([non_popular_result.id, less_popular_result.id, popular_result.id]) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  |  | ||||||
|  | @ -64,6 +64,7 @@ RSpec.describe 'projects/empty' do | ||||||
|       expect(rendered).to have_selector('.js-invite-members-modal') |       expect(rendered).to have_selector('.js-invite-members-modal') | ||||||
|       expect(rendered).to have_selector('[data-label=invite_members_empty_project]') |       expect(rendered).to have_selector('[data-label=invite_members_empty_project]') | ||||||
|       expect(rendered).to have_selector('[data-event=click_button]') |       expect(rendered).to have_selector('[data-event=click_button]') | ||||||
|  |       expect(rendered).to have_selector('[data-trigger-source=project-empty-page]') | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'when user does not have permissions to invite members' do |     context 'when user does not have permissions to invite members' do | ||||||
|  |  | ||||||
|  | @ -418,6 +418,7 @@ RSpec.describe 'Every Sidekiq worker' do | ||||||
|         'ScanSecurityReportSecretsWorker' => 17, |         'ScanSecurityReportSecretsWorker' => 17, | ||||||
|         'Security::AutoFixWorker' => 3, |         'Security::AutoFixWorker' => 3, | ||||||
|         'Security::StoreScansWorker' => 3, |         'Security::StoreScansWorker' => 3, | ||||||
|  |         'Security::TrackSecureScansWorker' => 1, | ||||||
|         'SelfMonitoringProjectCreateWorker' => 3, |         'SelfMonitoringProjectCreateWorker' => 3, | ||||||
|         'SelfMonitoringProjectDeleteWorker' => 3, |         'SelfMonitoringProjectDeleteWorker' => 3, | ||||||
|         'ServiceDeskEmailReceiverWorker' => 3, |         'ServiceDeskEmailReceiverWorker' => 3, | ||||||
|  |  | ||||||
|  | @ -3,18 +3,41 @@ | ||||||
| require 'spec_helper' | require 'spec_helper' | ||||||
| 
 | 
 | ||||||
| RSpec.describe MergeRequestCleanupRefsWorker do | RSpec.describe MergeRequestCleanupRefsWorker do | ||||||
|   describe '#perform' do |   let(:worker) { described_class.new } | ||||||
|     context 'when merge request exists' do |  | ||||||
|       let(:merge_request) { create(:merge_request) } |  | ||||||
|       let(:job_args) { merge_request.id } |  | ||||||
| 
 | 
 | ||||||
|       include_examples 'an idempotent worker' do |   describe '#perform_work' do | ||||||
|         it 'calls MergeRequests::CleanupRefsService#execute' do |     context 'when next cleanup schedule is found' do | ||||||
|           expect_next_instance_of(MergeRequests::CleanupRefsService, merge_request) do |svc| |       let(:failed_count) { 0 } | ||||||
|             expect(svc).to receive(:execute).and_call_original |       let!(:cleanup_schedule) { create(:merge_request_cleanup_schedule, failed_count: failed_count) } | ||||||
|           end.twice |  | ||||||
| 
 | 
 | ||||||
|           subject |       it 'marks the cleanup schedule as completed on success' do | ||||||
|  |         stub_cleanup_service(status: :success) | ||||||
|  |         worker.perform_work | ||||||
|  | 
 | ||||||
|  |         expect(cleanup_schedule.reload).to be_completed | ||||||
|  |         expect(cleanup_schedule.completed_at).to be_present | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when service fails' do | ||||||
|  |         before do | ||||||
|  |           stub_cleanup_service(status: :error) | ||||||
|  |           worker.perform_work | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'marks the cleanup schedule as unstarted and track the failure' do | ||||||
|  |           expect(cleanup_schedule.reload).to be_unstarted | ||||||
|  |           expect(cleanup_schedule.failed_count).to eq(1) | ||||||
|  |           expect(cleanup_schedule.completed_at).to be_nil | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         context "and cleanup schedule has already failed #{described_class::FAILURE_THRESHOLD} times" do | ||||||
|  |           let(:failed_count) { described_class::FAILURE_THRESHOLD } | ||||||
|  | 
 | ||||||
|  |           it 'marks the cleanup schedule as failed and track the failure' do | ||||||
|  |             expect(cleanup_schedule.reload).to be_failed | ||||||
|  |             expect(cleanup_schedule.failed_count).to eq(described_class::FAILURE_THRESHOLD + 1) | ||||||
|  |             expect(cleanup_schedule.completed_at).to be_nil | ||||||
|  |           end | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  | @ -23,20 +46,52 @@ RSpec.describe MergeRequestCleanupRefsWorker do | ||||||
|           stub_feature_flags(merge_request_refs_cleanup: false) |           stub_feature_flags(merge_request_refs_cleanup: false) | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         it 'does not clean up the merge request' do |         it 'does nothing' do | ||||||
|           expect(MergeRequests::CleanupRefsService).not_to receive(:new) |           expect(MergeRequests::CleanupRefsService).not_to receive(:new) | ||||||
| 
 | 
 | ||||||
|           perform_multiple(1) |           worker.perform_work | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'when merge request does not exist' do |     context 'when there is no next cleanup schedule found' do | ||||||
|       it 'does not call MergeRequests::CleanupRefsService' do |       it 'does nothing' do | ||||||
|         expect(MergeRequests::CleanupRefsService).not_to receive(:new) |         expect(MergeRequests::CleanupRefsService).not_to receive(:new) | ||||||
| 
 | 
 | ||||||
|         perform_multiple(1) |         worker.perform_work | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   describe '#remaining_work_count' do | ||||||
|  |     let_it_be(:unstarted) { create_list(:merge_request_cleanup_schedule, 2) } | ||||||
|  |     let_it_be(:running) { create_list(:merge_request_cleanup_schedule, 2, :running) } | ||||||
|  |     let_it_be(:completed) { create_list(:merge_request_cleanup_schedule, 2, :completed) } | ||||||
|  | 
 | ||||||
|  |     it 'returns number of scheduled and unstarted cleanup schedule records' do | ||||||
|  |       expect(worker.remaining_work_count).to eq(unstarted.count) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when count exceeds max_running_jobs' do | ||||||
|  |       before do | ||||||
|  |         create_list(:merge_request_cleanup_schedule, worker.max_running_jobs) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'gets capped at max_running_jobs' do | ||||||
|  |         expect(worker.remaining_work_count).to eq(worker.max_running_jobs) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '#max_running_jobs' do | ||||||
|  |     it 'returns the value of MAX_RUNNING_JOBS' do | ||||||
|  |       expect(worker.max_running_jobs).to eq(described_class::MAX_RUNNING_JOBS) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def stub_cleanup_service(result) | ||||||
|  |     expect_next_instance_of(MergeRequests::CleanupRefsService, cleanup_schedule.merge_request) do |svc| | ||||||
|  |       expect(svc).to receive(:execute).and_return(result) | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -6,16 +6,9 @@ RSpec.describe ScheduleMergeRequestCleanupRefsWorker do | ||||||
|   subject(:worker) { described_class.new } |   subject(:worker) { described_class.new } | ||||||
| 
 | 
 | ||||||
|   describe '#perform' do |   describe '#perform' do | ||||||
|     before do |  | ||||||
|       allow(MergeRequest::CleanupSchedule) |  | ||||||
|         .to receive(:scheduled_merge_request_ids) |  | ||||||
|         .with(described_class::LIMIT) |  | ||||||
|         .and_return([1, 2, 3, 4]) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'does nothing if the database is read-only' do |     it 'does nothing if the database is read-only' do | ||||||
|       allow(Gitlab::Database).to receive(:read_only?).and_return(true) |       allow(Gitlab::Database).to receive(:read_only?).and_return(true) | ||||||
|       expect(MergeRequestCleanupRefsWorker).not_to receive(:bulk_perform_in) |       expect(MergeRequestCleanupRefsWorker).not_to receive(:perform_with_capacity) | ||||||
| 
 | 
 | ||||||
|       worker.perform |       worker.perform | ||||||
|     end |     end | ||||||
|  | @ -26,25 +19,17 @@ RSpec.describe ScheduleMergeRequestCleanupRefsWorker do | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'does not schedule any merge request clean ups' do |       it 'does not schedule any merge request clean ups' do | ||||||
|         expect(MergeRequestCleanupRefsWorker).not_to receive(:bulk_perform_in) |         expect(MergeRequestCleanupRefsWorker).not_to receive(:perform_with_capacity) | ||||||
| 
 | 
 | ||||||
|         worker.perform |         worker.perform | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     include_examples 'an idempotent worker' do |     include_examples 'an idempotent worker' do | ||||||
|       it 'schedules MergeRequestCleanupRefsWorker to be performed by batch' do |       it 'schedules MergeRequestCleanupRefsWorker to be performed with capacity' do | ||||||
|         expect(MergeRequestCleanupRefsWorker) |         expect(MergeRequestCleanupRefsWorker).to receive(:perform_with_capacity).twice | ||||||
|           .to receive(:bulk_perform_in) |  | ||||||
|           .with( |  | ||||||
|             described_class::DELAY, |  | ||||||
|             [[1], [2], [3], [4]], |  | ||||||
|             batch_size: described_class::BATCH_SIZE |  | ||||||
|           ) |  | ||||||
| 
 | 
 | ||||||
|         expect(worker).to receive(:log_extra_metadata_on_done).with(:merge_requests_count, 4) |         subject | ||||||
| 
 |  | ||||||
|         worker.perform |  | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||