diff options
85 files changed, 1315 insertions, 229 deletions
@@ -295,7 +295,7 @@ gem 'gon', '~> 6.4.0' gem 'request_store', '~> 1.5' gem 'base32', '~> 0.3.0' -gem 'gitlab-license', '~> 1.5' +gem 'gitlab-license', '~> 2.0' # Protect against bruteforcing gem 'rack-attack', '~> 6.3.0' diff --git a/Gemfile.lock b/Gemfile.lock index bcca2d42006..61c01f740c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -490,7 +490,7 @@ GEM opentracing (~> 0.4) pg_query (~> 2.1) redis (> 3.0.0, < 5.0.0) - gitlab-license (1.5.0) + gitlab-license (2.0.0) gitlab-mail_room (0.0.9) gitlab-markup (1.7.1) gitlab-net-dns (0.9.1) @@ -1489,7 +1489,7 @@ DEPENDENCIES gitlab-experiment (~> 0.6.1) gitlab-fog-azure-rm (~> 1.1.1) gitlab-labkit (~> 0.20.0) - gitlab-license (~> 1.5) + gitlab-license (~> 2.0) gitlab-mail_room (~> 0.0.9) gitlab-markup (~> 1.7.1) gitlab-net-dns (~> 0.9.1) diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 3daa5eebcb6..cb7e3ef9632 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -227,7 +227,12 @@ export default { </template> </gl-sprintf> </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> <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> <template v-else> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 1c4413bef71..0b0560f63c1 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -225,11 +225,21 @@ export default { { name: '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, timesChartOptions: { height: INNER_CHART_HEIGHT, diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 424dc4529ff..273825b996a 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -3,6 +3,7 @@ import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { sprintf, __ } from '~/locale'; import getRefMixin from '../mixins/get_ref'; +import DeleteBlobModal from './delete_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue'; export default { @@ -15,6 +16,7 @@ export default { GlButtonGroup, GlButton, UploadBlobModal, + DeleteBlobModal, }, directives: { GlModal: GlModalDirective, @@ -41,10 +43,18 @@ export default { type: String, required: true, }, + deletePath: { + type: String, + required: true, + }, canPushCode: { type: Boolean, required: true, }, + emptyRepo: { + type: Boolean, + required: true, + }, }, computed: { replaceModalId() { @@ -53,6 +63,12 @@ export default { replaceModalTitle() { return sprintf(__('Replace %{name}'), { name: this.name }); }, + deleteModalId() { + return uniqueId('delete-modal'); + }, + deleteModalTitle() { + return sprintf(__('Delete %{name}'), { name: this.name }); + }, }, }; </script> @@ -63,7 +79,9 @@ export default { <gl-button v-gl-modal="replaceModalId"> {{ $options.i18n.replace }} </gl-button> - <gl-button>{{ $options.i18n.delete }}</gl-button> + <gl-button v-gl-modal="deleteModalId"> + {{ $options.i18n.delete }} + </gl-button> </gl-button-group> <upload-blob-modal :modal-id="replaceModalId" @@ -76,5 +94,15 @@ export default { :replace-path="replacePath" :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> </template> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index c3876a77ec4..09ac60c94c7 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -69,6 +69,7 @@ export default { pushCode: false, }, repository: { + empty: true, blobs: { nodes: [ { @@ -92,6 +93,7 @@ export default { forkPath: '', simpleViewer: {}, richViewer: null, + webPath: '', }, ], }, @@ -174,7 +176,9 @@ export default { :path="path" :name="blobInfo.name" :replace-path="blobInfo.replacePath" + :delete-path="blobInfo.webPath" :can-push-code="project.userPermissions.pushCode" + :empty-repo="project.repository.empty" /> </template> </blob-header> diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue new file mode 100644 index 00000000000..6599d99d7bd --- /dev/null +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -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> diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 22349261d3c..2d2faa8d9f3 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -1,3 +1,10 @@ +import { __ } from '~/locale'; + 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_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'); diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 1889f2269f5..a8f263941e2 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -4,6 +4,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { pushCode } repository { + empty blobs(paths: [$filePath]) { nodes { webPath diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js index 741690886b7..bc3741a3880 100644 --- a/app/assets/javascripts/vuex_shared/bindings.js +++ b/app/assets/javascripts/vuex_shared/bindings.js @@ -6,7 +6,7 @@ * @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} 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 */ export const mapComputed = (list, defaultUpdateFn, root) => { @@ -21,6 +21,10 @@ export const mapComputed = (list, defaultUpdateFn, root) => { if (getter) { return this.$store.getters[getter]; } else if (root) { + if (typeof root === 'function') { + return root(this.$store.state)[key]; + } + return this.$store.state[root][key]; } return this.$store.state[key]; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 79b69e11b35..a497f56f3b8 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -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 .fly-out-top-item-container { background-color: #2f2a6b; - color: #333; + color: var(--black, #333); } body.gl-dark .logo-text svg { fill: var(--gl-text-color); diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 45fbd8607fc..a94169ab494 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -185,7 +185,7 @@ &.active a, .fly-out-top-item-container { background-color: $purple-900; - color: $white; + color: var(--black, $white); } } } diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index c6c9237292d..08066acb45c 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -23,6 +23,10 @@ class Projects::BlobController < Projects::ApplicationController # We need to assign the blob vars before `authorize_edit_tree!` so we can # validate access to a specific ref. 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 :commit, except: [:new, :create] @@ -140,11 +144,15 @@ class Projects::BlobController < Projects::ApplicationController end def commit - @commit = @repository.commit(@ref) + @commit ||= @repository.commit(@ref) return render_404 unless @commit end + def redirect_renamed_default_branch? + action_name == 'show' + end + def assign_blob_vars @id = params[:id] @ref, @path = extract_ref(@id) @@ -152,6 +160,12 @@ class Projects::BlobController < Projects::ApplicationController render_404 end + def rectify_renamed_default_branch! + @commit ||= @repository.commit(@ref) + + super + end + # rubocop: disable CodeReuse/ActiveRecord def after_edit_path from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:from_merge_request_iid]) diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index b5cfc3990b2..475c9de2503 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -39,6 +39,10 @@ class Projects::TreeController < Projects::ApplicationController private + def redirect_renamed_default_branch? + action_name == 'show' + end + def assign_dir_vars @branch_name = params[:branch_name] diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 14783882f5e..e9a75babb97 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -20,7 +20,11 @@ module ClustersHelper { default_branch_name: clusterable_project.default_branch, 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 diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 1cbde1871d4..ec8ed3d6e7f 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -131,7 +131,7 @@ module SearchHelper end def search_sort_options - [ + options = [ { title: _('Created date'), 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 private diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index dc37d73df85..c8f6b9aaedb 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -27,9 +27,6 @@ class AwardEmoji < ApplicationRecord after_save :expire_cache after_destroy :expire_cache - after_save :update_awardable_upvotes_count - after_destroy :update_awardable_upvotes_count - class << self def votes_for_collection(ids, type) select('name', 'awardable_id', 'COUNT(*) as count') @@ -66,15 +63,6 @@ class AwardEmoji < ApplicationRecord def expire_cache awardable.try(:bump_updated_at) awardable.try(:expire_etag_cache) - end - - private - - def update_awardable_upvotes_count - return unless upvote? && awardable.has_attribute?(:upvotes_count) - - awardable.update_column(:upvotes_count, awardable.upvotes) + awardable.try(:update_upvotes_count) if upvote? end end - -AwardEmoji.prepend_mod_with('AwardEmoji') diff --git a/app/models/issue.rb b/app/models/issue.rb index 3b236620ed6..7926c4be489 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -520,6 +520,11 @@ class Issue < ApplicationRecord issue_assignees.pluck(:user_id) end + def update_upvotes_count + self.lock! + self.update_column(:upvotes_count, self.upvotes) + end + private def spammable_attribute_changed? diff --git a/app/models/merge_request/cleanup_schedule.rb b/app/models/merge_request/cleanup_schedule.rb index 79817269be2..35194b2b318 100644 --- a/app/models/merge_request/cleanup_schedule.rb +++ b/app/models/merge_request/cleanup_schedule.rb @@ -1,14 +1,61 @@ # frozen_string_literal: true class MergeRequest::CleanupSchedule < ApplicationRecord + STATUSES = { + unstarted: 0, + running: 1, + completed: 2, + failed: 3 + }.freeze + belongs_to :merge_request, inverse_of: :cleanup_schedule validates :scheduled_at, presence: true - def self.scheduled_merge_request_ids(limit) - where('completed_at IS NULL AND scheduled_at <= NOW()') + state_machine :status, initial: :unstarted do + 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') - .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 diff --git a/app/models/project.rb b/app/models/project.rb index e850494ab27..21d5b083476 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -416,6 +416,7 @@ class Project < ApplicationRecord prefix: :import, to: :import_state, allow_nil: true delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, 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 :name, to: :owner, allow_nil: true, prefix: true delegate :members, to: :team, prefix: true diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 4351a66351d..d6e7f165d72 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -66,6 +66,8 @@ module Projects previous_default_branch = project.default_branch if project.change_head(params[:default_branch]) + params[:previous_default_branch] = previous_default_branch + after_default_branch_change(previous_default_branch) else raise ValidationError, s_("UpdateProject|Could not set the default branch") diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 206d5edbf84..f45e6c5e8e9 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -31,8 +31,8 @@ .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) } } - else - = _('Service ping is disabled, and cannot be configured through this form.') - - deactivating_service_ping_path = help_page_path('development/service_ping/index.md', anchor: 'disable-service-ping') + = _('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-using-the-configuration-file') - 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 } .form-group diff --git a/app/views/admin/dev_ops_report/_callout.html.haml b/app/views/admin/dev_ops_report/_callout.html.haml index f313865478d..2b4c258a00c 100644 --- a/app/views/admin/dev_ops_report/_callout.html.haml +++ b/app/views/admin/dev_ops_report/_callout.html.haml @@ -8,6 +8,6 @@ %h4 = _('Introducing Your DevOps Report') %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 = custom_icon('dev_ops_report_overview') diff --git a/app/views/projects/_invite_members.html.haml b/app/views/projects/_invite_members_empty_project.html.haml index fc292da6fcf..ee2215b0fbb 100644 --- a/app/views/projects/_invite_members.html.haml +++ b/app/views/projects/_invite_members_empty_project.html.haml @@ -6,6 +6,7 @@ .js-invite-members-trigger{ data: { variant: 'confirm', classes: 'gl-mb-8 gl-xs-w-full', display_text: s_('InviteMember|Invite members'), + trigger_source: 'project-empty-page', event: 'click_button', label: 'invite_members_empty_project' } } diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 027b81d6c68..0fda74a3be5 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -7,7 +7,7 @@ = render "home_panel" = 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 = _('The repository for this project is empty') diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml index da0adba88db..551f5c048bc 100644 --- a/app/views/search/results/_issuable.html.haml +++ b/app/views/search/results/_issuable.html.haml @@ -1,14 +1,19 @@ -%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' } - %span.gl-display-flex.gl-align-items-center - %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable) - = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential? - = 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 - %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title - .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_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe - .description.term.col-sm-10.gl-px-0 - = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) +%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!' } + .col-sm-9 + %span.gl-display-flex.gl-align-items-center + %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable) + = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential? + = 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 + %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title + .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 + .description.term.gl-px-0 + = 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 diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb index 162c6dc2a88..408d070d56f 100644 --- a/app/workers/merge_request_cleanup_refs_worker.rb +++ b/app/workers/merge_request_cleanup_refs_worker.rb @@ -2,6 +2,8 @@ class MergeRequestCleanupRefsWorker include ApplicationWorker + include LimitedCapacity::Worker + include Gitlab::Utils::StrongMemoize sidekiq_options retry: 3 @@ -9,20 +11,60 @@ class MergeRequestCleanupRefsWorker tags :exclude_from_kubernetes idempotent! - def perform(merge_request_id) - return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) + # 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 - merge_request = MergeRequest.find_by_id(merge_request_id) + def perform_work + return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) 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 end - result = ::MergeRequests::CleanupRefsService.new(merge_request).execute + log_extra_metadata_on_done(:merge_request_id, merge_request.id) + + result = MergeRequests::CleanupRefsService.new(merge_request).execute + + 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 - return if result[:status] == :success + def merge_request + strong_memoize(:merge_request) do + merge_request_cleanup_schedule&.merge_request + end + end - logger.error("Failed cleanup refs of merge request (#{merge_request_id}): #{result[:message]}") + def merge_request_cleanup_schedule + strong_memoize(:merge_request_cleanup_schedule) do + MergeRequest::CleanupSchedule.start_next + end end end diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb index b5ea5298879..40a773ca58f 100644 --- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb +++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb @@ -10,21 +10,10 @@ class ScheduleMergeRequestCleanupRefsWorker tags :exclude_from_kubernetes 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 return if Gitlab::Database.read_only? return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false) - ids = MergeRequest::CleanupSchedule.scheduled_merge_request_ids(LIMIT).map { |id| [id] } - - MergeRequestCleanupRefsWorker.bulk_perform_in(DELAY, ids, batch_size: BATCH_SIZE) # rubocop:disable Scalability/BulkPerformWithContext - - log_extra_metadata_on_done(:merge_requests_count, ids.size) + MergeRequestCleanupRefsWorker.perform_with_capacity end end diff --git a/config/feature_flags/development/merge_request_discussion_cache.yml b/config/feature_flags/development/merge_request_discussion_cache.yml index 4dcdbebabc4..e90887fc2b3 100644 --- a/config/feature_flags/development/merge_request_discussion_cache.yml +++ b/config/feature_flags/development/merge_request_discussion_cache.yml @@ -1,7 +1,7 @@ --- name: merge_request_discussion_cache 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' type: development group: group::code review diff --git a/config/feature_flags/development/merge_request_refs_cleanup.yml b/config/feature_flags/development/merge_request_refs_cleanup.yml index 79ea3c8b7a7..7df06ccc52f 100644 --- a/config/feature_flags/development/merge_request_refs_cleanup.yml +++ b/config/feature_flags/development/merge_request_refs_cleanup.yml @@ -1,7 +1,7 @@ --- name: merge_request_refs_cleanup 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' type: development group: group::code review diff --git a/config/feature_flags/development/search_sort_issues_by_popularity.yml b/config/feature_flags/development/search_sort_issues_by_popularity.yml new file mode 100644 index 00000000000..64885f00792 --- /dev/null +++ b/config/feature_flags/development/search_sort_issues_by_popularity.yml @@ -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 diff --git a/db/migrate/20210705124128_add_project_settings_previous_default_branch.rb b/db/migrate/20210705124128_add_project_settings_previous_default_branch.rb new file mode 100644 index 00000000000..e54d762fa75 --- /dev/null +++ b/db/migrate/20210705124128_add_project_settings_previous_default_branch.rb @@ -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 diff --git a/db/migrate/20210707095545_add_status_to_merge_request_cleanup_schedules.rb b/db/migrate/20210707095545_add_status_to_merge_request_cleanup_schedules.rb new file mode 100644 index 00000000000..597e274cda2 --- /dev/null +++ b/db/migrate/20210707095545_add_status_to_merge_request_cleanup_schedules.rb @@ -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 diff --git a/db/migrate/20210707173645_add_project_settings_previous_default_branch_text_limit.rb b/db/migrate/20210707173645_add_project_settings_previous_default_branch_text_limit.rb new file mode 100644 index 00000000000..a6a83b00234 --- /dev/null +++ b/db/migrate/20210707173645_add_project_settings_previous_default_branch_text_limit.rb @@ -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 diff --git a/db/migrate/20210708063032_add_failed_count_to_merge_request_cleanup_schedules.rb b/db/migrate/20210708063032_add_failed_count_to_merge_request_cleanup_schedules.rb new file mode 100644 index 00000000000..f613856a18c --- /dev/null +++ b/db/migrate/20210708063032_add_failed_count_to_merge_request_cleanup_schedules.rb @@ -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 diff --git a/db/migrate/20210713070842_update_merge_request_cleanup_schedules_scheduled_at_index.rb b/db/migrate/20210713070842_update_merge_request_cleanup_schedules_scheduled_at_index.rb new file mode 100644 index 00000000000..a19d15d80a0 --- /dev/null +++ b/db/migrate/20210713070842_update_merge_request_cleanup_schedules_scheduled_at_index.rb @@ -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 diff --git a/db/post_migrate/20210706115312_add_upvotes_count_index_to_issues.rb b/db/post_migrate/20210706115312_add_upvotes_count_index_to_issues.rb new file mode 100644 index 00000000000..65ec43930ea --- /dev/null +++ b/db/post_migrate/20210706115312_add_upvotes_count_index_to_issues.rb @@ -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 diff --git a/db/schema_migrations/20210705124128 b/db/schema_migrations/20210705124128 new file mode 100644 index 00000000000..247378331e4 --- /dev/null +++ b/db/schema_migrations/20210705124128 @@ -0,0 +1 @@ +02aea8fe759614bc3aa751e023aa508963f8183366f6d6f518bbccc2d85ec1a1
\ No newline at end of file diff --git a/db/schema_migrations/20210706115312 b/db/schema_migrations/20210706115312 new file mode 100644 index 00000000000..a1298418836 --- /dev/null +++ b/db/schema_migrations/20210706115312 @@ -0,0 +1 @@ +ac150e706b115849aa3802ae7b8e07d983e89eb637c48582c64948cbc7d7163d
\ No newline at end of file diff --git a/db/schema_migrations/20210707095545 b/db/schema_migrations/20210707095545 new file mode 100644 index 00000000000..83255c22622 --- /dev/null +++ b/db/schema_migrations/20210707095545 @@ -0,0 +1 @@ +98d4deaf0564119c1ee44d76d3a30bff1a0fceb7cab67c5dbef576faef62ddf5
\ No newline at end of file diff --git a/db/schema_migrations/20210707173645 b/db/schema_migrations/20210707173645 new file mode 100644 index 00000000000..0cc2386b4ef --- /dev/null +++ b/db/schema_migrations/20210707173645 @@ -0,0 +1 @@ +e440dac0e14df7309c84e72b98ed6373c712901dc66310a474979e0fce7dc59c
\ No newline at end of file diff --git a/db/schema_migrations/20210708063032 b/db/schema_migrations/20210708063032 new file mode 100644 index 00000000000..9d3271bdd91 --- /dev/null +++ b/db/schema_migrations/20210708063032 @@ -0,0 +1 @@ +77f6db1d2aeebdefd76c96966da6c9e4ce5da2c92a42f6ac2398b35fa21c680f
\ No newline at end of file diff --git a/db/schema_migrations/20210713070842 b/db/schema_migrations/20210713070842 new file mode 100644 index 00000000000..857dea1627e --- /dev/null +++ b/db/schema_migrations/20210713070842 @@ -0,0 +1 @@ +2899d954a199fa52bf6ab4beca5f22dcb9f9f0312e658f1307d1a7355394f1bb
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 3e55db5a0d4..9421cbb5473 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14711,7 +14711,9 @@ CREATE TABLE merge_request_cleanup_schedules ( scheduled_at timestamp with time zone NOT NULL, completed_at timestamp with time zone, 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 @@ -17082,6 +17084,8 @@ CREATE TABLE project_settings ( prevent_merge_without_jira_issue boolean DEFAULT false NOT NULL, cve_id_request_enabled boolean DEFAULT true 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)) ); @@ -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 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_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 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 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 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); diff --git a/doc/ci/pipelines/img/coverage_check_approval_rule_14_1.png b/doc/ci/pipelines/img/coverage_check_approval_rule_14_1.png Binary files differnew file mode 100644 index 00000000000..00eb5c84ca9 --- /dev/null +++ b/doc/ci/pipelines/img/coverage_check_approval_rule_14_1.png diff --git a/doc/ci/pipelines/settings.md b/doc/ci/pipelines/settings.md index 236ca10190e..8aab7db04f8 100644 --- a/doc/ci/pipelines/settings.md +++ b/doc/ci/pipelines/settings.md @@ -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). +### 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**. + +![Coverage-Check approval rule](img/coverage_check_approval_rule_14_1.png) + ### Removing color codes Some test coverage tools output with ANSI color codes that aren't diff --git a/doc/development/fe_guide/vuex.md b/doc/development/fe_guide/vuex.md index 6926a0d380d..064f01c8195 100644 --- a/doc/development/fe_guide/vuex.md +++ b/doc/development/fe_guide/vuex.md @@ -540,11 +540,11 @@ export default { foo: '' }, actions: { - updateBar() {...} - updateAll() {...} + updateBar() {...}, + updateAll() {...}, }, 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[].updateFn - the name of the action, leave it empty to use the default action * @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 */ ...mapComputed( [ 'baz', - { key: 'bar', updateFn: 'updateBar' } + { key: 'bar', updateFn: 'updateBar' }, { key: 'foo', getter: 'getFoo' }, ], '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. + +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, + ), + } +} +``` diff --git a/doc/development/usage_ping/dictionary.md b/doc/development/usage_ping/dictionary.md index 246af7634d8..527da610623 100644 --- a/doc/development/usage_ping/dictionary.md +++ b/doc/development/usage_ping/dictionary.md @@ -4354,6 +4354,8 @@ The total count of Helm packages that have been published. Group: `group::package` +Data Category: `Optional` + Status: `implemented` Tiers: `free`, `premium`, `ultimate` diff --git a/doc/user/admin_area/analytics/dev_ops_report.md b/doc/user/admin_area/analytics/dev_ops_report.md index cc96e0e788c..6158a89a13e 100644 --- a/doc/user/admin_area/analytics/dev_ops_report.md +++ b/doc/user/admin_area/analytics/dev_ops_report.md @@ -20,21 +20,22 @@ To see DevOps Report: ## DevOps Score 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). -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 -provides a Lead score per feature, which is calculated based on GitLab analysis -of top-performing instances based on [Service Ping data](../settings/usage_statistics.md#service-ping) that GitLab has -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 overall **DevOps Score** is an average of your feature scores. You can use this score to compare your DevOps status to other organizations. +You can use the DevOps score to compare your DevOps status to other organizations. -The page also provides helpful links to articles and GitLab docs, to help you -improve your scores. +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. +You can also see the Leader usage score, calculated from top-performing instances based on +[Service Ping data](../settings/usage_statistics.md#service-ping) that GitLab has 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 overall **DevOps Score** is an average of your +feature scores. 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 -collected before this feature is available. +information is **not sent** to any other GitLab instances. +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)** @@ -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. > - 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 - Approvals @@ -62,8 +63,7 @@ DevOps Adoption shows you which groups within your organization are using the mo - Pipelines - Runners -When managing groups in the UI, you can add your groups with the **Add group to table** -button, in the top right hand section the page. +To add your groups, in the top right-hand section the page, select **Add group to table**. DevOps Adoption allows you to: diff --git a/doc/user/compliance/compliance_dashboard/index.md b/doc/user/compliance/compliance_dashboard/index.md index 008c55eb347..fb6b3fe2cf6 100644 --- a/doc/user/compliance/compliance_dashboard/index.md +++ b/doc/user/compliance/compliance_dashboard/index.md @@ -25,10 +25,6 @@ The Compliance Dashboard shows only the latest MR on each project. ## Merge request drawer > - [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 request: @@ -104,28 +100,3 @@ the dropdown next to the **List of all merge commits** button at the top of the NOTE: 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. - -## 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>) -``` diff --git a/doc/user/group/import/img/bulk_imports_v13_8.png b/doc/user/group/import/img/bulk_imports_v13_8.png Binary files differdeleted file mode 100644 index ae4d8567d80..00000000000 --- a/doc/user/group/import/img/bulk_imports_v13_8.png +++ /dev/null diff --git a/doc/user/group/import/img/bulk_imports_v14_1.png b/doc/user/group/import/img/bulk_imports_v14_1.png Binary files differnew file mode 100644 index 00000000000..fb419c1df6c --- /dev/null +++ b/doc/user/group/import/img/bulk_imports_v14_1.png diff --git a/doc/user/group/import/img/import_panel_v13_8.png b/doc/user/group/import/img/import_panel_v13_8.png Binary files differdeleted file mode 100644 index 28d61785098..00000000000 --- a/doc/user/group/import/img/import_panel_v13_8.png +++ /dev/null diff --git a/doc/user/group/import/img/import_panel_v14_1.png b/doc/user/group/import/img/import_panel_v14_1.png Binary files differnew file mode 100644 index 00000000000..28417383b6c --- /dev/null +++ b/doc/user/group/import/img/import_panel_v14_1.png diff --git a/doc/user/group/import/index.md b/doc/user/group/import/index.md index b7ba2de7bf9..d76685f992b 100644 --- a/doc/user/group/import/index.md +++ b/doc/user/group/import/index.md @@ -110,7 +110,7 @@ on an existing group's page. 1. On the New Group page, select **Import group**. - ![Fill in import details](img/import_panel_v13_8.png) + ![Fill in import details](img/import_panel_v14_1.png) 1. Fill in source URL of your GitLab. 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. -![Group Importer page](img/bulk_imports_v13_8.png) +![Group Importer page](img/bulk_imports_v14_1.png) diff --git a/doc/user/group/settings/img/import_panel_v13_4.png b/doc/user/group/settings/img/import_panel_v13_4.png Binary files differdeleted file mode 100644 index e4e5b0e91a1..00000000000 --- a/doc/user/group/settings/img/import_panel_v13_4.png +++ /dev/null diff --git a/doc/user/group/settings/img/import_panel_v14_1.png b/doc/user/group/settings/img/import_panel_v14_1.png Binary files differnew file mode 100644 index 00000000000..28417383b6c --- /dev/null +++ b/doc/user/group/settings/img/import_panel_v14_1.png diff --git a/doc/user/group/settings/import_export.md b/doc/user/group/settings/import_export.md index 94a79ec6e74..5f732bee03f 100644 --- a/doc/user/group/settings/import_export.md +++ b/doc/user/group/settings/import_export.md @@ -93,9 +93,9 @@ on an existing group's page. ![Navigation paths to create a new group](img/new_group_navigation_v13_1.png) -1. On the New Group page, select the **Import group** tab. +1. On the New Group page, select the **Import group**. - ![Fill in group details](img/import_panel_v13_4.png) + ![Fill in group details](img/import_panel_v14_1.png) 1. Enter your group name. diff --git a/doc/user/project/merge_requests/approvals/index.md b/doc/user/project/merge_requests/approvals/index.md index 053b5d161b3..40345f33cb2 100644 --- a/doc/user/project/merge_requests/approvals/index.md +++ b/doc/user/project/merge_requests/approvals/index.md @@ -104,6 +104,7 @@ Without the approvals, the work cannot merge. Required approvals enable multiple database, for all proposed code changes. - Use the [code owners of changed files](rules.md#code-owners-as-eligible-approvers), 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) before merging code that could introduce a vulnerability. **(ULTIMATE)** diff --git a/doc/user/project/repository/branches/default.md b/doc/user/project/repository/branches/default.md index ebc9d9aefde..0f4c831216a 100644 --- a/doc/user/project/repository/branches/default.md +++ b/doc/user/project/repository/branches/default.md @@ -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 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 - [Discussion of default branch renaming](https://lore.kernel.org/git/pull.656.v4.git.1593009996.gitgitgadget@gmail.com/) diff --git a/lib/extracts_path.rb b/lib/extracts_path.rb index 4c537eeaa89..055a3a771c2 100644 --- a/lib/extracts_path.rb +++ b/lib/extracts_path.rb @@ -26,17 +26,17 @@ module ExtractsPath # Automatically renders `not_found!` if a valid tree path could not be # 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 override :assign_ref_vars def assign_ref_vars super - if @path.empty? && !@commit && @id.ends_with?('.atom') - @id = @ref = extract_ref_without_atom(@id) - @commit = @repo.commit(@ref) + rectify_atom! - request.format = :atom if @commit - end + rectify_renamed_default_branch! && return raise InvalidPathError unless @commit @@ -59,6 +59,42 @@ module ExtractsPath 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 def repository_container @project diff --git a/lib/gitlab/search/sort_options.rb b/lib/gitlab/search/sort_options.rb index 2ab38147462..f8e5cf727ac 100644 --- a/lib/gitlab/search/sort_options.rb +++ b/lib/gitlab/search/sort_options.rb @@ -15,6 +15,10 @@ module Gitlab :updated_at_asc when %w[updated_at desc], [nil, 'updated_desc'] :updated_at_desc + when %w[popularity asc], [nil, 'popularity_asc'] + :popularity_asc + when %w[popularity desc], [nil, 'popularity_desc'] + :popularity_desc else :unknown end diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 678c0b396ef..e6851af8264 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -7,6 +7,11 @@ module Gitlab DEFAULT_PAGE = 1 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 # Limit search results by passed projects @@ -128,20 +133,29 @@ module Gitlab end # 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 # 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 - scope.reorder('created_at ASC') + results.reorder('created_at ASC') when :created_at_desc - scope.reorder('created_at DESC') + results.reorder('created_at DESC') when :updated_at_asc - scope.reorder('updated_at ASC') + results.reorder('updated_at ASC') 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 - scope.reorder('created_at DESC') + results.reorder('created_at DESC') end end # rubocop: enable CodeReuse/ActiveRecord @@ -157,7 +171,7 @@ module Gitlab issues = issues.where(project_id: project_ids_relation) # rubocop: disable CodeReuse/ActiveRecord end - apply_sort(issues) + apply_sort(issues, scope: 'issues') end # rubocop: disable CodeReuse/ActiveRecord @@ -177,7 +191,7 @@ module Gitlab merge_requests = merge_requests.in_projects(project_ids_relation) end - apply_sort(merge_requests) + apply_sort(merge_requests, scope: 'merge_requests') end def default_scope diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f4d5107de55..6ad72e44ebe 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1459,12 +1459,6 @@ msgstr "" msgid "A member of the abuse team will review your report as soon as possible." 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" msgstr "" @@ -5681,6 +5675,9 @@ msgstr "" msgid "BulkImport|Existing groups" msgstr "" +msgid "BulkImport|Filter by source group" +msgstr "" + msgid "BulkImport|From source group" msgstr "" @@ -19290,15 +19287,9 @@ msgstr "" msgid "Learn more about Auto DevOps" msgstr "" -msgid "Learn more about License-Check" -msgstr "" - msgid "Learn more about Needs relationships" msgstr "" -msgid "Learn more about Vulnerability-Check" -msgstr "" - msgid "Learn more about Web Terminal" msgstr "" @@ -19494,9 +19485,6 @@ msgstr "" msgid "License overview" msgstr "" -msgid "License-Check" -msgstr "" - msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are active" msgstr "" @@ -28931,18 +28919,51 @@ msgstr "" msgid "Security report is out of date. Run %{newPipelineLinkStart}a new pipeline%{newPipelineLinkEnd} for the target branch (%{targetBranchName})" 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}" 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}." msgstr "" +msgid "SecurityApprovals|License-Check" +msgstr "" + msgid "SecurityApprovals|Requires approval for Denied licenses. %{linkStart}More information%{linkEnd}" 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}" 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" msgstr "" @@ -29765,7 +29786,7 @@ msgstr "" msgid "Service URL" 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 "" msgid "ServiceDesk|Enable Service Desk" @@ -32554,6 +32575,9 @@ msgstr "" msgid "The default CI/CD configuration file and path for new projects." 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." msgstr "" @@ -36400,9 +36424,6 @@ msgstr "" msgid "Vulnerability resolved in the default branch" msgstr "" -msgid "Vulnerability-Check" -msgstr "" - msgid "VulnerabilityChart|%{formattedStartDate} to today" msgstr "" @@ -37923,7 +37944,7 @@ msgstr "" msgid "Your CSV import for project" 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 "" msgid "Your GPG keys (%{count})" diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 9493215247a..53efcc65066 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -5,7 +5,8 @@ require 'spec_helper' RSpec.describe Projects::BlobController do 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 def request @@ -42,6 +43,20 @@ RSpec.describe Projects::BlobController do it { is_expected.to respond_with(:not_found) } 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 let(:id) { 'binary-encoding/encoding/binary-1.bin' } diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb index 8e4e275bdbe..143516e4712 100644 --- a/spec/controllers/projects/tree_controller_spec.rb +++ b/spec/controllers/projects/tree_controller_spec.rb @@ -3,8 +3,9 @@ require 'spec_helper' RSpec.describe Projects::TreeController do - let(:project) { create(:project, :repository) } - let(:user) { create(:user) } + let(:project) { create(:project, :repository, previous_default_branch: previous_default_branch) } + let(:previous_default_branch) { nil } + let(:user) { create(:user) } before do sign_in(user) @@ -55,6 +56,20 @@ RSpec.describe Projects::TreeController do it { is_expected.to respond_with(:not_found) } 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 let(:id) { 'empty-branch/invalid-path/' } diff --git a/spec/factories/merge_request_cleanup_schedules.rb b/spec/factories/merge_request_cleanup_schedules.rb index a89d0c88731..ecf0d5818e4 100644 --- a/spec/factories/merge_request_cleanup_schedules.rb +++ b/spec/factories/merge_request_cleanup_schedules.rb @@ -3,6 +3,19 @@ FactoryBot.define do factory :merge_request_cleanup_schedule, class: 'MergeRequest::CleanupSchedule' do 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 diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml index 5cebfbcbad9..9de4d2a5644 100644 --- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml +++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml @@ -12,7 +12,7 @@ milestone: "13.9" introduced_by_url: time_frame: 7d data_source: -data_category: Operational +data_category: Optional distribution: - ee tier: diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml index d448e7bf3f6..0e7de369c82 100644 --- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml +++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml @@ -13,7 +13,7 @@ milestone: "13.9" introduced_by_url: time_frame: 7d data_source: -data_category: Operational +data_category: Optional distribution: - ce - ee diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index b9a11dd1270..a449fd6f06c 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -1,8 +1,8 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - 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'; const DEFAULT_PROPS = { @@ -10,6 +10,8 @@ const DEFAULT_PROPS = { path: 'some/path', canPushCode: true, replacePath: 'some/replace/path', + deletePath: 'some/delete/path', + emptyRepo: false, }; const DEFAULT_INJECT = { @@ -39,6 +41,7 @@ describe('BlobButtonGroup component', () => { wrapper.destroy(); }); + const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); const findReplaceButton = () => wrapper.findAll(GlButton).at(0); @@ -93,4 +96,22 @@ describe('BlobButtonGroup component', () => { 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, + }); + }); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index e1ac46171bb..a83d0a607f2 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -58,23 +58,36 @@ const richMockData = { renderError: null, }, }; -const userPermissionsMockData = { + +const projectMockData = { userPermissions: { pushCode: true, }, + repository: { + empty: false, + }, }; const localVue = createLocalVue(); const mockAxios = new MockAdapter(axios); -const createComponentWithApollo = (mockData, mockPermissionData = true) => { +const createComponentWithApollo = (mockData = {}) => { 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({ data: { project: { - userPermissions: { pushCode: mockPermissionData }, - repository: { blobs: { nodes: [mockData] } }, + userPermissions: { pushCode: canPushCode }, + repository: { + empty: emptyRepo, + blobs: { + nodes: [blobs], + }, + }, }, }, }); @@ -209,14 +222,14 @@ describe('Blob content viewer component', () => { describe('legacy viewers', () => { it('does not load a legacy viewer when a rich viewer is not available', async () => { - createComponentWithApollo(simpleMockData); + createComponentWithApollo({ blobs: simpleMockData }); await waitForPromises(); expect(mockAxios.history.get).toHaveLength(0); }); it('loads a legacy viewer when a rich viewer is available', async () => { - createComponentWithApollo(richMockData); + createComponentWithApollo({ blobs: richMockData }); await waitForPromises(); expect(mockAxios.history.get).toHaveLength(1); @@ -320,16 +333,20 @@ describe('Blob content viewer component', () => { }); describe('BlobButtonGroup', () => { - const { name, path, replacePath } = simpleMockData; + const { name, path, replacePath, webPath } = simpleMockData; const { userPermissions: { pushCode }, - } = userPermissionsMockData; + repository: { empty }, + } = projectMockData; it('renders component', async () => { window.gon.current_user_id = 1; fullFactory({ - mockData: { blobInfo: simpleMockData, project: userPermissionsMockData }, + mockData: { + blobInfo: simpleMockData, + project: { userPermissions: { pushCode }, repository: { empty } }, + }, stubs: { BlobContent: true, BlobButtonGroup: true, @@ -342,7 +359,9 @@ describe('Blob content viewer component', () => { name, path, replacePath, + deletePath: webPath, canPushCode: pushCode, + emptyRepo: empty, }); }); diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js new file mode 100644 index 00000000000..a74e3e6d325 --- /dev/null +++ b/spec/frontend/repository/components/delete_blob_modal_spec.js @@ -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); + }, + ); + }); +}); diff --git a/spec/frontend/vuex_shared/bindings_spec.js b/spec/frontend/vuex_shared/bindings_spec.js index 0f91a09018f..4e210143c8c 100644 --- a/spec/frontend/vuex_shared/bindings_spec.js +++ b/spec/frontend/vuex_shared/bindings_spec.js @@ -3,7 +3,7 @@ import { mapComputed } from '~/vuex_shared/bindings'; describe('Binding utils', () => { describe('mapComputed', () => { - const defaultArgs = [['baz'], 'bar', 'foo']; + const defaultArgs = [['baz'], 'bar', 'foo', 'qux']; const createDummy = (mapComputedArgs = defaultArgs) => ({ 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 result = mapComputed(keyList, 'foo', 'bar'); 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', () => { const result = mapComputed(['baz'], 'foo', 'bar'); expect(result.baz.set).toBeDefined(); diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb index 8c738141063..f64afa1ed71 100644 --- a/spec/helpers/clusters_helper_spec.rb +++ b/spec/helpers/clusters_helper_spec.rb @@ -75,6 +75,13 @@ RSpec.describe ClustersHelper do it 'displays project path' do expect(subject[:project_path]).to eq(project.full_path) 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 describe '#js_clusters_list_data' do diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb index 57eda63854d..05f3bb2f71a 100644 --- a/spec/lib/extracts_path_spec.rb +++ b/spec/lib/extracts_path_spec.rb @@ -7,10 +7,17 @@ RSpec.describe ExtractsPath do include RepoHelpers 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(:container) { create(:project, :repository, creator: owner) } let(:request) { double('request') } + let(:flash) { {} } + let(:redirect_renamed_default_branch?) { true } before do @project = container @@ -18,11 +25,14 @@ RSpec.describe ExtractsPath do allow(container.repository).to receive(:ref_names).and_return(ref_names) allow(request).to receive(:format=) + allow(request).to receive(:get?) + allow(request).to receive(:head?) end describe '#assign_ref_vars' do 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' @@ -126,6 +136,66 @@ RSpec.describe ExtractsPath do expect(@commit).to be_nil 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 it_behaves_like 'extracts refs' diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 06dc9b3307a..2974893ec4a 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -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!(: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 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) } 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 diff --git a/spec/migrations/add_upvotes_count_index_to_issues_spec.rb b/spec/migrations/add_upvotes_count_index_to_issues_spec.rb new file mode 100644 index 00000000000..c04cb98a107 --- /dev/null +++ b/spec/migrations/add_upvotes_count_index_to_issues_spec.rb @@ -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 diff --git a/spec/models/merge_request/cleanup_schedule_spec.rb b/spec/models/merge_request/cleanup_schedule_spec.rb index 925d287088b..85208f901fd 100644 --- a/spec/models/merge_request/cleanup_schedule_spec.rb +++ b/spec/models/merge_request/cleanup_schedule_spec.rb @@ -11,22 +11,125 @@ RSpec.describe MergeRequest::CleanupSchedule do it { is_expected.to validate_presence_of(:scheduled_at) } end - describe '.scheduled_merge_request_ids' do - let_it_be(:mr_cleanup_schedule_1) { create(:merge_request_cleanup_schedule, scheduled_at: 2.days.ago) } - 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 - expect(described_class.scheduled_merge_request_ids(4)).to eq([ - mr_cleanup_schedule_2.merge_request_id, - mr_cleanup_schedule_1.merge_request_id, - mr_cleanup_schedule_5.merge_request_id, - mr_cleanup_schedule_4.merge_request_id + describe 'state machine transitions' do + let(:cleanup_schedule) { create(:merge_request_cleanup_schedule) } + + it 'sets status to unstarted by default' do + expect(cleanup_schedule).to be_unstarted + end + + describe '#run' do + 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 + + 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 diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index 2932447f663..8341fac3191 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -137,6 +137,7 @@ project_setting: - has_confluence - has_vulnerabilities - prevent_merge_without_jira_issue + - previous_default_branch - project_id - push_rule_id - show_default_award_emojis diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 0cab1aa4abc..c74a8295d0a 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -200,17 +200,32 @@ RSpec.describe Projects::UpdateService do context 'when updating a default branch' do 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') - 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 it 'does not change a default branch' do # The branch 'unexisted-branch' does not exist. 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 diff --git a/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb index eafb49cef71..e4f09dfa0b0 100644 --- a/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb @@ -33,3 +33,21 @@ RSpec.shared_examples 'search results sorted' do 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 diff --git a/spec/views/projects/empty.html.haml_spec.rb b/spec/views/projects/empty.html.haml_spec.rb index 7fa95507f75..0fb0ae5ff29 100644 --- a/spec/views/projects/empty.html.haml_spec.rb +++ b/spec/views/projects/empty.html.haml_spec.rb @@ -64,6 +64,7 @@ RSpec.describe 'projects/empty' do 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-event=click_button]') + expect(rendered).to have_selector('[data-trigger-source=project-empty-page]') end context 'when user does not have permissions to invite members' do diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index 8c1667e5b4d..c75b9b43ef4 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -418,6 +418,7 @@ RSpec.describe 'Every Sidekiq worker' do 'ScanSecurityReportSecretsWorker' => 17, 'Security::AutoFixWorker' => 3, 'Security::StoreScansWorker' => 3, + 'Security::TrackSecureScansWorker' => 1, 'SelfMonitoringProjectCreateWorker' => 3, 'SelfMonitoringProjectDeleteWorker' => 3, 'ServiceDeskEmailReceiverWorker' => 3, diff --git a/spec/workers/merge_request_cleanup_refs_worker_spec.rb b/spec/workers/merge_request_cleanup_refs_worker_spec.rb index 7401c6dd4d7..1de927a81e4 100644 --- a/spec/workers/merge_request_cleanup_refs_worker_spec.rb +++ b/spec/workers/merge_request_cleanup_refs_worker_spec.rb @@ -3,18 +3,41 @@ require 'spec_helper' RSpec.describe MergeRequestCleanupRefsWorker do - describe '#perform' do - context 'when merge request exists' do - let(:merge_request) { create(:merge_request) } - let(:job_args) { merge_request.id } - - include_examples 'an idempotent worker' do - it 'calls MergeRequests::CleanupRefsService#execute' do - expect_next_instance_of(MergeRequests::CleanupRefsService, merge_request) do |svc| - expect(svc).to receive(:execute).and_call_original - end.twice - - subject + let(:worker) { described_class.new } + + describe '#perform_work' do + context 'when next cleanup schedule is found' do + let(:failed_count) { 0 } + let!(:cleanup_schedule) { create(:merge_request_cleanup_schedule, failed_count: failed_count) } + + 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 @@ -23,20 +46,52 @@ RSpec.describe MergeRequestCleanupRefsWorker do stub_feature_flags(merge_request_refs_cleanup: false) end - it 'does not clean up the merge request' do + it 'does nothing' do expect(MergeRequests::CleanupRefsService).not_to receive(:new) - perform_multiple(1) + worker.perform_work end end end - context 'when merge request does not exist' do - it 'does not call MergeRequests::CleanupRefsService' do + context 'when there is no next cleanup schedule found' do + it 'does nothing' do expect(MergeRequests::CleanupRefsService).not_to receive(:new) - perform_multiple(1) + worker.perform_work + 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 diff --git a/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb b/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb index 869818b257e..ef515e43474 100644 --- a/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb +++ b/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb @@ -6,16 +6,9 @@ RSpec.describe ScheduleMergeRequestCleanupRefsWorker do subject(:worker) { described_class.new } 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 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 end @@ -26,25 +19,17 @@ RSpec.describe ScheduleMergeRequestCleanupRefsWorker do end 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 end end include_examples 'an idempotent worker' do - it 'schedules MergeRequestCleanupRefsWorker to be performed by batch' do - expect(MergeRequestCleanupRefsWorker) - .to receive(:bulk_perform_in) - .with( - described_class::DELAY, - [[1], [2], [3], [4]], - batch_size: described_class::BATCH_SIZE - ) + it 'schedules MergeRequestCleanupRefsWorker to be performed with capacity' do + expect(MergeRequestCleanupRefsWorker).to receive(:perform_with_capacity).twice - expect(worker).to receive(:log_extra_metadata_on_done).with(:merge_requests_count, 4) - - worker.perform + subject end end end |