diff --git a/.rubocop_todo/rspec/missing_feature_category.yml b/.rubocop_todo/rspec/missing_feature_category.yml index cee4f644e29..68aaf8014d9 100644 --- a/.rubocop_todo/rspec/missing_feature_category.yml +++ b/.rubocop_todo/rspec/missing_feature_category.yml @@ -3393,12 +3393,9 @@ RSpec/MissingFeatureCategory: - 'spec/lib/gitlab/database/migrations/test_background_runner_spec.rb' - 'spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb' - 'spec/lib/gitlab/database/partitioning/detached_partition_dropper_spec.rb' - - 'spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb' - - 'spec/lib/gitlab/database/partitioning/partition_manager_spec.rb' - 'spec/lib/gitlab/database/partitioning/partition_monitoring_spec.rb' - 'spec/lib/gitlab/database/partitioning/replace_table_spec.rb' - 'spec/lib/gitlab/database/partitioning/single_numeric_list_partition_spec.rb' - - 'spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb' - 'spec/lib/gitlab/database/partitioning/time_partition_spec.rb' - 'spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb' - 'spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb' diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue new file mode 100644 index 00000000000..a87f4b8467e --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue @@ -0,0 +1,34 @@ + + + + {{ s__('Job|External links') }} + + + + + {{ externalLink.label }} + + + + + diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue index 4711d5b00e3..305d7004357 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue @@ -3,8 +3,10 @@ import { isEmpty } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { forwardDeploymentFailureModalId } from '~/ci/constants'; +import { filterAnnotations } from '~/ci/job_details/utils'; import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; +import ExternalLinksBlock from './external_links_block.vue'; import JobsContainer from './jobs_container.vue'; import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; @@ -25,6 +27,7 @@ export default { SidebarHeader, StagesDropdown, TriggerBlock, + ExternalLinksBlock, }, props: { artifactHelpUrl: { @@ -40,6 +43,9 @@ export default { // the artifact object will always have a locked property return Object.keys(this.job.artifact).length > 1; }, + hasExternalLinks() { + return this.externalLinks.length > 0; + }, hasTriggers() { return !isEmpty(this.job.trigger); }, @@ -52,6 +58,9 @@ export default { shouldShowJobRetryForwardDeploymentModal() { return this.job.retry_path && this.hasForwardDeploymentFailure; }, + externalLinks() { + return filterAnnotations(this.job.annotations, 'external_link'); + }, }, watch: { job(value, oldValue) { @@ -88,6 +97,13 @@ export default { :help-url="artifactHelpUrl" /> + + { return compactedLog; }; + +export const filterAnnotations = (annotations, type) => { + return [...annotations] + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((annotationList) => annotationList.data) + .flatMap((annotation) => annotation[type] ?? []); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue index 3e7827dc416..055b6742ae1 100644 --- a/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue @@ -54,6 +54,7 @@ export default { :title="emptyStateText.title" :description="emptyStateText.description" :svg-path="emptyStateImagePath" + :svg-height="150" :primary-button-link="testReportDocPath" :primary-button-text="emptyStateText.button" /> diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js index bcda2c55974..67afbee3854 100644 --- a/app/assets/javascripts/projects/settings/init_access_dropdown.js +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -18,6 +18,15 @@ export const initAccessDropdown = (el, options) => { return new Vue({ el, + name: 'AccessDropdownRoot', + data() { + return { preselected }; + }, + methods: { + setPreselectedItems(items) { + this.preselected = items; + }, + }, render(createElement) { const vm = this; return createElement(AccessDropdown, { @@ -25,7 +34,7 @@ export const initAccessDropdown = (el, options) => { label, disabled, accessLevelsData: accessLevelsData.roles, - preselectedItems: preselected, + preselectedItems: this.preselected, ...props, }, on: { @@ -35,6 +44,9 @@ export const initAccessDropdown = (el, options) => { shown() { vm.$emit('shown'); }, + hidden() { + vm.$emit('hidden'); + }, }, }); }, diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index bbf10ef3b01..29034b3bc0e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -2,28 +2,23 @@ import { find } from 'lodash'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import AccessDropdown from '~/projects/settings/access_dropdown'; import { initToggle } from '~/toggles'; +import { initAccessDropdown } from '~/projects/settings/init_access_dropdown'; import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedBranchEdit { constructor(options) { this.hasLicense = options.hasLicense; - this.$wraps = {}; this.hasChanges = false; this.$wrap = options.$wrap; - this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); - this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); - this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest( - `.${ACCESS_LEVELS.MERGE}-container`, - ); - this.$wraps[ACCESS_LEVELS.PUSH] = this.$allowedToPushDropdown.closest( - `.${ACCESS_LEVELS.PUSH}-container`, - ); + this.selectedItems = { + [ACCESS_LEVELS.PUSH]: [], + [ACCESS_LEVELS.MERGE]: [], + }; + this.initDropdowns(); - this.buildDropdowns(); this.initToggles(); } @@ -67,6 +62,66 @@ export default class ProtectedBranchEdit { } } + initDropdowns() { + // Allowed to Merge dropdown + this[`${ACCESS_LEVELS.MERGE}_dropdown`] = this.buildDropdown( + 'js-allowed-to-merge', + ACCESS_LEVELS.MERGE, + gon.merge_access_levels, + 'protected-branch-allowed-to-merge', + ); + + // Allowed to Push dropdown + this[`${ACCESS_LEVELS.PUSH}_dropdown`] = this.buildDropdown( + 'js-allowed-to-push', + ACCESS_LEVELS.PUSH, + gon.push_access_levels, + 'protected-branch-allowed-to-push', + ); + } + + buildDropdown(selector, accessLevel, accessLevelsData, testId) { + const [el] = this.$wrap.find(`.${selector}`); + if (!el) return undefined; + + const projectId = gon.current_project_id; + const dropdown = initAccessDropdown(el, { + toggleClass: selector, + hasLicense: this.hasLicense, + searchEnabled: el.dataset.filter !== undefined, + showUsers: projectId !== undefined, + block: true, + accessLevel, + accessLevelsData, + testId, + }); + + dropdown.$on('select', (selected) => this.onSelectItems(accessLevel, selected)); + dropdown.$on('hidden', () => this.onDropdownHide()); + + this.initSelectedItems(dropdown, accessLevel); + return dropdown; + } + + initSelectedItems(dropdown, accessLevel) { + this.selectedItems[accessLevel] = dropdown.preselected.map((item) => { + if (item.type === LEVEL_TYPES.USER) return { id: item.id, user_id: item.user_id }; + if (item.type === LEVEL_TYPES.ROLE) return { id: item.id, access_level: item.access_level }; + if (item.type === LEVEL_TYPES.GROUP) return { id: item.id, group_id: item.group_id }; + return { id: item.id, deploy_key_id: item.deploy_key_id }; + }); + } + + onSelectItems(accessLevel, selected) { + this.selectedItems[accessLevel] = selected; + this.hasChanges = true; + } + + onDropdownHide() { + if (!this.hasChanges) return; + this.updatePermissions(); + } + updateProtectedBranch(formData, callback) { axios .patch(this.$wrap.data('url'), { @@ -78,79 +133,25 @@ export default class ProtectedBranchEdit { }); } - buildDropdowns() { - // Allowed to merge dropdown - this[`${ACCESS_LEVELS.MERGE}_dropdown`] = new AccessDropdown({ - accessLevel: ACCESS_LEVELS.MERGE, - accessLevelsData: gon.merge_access_levels, - $dropdown: this.$allowedToMergeDropdown, - onSelect: this.onSelectOption.bind(this), - onHide: this.onDropdownHide.bind(this), - hasLicense: this.hasLicense, - }); - - // Allowed to push dropdown - this[`${ACCESS_LEVELS.PUSH}_dropdown`] = new AccessDropdown({ - accessLevel: ACCESS_LEVELS.PUSH, - accessLevelsData: gon.push_access_levels, - $dropdown: this.$allowedToPushDropdown, - onSelect: this.onSelectOption.bind(this), - onHide: this.onDropdownHide.bind(this), - hasLicense: this.hasLicense, - }); - } - - onSelectOption() { - this.hasChanges = true; - } - - onDropdownHide() { - if (!this.hasChanges) { - return; - } - - this.hasChanges = true; - this.updatePermissions(); - } - updatePermissions() { - const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => { - const accessLevelName = ACCESS_LEVELS[level]; - const inputData = this[`${accessLevelName}_dropdown`].getInputData(accessLevelName); - acc[`${accessLevelName}_attributes`] = inputData; - + const formData = Object.values(ACCESS_LEVELS).reduce((acc, level) => { + acc[`${level}_attributes`] = this.selectedItems[level]; return acc; }, {}); - - axios - .patch(this.$wrap.data('url'), { - protected_branch: formData, - }) - .then(({ data }) => { - this.hasChanges = false; - - Object.keys(ACCESS_LEVELS).forEach((level) => { - const accessLevelName = ACCESS_LEVELS[level]; - - // The data coming from server will be the new persisted *state* for each dropdown - this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`); - }); - this.$allowedToMergeDropdown.enable(); - this.$allowedToPushDropdown.enable(); - }) - .catch(() => { - this.$allowedToMergeDropdown.enable(); - this.$allowedToPushDropdown.enable(); - createAlert({ message: __('Failed to update branch!') }); + this.updateProtectedBranch(formData, ({ data }) => { + this.hasChanges = false; + Object.values(ACCESS_LEVELS).forEach((level) => { + this.setSelectedItemsToDropdown(data[level], level); }); + }); } - setSelectedItemsToDropdown(items = [], dropdownName) { + setSelectedItemsToDropdown(items = [], accessLevel) { const itemsToAdd = items.map((currentItem) => { if (currentItem.user_id) { // Do this only for users for now // get the current data for selected items - const selectedItems = this[dropdownName].getSelectedItems(); + const selectedItems = this.selectedItems[accessLevel]; const currentSelectedItem = find(selectedItems, { user_id: currentItem.user_id, }); @@ -182,6 +183,7 @@ export default class ProtectedBranchEdit { }; }); - this[dropdownName].setSelectedItems(itemsToAdd); + this.selectedItems[accessLevel] = itemsToAdd; + this[`${accessLevel}_dropdown`]?.setPreselectedItems(itemsToAdd); } } diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index eb7971a9d5a..da743f5c496 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -3,8 +3,11 @@ import { mapState, mapGetters } from 'vuex'; import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; +import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue'; +import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, @@ -21,12 +24,14 @@ export default { name: 'GlobalSearchSidebar', components: { IssuesFilters, - ScopeLegacyNavigation, - ScopeSidebarNavigation, - SidebarPortal, MergeRequestsFilters, BlobsFilters, ProjectsFilters, + ScopeLegacyNavigation, + ScopeSidebarNavigation, + SidebarPortal, + DomElementListener, + SmallScreenDrawerNavigation, }, mixins: [glFeatureFlagsMixin()], computed: { @@ -53,11 +58,17 @@ export default { return Boolean(this.currentScope); }, }, + methods: { + toggleFiltersFromSidebar() { + toggleSuperSidebarCollapsed(); + }, + }, }; + @@ -66,14 +77,24 @@ export default { + - - - - - + + + + + + + + + + + + + + diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js index 77efbdd9e60..d765a821116 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js @@ -7,6 +7,7 @@ export const TRACKING_LABEL_CHECKBOX = 'checkbox'; const scopes = { PROJECTS: 'projects', + ISSUES: 'issues', }; const filterParam = 'include_archived'; diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue index 1984e3a36c4..c31c46f2e6a 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/index.vue @@ -14,7 +14,7 @@ export default { GlFormCheckbox, }, computed: { - ...mapState(['urlQuery']), + ...mapState(['urlQuery', 'useSidebarNavigation']), selectedFilter: { get() { return [parseBoolean(this.urlQuery?.include_archived)]; @@ -41,7 +41,9 @@ export default { - {{ $options.archivedFilterData.headerLabel }} + + {{ $options.archivedFilterData.headerLabel }} + - + - + {{ __('Apply') }} diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index 919bd2b2e49..dbd52978163 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -2,13 +2,15 @@ // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { HR_DEFAULT_CLASSES } from '../constants/index'; +import { HR_DEFAULT_CLASSES, SEARCH_TYPE_ADVANCED } from '../constants'; import { confidentialFilterData } from './confidentiality_filter/data'; import { statusFilterData } from './status_filter/data'; import ConfidentialityFilter from './confidentiality_filter/index.vue'; import { labelFilterData } from './label_filter/data'; +import { archivedFilterData } from './archived_filter/data'; import LabelFilter from './label_filter/index.vue'; import StatusFilter from './status_filter/index.vue'; +import ArchivedFilter from './archived_filter/index.vue'; import FiltersTemplate from './filters_template.vue'; @@ -19,11 +21,12 @@ export default { ConfidentialityFilter, LabelFilter, FiltersTemplate, + ArchivedFilter, }, mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentScope']), - ...mapState(['useSidebarNavigation']), + ...mapState(['useSidebarNavigation', 'searchType']), showConfidentialityFilter() { return Object.values(confidentialFilterData.scopes).includes(this.currentScope); }, @@ -33,7 +36,15 @@ export default { showLabelFilter() { return ( Object.values(labelFilterData.scopes).includes(this.currentScope) && - this.glFeatures.searchIssueLabelAggregation + this.glFeatures.searchIssueLabelAggregation && + this.searchType === SEARCH_TYPE_ADVANCED + ); + }, + showArchivedFilter() { + return ( + Object.values(archivedFilterData.scopes).includes(this.currentScope) && + this.glFeatures.searchIssuesHideArchivedProjects && + this.searchType === SEARCH_TYPE_ADVANCED ); }, showDivider() { @@ -52,6 +63,8 @@ export default { - + + + diff --git a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue index e8d5de4d769..a4c1119736f 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue @@ -57,7 +57,7 @@ export default { - + - diff --git a/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue b/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue new file mode 100644 index 00000000000..e966b8d877e --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/small_screen_drawer_navigation.vue @@ -0,0 +1,61 @@ + + + + + + + {{ $options.i18n.smallScreenFiltersDrawerHeader }} + + + + + + + + + + diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js index a68a0f75a2f..211bbaf82cd 100644 --- a/app/assets/javascripts/search/store/actions.js +++ b/app/assets/javascripts/search/store/actions.js @@ -138,7 +138,7 @@ export const setLabelFilterSearch = ({ commit }, { value }) => { export const fetchSidebarCount = ({ commit, state }) => { const promises = Object.values(state.navigation).map((navItem) => { // active nav item has count already so we skip it - if (!navItem.active) { + if (!navItem.active && navItem.count_link) { return axios .get(navItem.count_link) .then(({ data: { count } }) => { diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js index 65bb21f1b8a..b248681f053 100644 --- a/app/assets/javascripts/search/store/mutations.js +++ b/app/assets/javascripts/search/store/mutations.js @@ -33,7 +33,7 @@ export default { state.frequentItems[key] = data; }, [types.RECEIVE_NAVIGATION_COUNT](state, { key, count }) { - const item = { ...state.navigation[key], count }; + const item = { ...state.navigation[key], count, count_link: null }; state.navigation = { ...state.navigation, [key]: item }; }, [types.REQUEST_AGGREGATIONS](state) { diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index a6377480fa7..47701d0490a 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -4,6 +4,7 @@ @import './pages/groups'; @import './pages/hierarchy'; @import './pages/issues'; +@import './pages/labels'; @import './pages/note_form'; @import './pages/notes'; @import './pages/pipelines'; diff --git a/app/assets/stylesheets/page_bundles/labels.scss b/app/assets/stylesheets/pages/labels.scss similarity index 92% rename from app/assets/stylesheets/page_bundles/labels.scss rename to app/assets/stylesheets/pages/labels.scss index c5fabe0fd0c..29f2d15008b 100644 --- a/app/assets/stylesheets/page_bundles/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -1,5 +1,3 @@ -@import 'mixins_and_variables_and_functions'; - .suggest-colors { padding-top: 3px; @@ -31,19 +29,19 @@ margin-bottom: -5px; &:first-of-type { - border-top-left-radius: $gl-border-radius-base; + border-top-left-radius: $border-radius-base; } &:nth-of-type(7) { - border-top-right-radius: $gl-border-radius-base; + border-top-right-radius: $border-radius-base; } &:nth-last-child(7) { - border-bottom-left-radius: $gl-border-radius-base; + border-bottom-left-radius: $border-radius-base; } &:last-of-type { - border-bottom-right-radius: $gl-border-radius-base; + border-bottom-right-radius: $border-radius-base; } } } diff --git a/app/controllers/concerns/harbor/access.rb b/app/controllers/concerns/harbor/access.rb index 211566aeda7..9466952e98e 100644 --- a/app/controllers/concerns/harbor/access.rb +++ b/app/controllers/concerns/harbor/access.rb @@ -5,21 +5,13 @@ module Harbor extend ActiveSupport::Concern included do - before_action :harbor_registry_enabled! before_action :authorize_read_harbor_registry! - before_action do - push_frontend_feature_flag(:harbor_registry_integration) - end feature_category :integrations end private - def harbor_registry_enabled! - render_404 unless Feature.enabled?(:harbor_registry_integration, defined?(group) ? group : project) - end - def authorize_read_harbor_registry! raise NotImplementedError end diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 42e8e44c94c..0c3b7d26fe2 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -18,7 +18,7 @@ module Projects suite_endpoint: project_pipeline_test_path(project, pipeline, suite_name: 'suite', format: :json), blob_path: project_blob_path(project, pipeline.sha), has_test_report: pipeline.has_test_reports?, - empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'), + empty_state_image_path: image_path('illustrations/empty-todos-md.svg'), empty_dag_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), artifacts_expired_image_path: image_path('illustrations/pipeline.svg'), tests_count: pipeline.test_report_summary.total[:count] diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index ec6c85d888d..c4b1281fa72 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -107,7 +107,10 @@ module Ci partitioned_by :partition_id, strategy: :ci_sliding_list, next_partition_if: proc { false }, - detach_partition_if: proc { false } + detach_partition_if: proc { false }, + # Most of the db tasks are run in a weekly basis, e.g. execute_batched_migrations. + # Therefore, let's start with 1.week and see how it'd go. + analyze_interval: 1.week end end end diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb index d7182778b36..6581928f637 100644 --- a/app/models/concerns/require_email_verification.rb +++ b/app/models/concerns/require_email_verification.rb @@ -7,10 +7,7 @@ module RequireEmailVerification extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize - # This value is twice the amount we want it to be, because due to a bug in the devise-two-factor - # gem every failed login attempt increments the value of failed_attempts by 2 instead of 1. - # See: https://github.com/tinfoil/devise-two-factor/issues/127 - MAXIMUM_ATTEMPTS = 3 * 2 + MAXIMUM_ATTEMPTS = 3 UNLOCK_IN = 24.hours included do diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index a34f329e9ec..741643f7989 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -57,6 +57,10 @@ class BuildDetailsEntity < Ci::JobEntity using: JobArtifactReportEntity, if: -> (*) { can?(current_user, :read_build, build) } + expose :job_annotations, + as: :annotations, + using: Ci::JobAnnotationEntity + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build| erase_project_job_path(project, build) diff --git a/app/serializers/ci/job_annotation_entity.rb b/app/serializers/ci/job_annotation_entity.rb new file mode 100644 index 00000000000..8d7b2e21460 --- /dev/null +++ b/app/serializers/ci/job_annotation_entity.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Ci + class JobAnnotationEntity < Grape::Entity + expose :name + expose :data + end +end diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index b3d04e4f576..3d392a86566 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Labels") -- add_page_specific_style 'page_bundles/labels' = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card labels other-labels js-toggle-container js-admin-labels-container' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0' }) do |c| - c.with_header do diff --git a/app/views/admin/labels/new.html.haml b/app/views/admin/labels/new.html.haml index b5b6c1a32f4..c8c3fe7b9af 100644 --- a/app/views/admin/labels/new.html.haml +++ b/app/views/admin/labels/new.html.haml @@ -1,6 +1,4 @@ - page_title _("New Label") -- add_page_specific_style 'page_bundles/labels' - %h1.page-title.gl-font-size-h-display = _('New Label') = render 'shared/labels/form', url: admin_labels_path, back_path: admin_labels_path diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index e174d6318e9..6b4832d81aa 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -3,7 +3,6 @@ - search = params[:search] - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || search.present? || subscribed.present? -- add_page_specific_style 'page_bundles/labels' - if labels_or_filters #js-promote-label-modal diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml index b63b881ed6a..fd9aae987d4 100644 --- a/app/views/groups/labels/new.html.haml +++ b/app/views/groups/labels/new.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _("Labels"), group_labels_path(@group) - breadcrumb_title _("New") - page_title _("New Label") -- add_page_specific_style 'page_bundles/labels' %h1.page-title.gl-font-size-h-display = _('New Label') diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml index 57f78152159..64143502b77 100644 --- a/app/views/projects/issues/new.html.haml +++ b/app/views/projects/issues/new.html.haml @@ -1,5 +1,4 @@ - add_page_specific_style 'page_bundles/merge_request' -- add_page_specific_style 'page_bundles/labels' - add_to_breadcrumbs _("Issues"), project_issues_path(@project) - breadcrumb_title _("New") - page_title _("New Issue") diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 03086b19984..4b27b344498 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -3,7 +3,6 @@ - search = params[:search] - subscribed = params[:subscribed] - labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present? -- add_page_specific_style 'page_bundles/labels' - if labels_or_filters #js-promote-label-modal diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml index 1dfde0e73a1..7002da0b76a 100644 --- a/app/views/projects/labels/new.html.haml +++ b/app/views/projects/labels/new.html.haml @@ -1,7 +1,6 @@ - add_to_breadcrumbs _("Labels"), project_labels_path(@project) - breadcrumb_title _("New") - page_title _("New Label") -- add_page_specific_style 'page_bundles/labels' %h1.page-title.gl-font-size-h-display = _('New Label') diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml index 77579f06b50..f2c2700b012 100644 --- a/app/views/projects/merge_requests/creations/new.html.haml +++ b/app/views/projects/merge_requests/creations/new.html.haml @@ -4,7 +4,6 @@ - add_page_specific_style 'page_bundles/pipelines' - add_page_specific_style 'page_bundles/ci_status' - add_page_specific_style 'page_bundles/merge_request' -- add_page_specific_style 'page_bundles/labels' - conflicting_mr = @merge_request.existing_mrs_targeting_same_branch.first diff --git a/app/views/protected_branches/shared/_update_protected_branch.html.haml b/app/views/protected_branches/shared/_update_protected_branch.html.haml index ad61f557bb8..e4c8b779447 100644 --- a/app/views/protected_branches/shared/_update_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_update_protected_branch.html.haml @@ -3,17 +3,13 @@ %td.merge_access_levels-container = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", merge_access_levels.first&.access_level - = dropdown_tag((merge_access_levels.first&.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', - data: { field_name: "allowed_to_merge_#{protected_branch.id}", preselected_items: access_levels_data(merge_access_levels) }}) + .js-allowed-to-merge{ data: { preselected_items: access_levels_data(merge_access_levels).to_json } } = render_if_exists 'protected_branches/shared/user_merge_access_levels', protected_branch: protected_branch = render_if_exists 'protected_branches/shared/group_merge_access_levels', protected_branch: protected_branch %td.push_access_levels-container = hidden_field_tag "allowed_to_push_#{protected_branch.id}", push_access_levels.first&.access_level - = dropdown_tag((push_access_levels.first&.humanize || 'Select') , - options: { toggle_class: "js-allowed-to-push js-multiselect", dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', - data: { field_name: "allowed_to_push_#{protected_branch.id}", preselected_items: access_levels_data(push_access_levels) }}) + .js-allowed-to-push{ data: { preselected_items: access_levels_data(push_access_levels).to_json } } = render_if_exists 'protected_branches/shared/user_push_access_levels', protected_branch: protected_branch = render_if_exists 'protected_branches/shared/group_push_access_levels', protected_branch: protected_branch diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 7399f51d7f8..a1839b3dd39 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,3 +1,3 @@ .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden - = render partial: 'search/results_status' unless @search_objects.to_a.empty? + = render partial: 'search/results_status' = render partial: 'search/results_list' diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml index ff79f003e7d..fb96672cf99 100644 --- a/app/views/search/_results_list.html.haml +++ b/app/views/search/_results_list.html.haml @@ -8,7 +8,7 @@ - elsif @search_objects.blank? = render partial: "search/results/empty" - else - - statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : '' + - statusBarClass = !show_super_sidebar? ? 'gl-lg-pl-5' : '' .section{ class: statusBarClass } - if @scope == 'commits' diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index 6fc07d35296..8417b66eb34 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -1,28 +1,33 @@ -- return unless @search_service_presenter.show_results_status? -- statusBarClass = !show_super_sidebar? ? 'gl-md-pl-5' : '' +- statusBarClass = !show_super_sidebar? ? 'gl-lg-pl-5' : '' +- statusBarClass = statusBarClass + ' gl-lg-display-none' if @search_objects.to_a.empty? .section{ class: statusBarClass } .search-results-status .gl-display-flex.gl-flex-direction-column - .gl-p-5.gl-display-flex - .gl-md-display-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1.gl-white-space-nowrap.gl-max-w-full - - unless @search_service_presenter.without_count? - .gl-text-truncate - = search_entries_info(@search_objects, @scope, @search_term) - - unless @search_service_presenter.show_snippets? - - if @project - - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1 gl-text-truncate search-wrap-f-md-down') - - if @scope == 'blobs' - = _("in") - .mx-md-1 - #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } } - = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } - - else - = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } - - elsif @group - - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') - = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } - - if @search_service_presenter.show_sort_dropdown? - .gl-md-display-flex.gl-flex-direction-column - #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } - %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full + .gl-p-5.gl-display-flex.gl-flex-wrap + - unless @search_objects.to_a.empty? + .gl-display-flex.gl-text-left.gl-flex-grow-1.gl-flex-shrink-1.gl-white-space-nowrap.gl-flex-wrap.gl-sm-w-half + %p.gl-text-truncate.gl-my-auto + - unless @search_service_presenter.without_count? + = search_entries_info(@search_objects, @scope, @search_term) + - unless @search_service_presenter.show_snippets? + - if @project + - link_to_project = link_to(@project.full_name, @project, class: 'search-wrap-f-md-down') + - if @scope == 'blobs' + = _("in") + .mx-md-1.gl-my-auto + #js-blob-ref-switcher{ data: { "project-id" => @project.id, "ref" => repository_ref(@project), "field-name": "repository_ref" } } + %p.gl-text-truncate.gl-my-auto + = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project } + - else + = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project } + - elsif @group + - link_to_group = link_to(@group.name, @group, class: 'ml-md-1') + = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } + .gl-display-flex.gl-my-3.gl-flex-grow-1.gl-flex-shrink-1.gl-justify-content-end + = render Pajamas::ButtonComponent.new(category: 'primary', icon: 'filter', button_options: {id: 'js-open-mobile-filters', class: 'gl-lg-display-none'}) do + = s_('GlobalSearch|Filters') + - if @search_service_presenter.show_sort_dropdown? && !@search_objects.to_a.empty? + .gl-ml-3 + #js-search-sort{ data: { "search-sort-options" => search_sort_options.to_json } } + %hr.gl-mb-5.gl-mt-0.gl-border-gray-100.gl-w-full diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index e1fa0760d53..2fd6e4a5ca5 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -22,7 +22,7 @@ = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' } #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "elasticsearch-enabled": @search_service_presenter.advanced_search_enabled?.to_s, "default-branch-name": @project&.default_branch } } -.results.gl-md-display-flex.gl-mt-0 +.results.gl-lg-display-flex.gl-mt-0 #js-search-sidebar{ class: search_bar_classes, data: { navigation_json: search_navigation_json, search_type: search_service.search_type } } - if @search_term = render 'search/results' diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 882730f536d..e5aa4c58da1 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -15,6 +15,5 @@ - page_title("#{board.name}", _("Boards")) - add_page_specific_style 'page_bundles/boards' -- add_page_specific_style 'page_bundles/labels' #js-issuable-board-app{ data: board_data } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fe7fbb1328c..93e1a53ccb4 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -11,7 +11,6 @@ - is_merge_request = issuable_type === 'merge_request' - moved_sidebar_enabled = moved_mr_sidebar_enabled? - is_merge_request_with_flag = is_merge_request && moved_sidebar_enabled -- add_page_specific_style 'page_bundles/labels' %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { always_show_toggle: true, signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" } diff --git a/config/application.rb b/config/application.rb index 1cd3b4622ad..7bce5afa1af 100644 --- a/config/application.rb +++ b/config/application.rb @@ -361,7 +361,6 @@ module Gitlab config.assets.precompile << "page_bundles/wiki.css" config.assets.precompile << "page_bundles/work_items.css" config.assets.precompile << "page_bundles/xterm.css" - config.assets.precompile << "page_bundles/labels.css" config.assets.precompile << "lazy_bundles/cropper.css" config.assets.precompile << "lazy_bundles/gridstack.css" config.assets.precompile << "performance_bar.css" diff --git a/config/feature_flags/development/harbor_registry_integration.yml b/config/feature_flags/development/database_analyze_on_partitioned_tables.yml similarity index 61% rename from config/feature_flags/development/harbor_registry_integration.yml rename to config/feature_flags/development/database_analyze_on_partitioned_tables.yml index f1786f53c19..1d11d3dade3 100644 --- a/config/feature_flags/development/harbor_registry_integration.yml +++ b/config/feature_flags/development/database_analyze_on_partitioned_tables.yml @@ -1,8 +1,8 @@ --- -name: harbor_registry_integration -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81593 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353595 -milestone: '14.9' +name: database_analyze_on_partitioned_tables +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/130599 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/423959 +milestone: '16.4' type: development -group: group::container registry +group: group::database default_enabled: false diff --git a/config/initializers/8_devise.rb b/config/initializers/8_devise.rb index 237231f544f..3682a391033 100644 --- a/config/initializers/8_devise.rb +++ b/config/initializers/8_devise.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true +require_dependency 'gitlab/auth/devise/strategies/combined_two_factor_authenticatable' + # Use this hook to configure devise mailer, warden hooks and so forth. The first # four configuration values can also be set straight in your models. Devise.setup do |config| config.warden do |manager| - manager.default_strategies(scope: :user).unshift :two_factor_authenticatable - manager.default_strategies(scope: :user).unshift :two_factor_backupable + user_scoped_strategies = manager.default_strategies(scope: :user) + user_scoped_strategies.delete :two_factor_backupable + user_scoped_strategies.delete :two_factor_authenticatable + user_scoped_strategies.unshift :combined_two_factor_authenticatable end # This is the default. This makes it explicit that Devise loads routes diff --git a/doc/administration/appearance.md b/doc/administration/appearance.md index f81c0a2afb1..ceb4a4a30e9 100644 --- a/doc/administration/appearance.md +++ b/doc/administration/appearance.md @@ -29,7 +29,7 @@ supported by many email clients. ## Favicon -By default, the favicon (used by the browser as the tab icon, as well as the CI status icon) +By default, the favicon (used by the browser as the tab icon and the CI status icon) uses the GitLab logo. This can be customized with any icon desired. It must be a 32x32 `.png` or `.ico` image. @@ -42,7 +42,7 @@ of the page to activate it in the GitLab instance. You can add a small header message, a small footer message, or both, to the interface of your GitLab instance. These messages appear on all projects and pages of the -instance, including the sign in / sign up page. The default color is white text on +instance, including the sign-in/sign-up page. The default color is white text on an orange background, but this can be customized by selecting **Customize colors**. Limited [Markdown](../user/markdown.md) is supported, such as bold, italics, and links, for @@ -55,9 +55,9 @@ the header and footer added to all emails sent by the GitLab instance. After you add a message, select **Update appearance settings** at the bottom of the page to activate it in the GitLab instance. -## Sign in / Sign up pages +## Sign-in / Sign-up pages -You can replace the default message on the sign in / sign up page with your own message +You can replace the default message on the sign-in/sign-up page with your own message and logo. You can make full use of [Markdown](../user/markdown.md) in the description. The optimal size for the logo is 128 x 128 pixels, but any image can be used (below 1 MB) @@ -69,7 +69,7 @@ to activate it in the GitLab instance. You can also select **Sign-in page**, to review the saved appearance settings: NOTE: -You can add also add a [customized hcelp message](settings/help_page.md) below the sign in message or add [a Sign in text message](settings/sign_in_restrictions.md#sign-in-information). +You can add also add a [customized help message](settings/help_page.md) below the sign-in message or add [a Sign-in text message](settings/sign_in_restrictions.md#sign-in-information). ## Progressive Web App diff --git a/doc/user/permissions.md b/doc/user/permissions.md index ac86ea36b5c..246537216b7 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -477,6 +477,7 @@ To work around the issue, give these users the Guest role or higher to any proje > - The ability for a custom role to view a vulnerability report [introduced](https://gitlab.com/groups/gitlab-org/-/epics/10160) in GitLab 16.1 [with a flag](../administration/feature_flags.md) named `custom_roles_vulnerability`. > - Ability to view a vulnerability report [enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123835) in GitLab 16.1. > - [Feature flag `custom_roles_vulnerability` removed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124049) in GitLab 16.2. +> - Ability to create and remove a custom role with the UI [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/393235) in GitLab 16.4. Custom roles allow group members who are assigned the Owner role to create roles specific to the needs of their organization. @@ -502,12 +503,48 @@ This does not apply to the Guest+1 custom role because the `view_code` ability i ### Create a custom role -To enable custom roles for your group, a group member with the Owner role: +Prerequisites: -1. Makes sure that there is at least one private project in this group or one of - its subgroups, so that you can see the effect of giving a Guest a custom role. -1. Creates a personal access token with the API scope. -1. Uses [the API](../api/member_roles.md#add-a-member-role-to-a-group) to create a custom role for the root group. +- You must be an administrator for the self-managed instance, or have the Owner + role in the group you are creating the custom role in. +- The group must be in the Ultimate tier. +- You must have: + - At least one private project so that you can see the effect of giving a + user with the Guest role a custom role. The project can be in the group itself + or one of that group's subgroups. + - A [personal access token with the API scope](profile/personal_access_tokens.md#create-a-personal-access-token). + +#### GitLab SaaS + +Prerequisite: + +- You must have the Owner role in the group you are creating the custom role in. + +1. On the left sidebar, select **Search or go to** and find your group. +1. Select **Settings > Roles and Permissions**. +1. Select **Add new role**. +1. In **Base role to use as template**, select **Guest**. +1. In **Role name**, enter the custom role's title. +1. Select the **Permissions** for the new custom role. +1. Select **Create new role**. + +#### GitLab.com + +Prerequisite: + +- You must be an administrator for the self-managed instance you are creating the custom role in. + +1. On the left sidebar, select **Search or go to**. +1. Select **Admin Area**. +1. Select **Settings > Roles and Permissions**. +1. From the top dropdown list, select the group you want to create a custom role in. +1. Select **Add new role**. +1. In **Base role to use as template**, select **Guest**. +1. In **Role name**, enter the custom role's title. +1. Select the **Permissions** for the new custom role. +1. Select **Create new role**. + +To create a custom role, you can also [use the API](../api/member_roles.md#add-a-member-role-to-a-group). #### Custom role requirements @@ -567,16 +604,45 @@ Now the user is a regular Guest. ### Remove a custom role -Removing a custom role also removes all members with that custom role from -the group. If you decide to delete a custom role, you must re-add any users with that custom -role to the group. +Prerequisite: -To remove a custom role from a group, a group member with -the Owner role: +- You must have the Owner role in the group you are removing the custom role from. +- No group members have that custom role. -1. Optional. If the Owner does not know the `ID` of a custom - role, finds that `ID` by making an [API request](../api/member_roles.md#list-all-member-roles-of-a-group). -1. Uses [the API](../api/member_roles.md#remove-member-role-of-a-group) to delete the custom role. +You cannot remove a custom role from a group until there are no group members with +that custom role. + +To do this, you can either remove the custom role from all group members with that +custom role, or remove those members from the group. You complete both of these actions +from the group members page: + +1. On the left sidebar, select **Search or go to** and find your group. +1. Select **Manage > Members**. + +To remove a custom role from a group member with that custom role: + +1. On the member row you want to remove, in the **Max role** column, select the + dropdown list to change the role for the group member. + +To remove a member with a custom role from the group: + +1. On the member row you want to remove, select the vertical ellipsis + (**{ellipsis_v}**) and select **Remove member**. +1. In the **Remove member** confirmation dialog, do not select any checkboxes. +1. Select **Remove member**. + +After you have made sure no group members have that custom role, delete the +custom role. + +1. On the left sidebar, select **Search or go to**. +1. GitLab.com only. Select **Admin Area**. +1. Select **Settings > Roles and Permissions**. +1. Select **Custom Roles**. +1. In the **Actions** column, select **Delete role** (**{remove}**) and confirm. + +To delete a custom role, you can also [use the API](../api/member_roles.md#remove-member-role-of-a-group). +To use the API, you must know the `ID` of the custom role. If you do not know this +`ID`, find it by making an [API request](../api/member_roles.md#list-all-member-roles-of-a-group). ### Known issues diff --git a/lib/api/helpers/kubernetes/agent_helpers.rb b/lib/api/helpers/kubernetes/agent_helpers.rb new file mode 100644 index 00000000000..50a8c2a5aed --- /dev/null +++ b/lib/api/helpers/kubernetes/agent_helpers.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module API + module Helpers + module Kubernetes + module AgentHelpers + include Gitlab::Utils::StrongMemoize + + def authenticate_gitlab_kas_request! + render_api_error!('KAS JWT authentication invalid', 401) unless Gitlab::Kas.verify_api_request(headers) + end + + def agent_token + cluster_agent_token_from_authorization_token + end + strong_memoize_attr :agent_token + + def agent + agent_token.agent + end + strong_memoize_attr :agent + + def gitaly_info(project) + gitaly_features = Feature::Gitaly.server_feature_flags + + Gitlab::GitalyClient.connection_data(project.repository_storage).merge(features: gitaly_features) + end + + def gitaly_repository(project) + project.repository.gitaly_repository.to_h + end + + def check_feature_enabled + not_found!('Internal API not found') unless Feature.enabled?(:kubernetes_agent_internal_api, type: :ops) + end + + def check_agent_token + unauthorized! unless agent_token + + ::Clusters::AgentTokens::TrackUsageService.new(agent_token).execute + end + + def agent_has_access_to_project?(project) + Guest.can?(:download_code, project) || agent.has_access_to?(project) + end + + def increment_unique_events + events = params[:unique_counters]&.slice( + :agent_users_using_ci_tunnel, + :k8s_api_proxy_requests_unique_users_via_ci_access, :k8s_api_proxy_requests_unique_agents_via_ci_access, + :k8s_api_proxy_requests_unique_users_via_user_access, :k8s_api_proxy_requests_unique_agents_via_user_access, + :k8s_api_proxy_requests_unique_users_via_pat_access, :k8s_api_proxy_requests_unique_agents_via_pat_access, + :flux_git_push_notified_unique_projects + ) + + events&.each do |event, entity_ids| + increment_unique_values(event, entity_ids) + end + end + + def increment_count_events + events = params[:counters]&.slice( + :gitops_sync, :k8s_api_proxy_request, :flux_git_push_notifications_total, + :k8s_api_proxy_requests_via_ci_access, :k8s_api_proxy_requests_via_user_access, + :k8s_api_proxy_requests_via_pat_access + ) + + Gitlab::UsageDataCounters::KubernetesAgentCounter.increment_event_counts(events) + end + + def update_configuration(agent:, config:) + ::Clusters::Agents::Authorizations::CiAccess::RefreshService.new(agent, config: config).execute + ::Clusters::Agents::Authorizations::UserAccess::RefreshService.new(agent, config: config).execute + end + + def retrieve_user_from_session_cookie + # Load session + public_session_id_string = + begin + Gitlab::Kas::UserAccess.decrypt_public_session_id(params[:access_key]) + rescue StandardError + bad_request!('Invalid access_key') + end + + session_id = Rack::Session::SessionId.new(public_session_id_string) + session = ActiveSession.sessions_from_ids([session_id.private_id]).first + unauthorized!('Invalid session') unless session + + # CSRF check + unless ::Gitlab::Kas::UserAccess.valid_authenticity_token?(session.symbolize_keys, params[:csrf_token]) + unauthorized!('CSRF token does not match') + end + + # Load user + user = Warden::SessionSerializer.new('rack.session' => session).fetch(:user) + unauthorized!('Invalid user in session') unless user + user + end + + def retrieve_user_from_personal_access_token + return unless access_token.present? + + validate_access_token!(scopes: [Gitlab::Auth::K8S_PROXY_SCOPE]) + + ::PersonalAccessTokens::LastUsedService.new(access_token).execute + + access_token.user || raise(UnauthorizedError) + end + + def access_token + return unless params[:access_key].present? + + PersonalAccessToken.find_by_token(params[:access_key]) + end + strong_memoize_attr :access_token + end + end + end +end diff --git a/lib/api/internal/kubernetes.rb b/lib/api/internal/kubernetes.rb index 3372fc2fcf3..a88c8b69b81 100644 --- a/lib/api/internal/kubernetes.rb +++ b/lib/api/internal/kubernetes.rb @@ -9,119 +9,7 @@ module API authenticate_gitlab_kas_request! end - helpers do - include Gitlab::Utils::StrongMemoize - - def authenticate_gitlab_kas_request! - render_api_error!('KAS JWT authentication invalid', 401) unless Gitlab::Kas.verify_api_request(headers) - end - - def agent_token - @agent_token ||= cluster_agent_token_from_authorization_token - end - - def agent - @agent ||= agent_token.agent - end - - def repo_type - Gitlab::GlRepository::PROJECT - end - - def gitaly_info(project) - gitaly_features = Feature::Gitaly.server_feature_flags - - Gitlab::GitalyClient.connection_data(project.repository_storage).merge(features: gitaly_features) - end - - def gitaly_repository(project) - project.repository.gitaly_repository.to_h - end - - def check_feature_enabled - not_found!('Internal API not found') unless Feature.enabled?(:kubernetes_agent_internal_api, type: :ops) - end - - def check_agent_token - unauthorized! unless agent_token - - ::Clusters::AgentTokens::TrackUsageService.new(agent_token).execute - end - - def agent_has_access_to_project?(project) - Guest.can?(:download_code, project) || agent.has_access_to?(project) - end - - def increment_unique_events - events = params[:unique_counters]&.slice( - :agent_users_using_ci_tunnel, - :k8s_api_proxy_requests_unique_users_via_ci_access, :k8s_api_proxy_requests_unique_agents_via_ci_access, - :k8s_api_proxy_requests_unique_users_via_user_access, :k8s_api_proxy_requests_unique_agents_via_user_access, - :k8s_api_proxy_requests_unique_users_via_pat_access, :k8s_api_proxy_requests_unique_agents_via_pat_access, - :flux_git_push_notified_unique_projects - ) - - events&.each do |event, entity_ids| - increment_unique_values(event, entity_ids) - end - end - - def increment_count_events - events = params[:counters]&.slice( - :gitops_sync, :k8s_api_proxy_request, :flux_git_push_notifications_total, - :k8s_api_proxy_requests_via_ci_access, :k8s_api_proxy_requests_via_user_access, - :k8s_api_proxy_requests_via_pat_access - ) - - Gitlab::UsageDataCounters::KubernetesAgentCounter.increment_event_counts(events) - end - - def update_configuration(agent:, config:) - ::Clusters::Agents::Authorizations::CiAccess::RefreshService.new(agent, config: config).execute - ::Clusters::Agents::Authorizations::UserAccess::RefreshService.new(agent, config: config).execute - end - - def retrieve_user_from_session_cookie - # Load session - public_session_id_string = - begin - Gitlab::Kas::UserAccess.decrypt_public_session_id(params[:access_key]) - rescue StandardError - bad_request!('Invalid access_key') - end - - session_id = Rack::Session::SessionId.new(public_session_id_string) - session = ActiveSession.sessions_from_ids([session_id.private_id]).first - unauthorized!('Invalid session') unless session - - # CSRF check - unless ::Gitlab::Kas::UserAccess.valid_authenticity_token?(session.symbolize_keys, params[:csrf_token]) - unauthorized!('CSRF token does not match') - end - - # Load user - user = Warden::SessionSerializer.new('rack.session' => session).fetch(:user) - unauthorized!('Invalid user in session') unless user - user - end - - def retrieve_user_from_personal_access_token - return unless access_token.present? - - validate_access_token!(scopes: [Gitlab::Auth::K8S_PROXY_SCOPE]) - - ::PersonalAccessTokens::LastUsedService.new(access_token).execute - - access_token.user || raise(UnauthorizedError) - end - - def access_token - return unless params[:access_key].present? - - PersonalAccessToken.find_by_token(params[:access_key]) - end - strong_memoize_attr :access_token - end + helpers ::API::Helpers::Kubernetes::AgentHelpers namespace 'internal' do namespace 'kubernetes' do diff --git a/lib/bulk_imports/projects/pipelines/references_pipeline.rb b/lib/bulk_imports/projects/pipelines/references_pipeline.rb index 36ef28c72d8..e2032569ab5 100644 --- a/lib/bulk_imports/projects/pipelines/references_pipeline.rb +++ b/lib/bulk_imports/projects/pipelines/references_pipeline.rb @@ -40,11 +40,14 @@ module BulkImports private def mapped_usernames - @mapped_usernames ||= ::BulkImports::UsersMapper.new(context: context).map_usernames + @mapped_usernames ||= ::BulkImports::UsersMapper.new(context: context) + .map_usernames.transform_keys { |key| "@#{key}" } + .transform_values { |value| "@#{value}" } end def username_regex(mapped_usernames) - @username_regex ||= Regexp.new(mapped_usernames.keys.map { |x| Regexp.escape(x) }.join('|')) + @username_regex ||= Regexp.new(mapped_usernames.keys.sort_by(&:length) + .reverse.map { |x| Regexp.escape(x) }.join('|')) end def add_matching_objects(collection, enum) diff --git a/lib/gitlab/auth/devise/strategies/combined_two_factor_authenticatable.rb b/lib/gitlab/auth/devise/strategies/combined_two_factor_authenticatable.rb new file mode 100644 index 00000000000..ef326fd2a99 --- /dev/null +++ b/lib/gitlab/auth/devise/strategies/combined_two_factor_authenticatable.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module Devise + module Strategies + # This strategy combines the following strategies from + # devise_two_factor gem: + # - TwoFactorAuthenticatable: https://github.com/devise-two-factor/devise-two-factor/blob/v4.0.2/lib/devise_two_factor/strategies/two_factor_authenticatable.rb + # - TwoFactorBackupable: https://github.com/devise-two-factor/devise-two-factor/blob/v4.0.2/lib/devise_two_factor/strategies/two_factor_backupable.rb + # to avoid double incrementing failed login attempts counter by each + # strategy in case an incorrect password is provided. + class CombinedTwoFactorAuthenticatable < ::Devise::Strategies::DatabaseAuthenticatable + def authenticate! + resource = mapping.to.find_for_database_authentication(authentication_hash) + + # We check the OTP / backup code, then defer to DatabaseAuthenticatable + is_valid = validate(resource) do + validate_otp(resource) || resource.invalidate_otp_backup_code!(params[scope]['otp_attempt']) + end + + if is_valid + # Devise fails to authenticate invalidated resources, but if we've + # gotten here, the object changed (Since we deleted a recovery code) + resource.save! + + super + end + + fail(::Devise.paranoid ? :invalid : :not_found_in_database) unless resource # rubocop: disable Style/SignalException + + # We want to cascade to the next strategy if this one fails, + # but database authenticatable automatically halts on a bad password + @halted = false if @result == :failure + end + + def validate_otp(resource) + return true unless resource.otp_required_for_login + + return if params[scope]['otp_attempt'].nil? + + resource.validate_and_consume_otp!(params[scope]['otp_attempt']) + end + end + end + end + end +end + +Warden::Strategies.add( + :combined_two_factor_authenticatable, + Gitlab::Auth::Devise::Strategies::CombinedTwoFactorAuthenticatable) diff --git a/lib/gitlab/auth/o_auth/provider.rb b/lib/gitlab/auth/o_auth/provider.rb index 2ce8677c8b7..e9f8c4870e1 100644 --- a/lib/gitlab/auth/o_auth/provider.rb +++ b/lib/gitlab/auth/o_auth/provider.rb @@ -33,7 +33,7 @@ module Gitlab end def self.providers - Devise.omniauth_providers + ::Devise.omniauth_providers end def self.enabled?(name) diff --git a/lib/gitlab/database/partitioning/monthly_strategy.rb b/lib/gitlab/database/partitioning/monthly_strategy.rb index 0f08a47d754..e6af4ac4574 100644 --- a/lib/gitlab/database/partitioning/monthly_strategy.rb +++ b/lib/gitlab/database/partitioning/monthly_strategy.rb @@ -4,18 +4,21 @@ module Gitlab module Database module Partitioning class MonthlyStrategy - attr_reader :model, :partitioning_key, :retain_for, :retain_non_empty_partitions + attr_reader :model, :partitioning_key, :retain_for, :retain_non_empty_partitions, :analyze_interval # We create this many partitions in the future HEADROOM = 6.months delegate :table_name, to: :model - def initialize(model, partitioning_key, retain_for: nil, retain_non_empty_partitions: false) + def initialize( + model, partitioning_key, retain_for: nil, retain_non_empty_partitions: false, + analyze_interval: nil) @model = model @partitioning_key = partitioning_key @retain_for = retain_for @retain_non_empty_partitions = retain_non_empty_partitions + @analyze_interval = analyze_interval end def current_partitions diff --git a/lib/gitlab/database/partitioning/partition_manager.rb b/lib/gitlab/database/partitioning/partition_manager.rb index 124fae582d3..3fc694beeff 100644 --- a/lib/gitlab/database/partitioning/partition_manager.rb +++ b/lib/gitlab/database/partitioning/partition_manager.rb @@ -4,9 +4,12 @@ module Gitlab module Database module Partitioning class PartitionManager + include ::Gitlab::Utils::StrongMemoize + UnsafeToDetachPartitionError = Class.new(StandardError) - LEASE_TIMEOUT = 1.minute + LEASE_TIMEOUT = 1.hour + STATEMENT_TIMEOUT = 1.hour MANAGEMENT_LEASE_KEY = 'database_partition_management_%s' RETAIN_DETACHED_PARTITIONS_FOR = 1.week @@ -33,6 +36,8 @@ module Gitlab create(partitions_to_create) unless partitions_to_create.empty? detach(partitions_to_detach) unless partitions_to_detach.empty? + + run_analyze_on_partitioned_table end rescue ArgumentError => e Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) @@ -146,6 +151,50 @@ module Gitlab connection_name: @connection_name ) end + + def run_analyze_on_partitioned_table + return if Feature.disabled?(:database_analyze_on_partitioned_tables) + return if ineligible_for_analyzing? + + set_analyze_statement_timeout do + # Running ANALYZE on partitioned table will go through itself and its partitions + connection.execute("ANALYZE VERBOSE #{model.quoted_table_name}") + end + end + + def ineligible_for_analyzing? + first_model_partition.blank? || analyze_interval.blank? || last_analyzed_at_within_interval? + end + + def last_analyzed_at_within_interval? + table_to_query = first_model_partition.identifier + + # We don't need to get the last_analyze_time from partitioned table, + # because it's not supported and always returns NULL for PG version below 14 + # Therefore, we can always get the last_analyze_time from the first partition + last_analyzed_at = connection.select_value( + "SELECT pg_stat_get_last_analyze_time('#{table_to_query}'::regclass)" + ) + last_analyzed_at.present? && last_analyzed_at >= Time.current - analyze_interval + end + + def first_model_partition + Gitlab::Database::SharedModel.using_connection(connection) do + Gitlab::Database::PostgresPartition.for_parent_table(model.table_name).first + end + end + strong_memoize_attr :first_model_partition + + def analyze_interval + model.partitioning_strategy.analyze_interval + end + + def set_analyze_statement_timeout + connection.execute(format("SET statement_timeout TO '%ds'", STATEMENT_TIMEOUT)) + yield + ensure + connection.execute('RESET statement_timeout') + end end end end diff --git a/lib/gitlab/database/partitioning/sliding_list_strategy.rb b/lib/gitlab/database/partitioning/sliding_list_strategy.rb index 8f8afdfc551..77997e93480 100644 --- a/lib/gitlab/database/partitioning/sliding_list_strategy.rb +++ b/lib/gitlab/database/partitioning/sliding_list_strategy.rb @@ -4,15 +4,16 @@ module Gitlab module Database module Partitioning class SlidingListStrategy - attr_reader :model, :partitioning_key, :next_partition_if, :detach_partition_if + attr_reader :model, :partitioning_key, :next_partition_if, :detach_partition_if, :analyze_interval delegate :table_name, to: :model - def initialize(model, partitioning_key, next_partition_if:, detach_partition_if:) + def initialize(model, partitioning_key, next_partition_if:, detach_partition_if:, analyze_interval: nil) @model = model @partitioning_key = partitioning_key @next_partition_if = next_partition_if @detach_partition_if = detach_partition_if + @analyze_interval = analyze_interval ensure_partitioning_column_ignored_or_readonly! end diff --git a/lib/sidebars/groups/menus/packages_registries_menu.rb b/lib/sidebars/groups/menus/packages_registries_menu.rb index b2d6aec1a78..19a6e210231 100644 --- a/lib/sidebars/groups/menus/packages_registries_menu.rb +++ b/lib/sidebars/groups/menus/packages_registries_menu.rb @@ -57,8 +57,7 @@ module Sidebars end def harbor_registry_menu_item - if Feature.disabled?(:harbor_registry_integration) || - context.group.harbor_integration.nil? || + if context.group.harbor_integration.nil? || !context.group.harbor_integration.activated? return nil_menu_item(:harbor_registry) end diff --git a/lib/sidebars/projects/menus/packages_registries_menu.rb b/lib/sidebars/projects/menus/packages_registries_menu.rb index 053ce5e82fd..5a378d5f9a8 100644 --- a/lib/sidebars/projects/menus/packages_registries_menu.rb +++ b/lib/sidebars/projects/menus/packages_registries_menu.rb @@ -75,8 +75,7 @@ module Sidebars end def harbor_registry_menu_item - if Feature.disabled?(:harbor_registry_integration, context.project) || - context.project.harbor_integration.nil? || + if context.project.harbor_integration.nil? || !context.project.harbor_integration.activated? return ::Sidebars::NilMenuItem.new(item_id: :harbor_registry) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 48c759dd8b9..508e3acb4d6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1969,12 +1969,24 @@ msgstr "" msgid "AI|GitLab Duo" msgstr "" +msgid "AI|Give feedback on AI content" +msgstr "" + +msgid "AI|Give feedback to improve this answer." +msgstr "" + msgid "AI|Has no support and might not be documented" msgstr "" msgid "AI|Helpful" msgstr "" +msgid "AI|How could the content be improved?" +msgstr "" + +msgid "AI|How was the AI content?" +msgstr "" + msgid "AI|I don't see how I can help. Please give better instructions!" msgstr "" @@ -2002,6 +2014,9 @@ msgstr "" msgid "AI|Something went wrong. Please try again later" msgstr "" +msgid "AI|Thank you for your feedback." +msgstr "" + msgid "AI|The container element wasn't found, stopping AI Genie." msgstr "" @@ -2017,6 +2032,9 @@ msgstr "" msgid "AI|Third-party AI services" msgstr "" +msgid "AI|To help improve the quality of the content, send your feedback to GitLab team members." +msgstr "" + msgid "AI|Unhelpful" msgstr "" @@ -2422,6 +2440,9 @@ msgstr "" msgid "AbuseReport|View screenshot" msgstr "" +msgid "Abusive or offensive" +msgstr "" + msgid "Accept invitation" msgstr "" @@ -19549,6 +19570,9 @@ msgstr "" msgid "Facebook" msgstr "" +msgid "Factually incorrect" +msgstr "" + msgid "Fail" msgstr "" @@ -21838,6 +21862,9 @@ msgstr "" msgid "GlobalSearch|Fetching aggregations error." msgstr "" +msgid "GlobalSearch|Filters" +msgstr "" + msgid "GlobalSearch|Group" msgstr "" @@ -23396,6 +23423,9 @@ msgstr "" msgid "Help translate to your language" msgstr "" +msgid "Helpful" +msgstr "" + msgid "Helps prevent bots from brute-force attacks." msgstr "" @@ -26903,6 +26933,9 @@ msgstr "" msgid "Job|Erase job log and artifacts" msgstr "" +msgid "Job|External links" +msgstr "" + msgid "Job|Failed" msgstr "" @@ -39719,9 +39752,6 @@ msgstr "" msgid "Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}" msgstr "" -msgid "RepositorySettingsAccessLevel|Select" -msgstr "" - msgid "Request" msgstr "" @@ -44668,6 +44698,9 @@ msgstr "" msgid "Someone, hopefully you, has requested to reset the password for your GitLab account on %{link_to_gitlab}." msgstr "" +msgid "Something else" +msgstr "" + msgid "Something went wrong" msgstr "" @@ -49477,6 +49510,9 @@ msgstr "" msgid "Tomorrow" msgstr "" +msgid "Too long" +msgstr "" + msgid "Too many namespaces enabled. Manage them through the console or the API." msgstr "" @@ -50249,6 +50285,9 @@ msgstr "" msgid "Unhappy?" msgstr "" +msgid "Unhelpful or irrelevant" +msgstr "" + msgid "Units|d" msgstr "" diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index 034aa6ab020..6a38f0c59a8 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -18,7 +18,6 @@ RSpec.describe 'Group navbar', :with_license, feature_category: :navigation do stub_config(dependency_proxy: { enabled: false }) stub_config(registry: { enabled: false }) - stub_feature_flags(harbor_registry_integration: false) stub_feature_flags(observability_group_tab: false) stub_group_wikis(false) group.add_maintainer(user) @@ -87,8 +86,6 @@ RSpec.describe 'Group navbar', :with_license, feature_category: :navigation do before do group.update!(harbor_integration: harbor_integration) - stub_feature_flags(harbor_registry_integration: true) - insert_harbor_registry_nav(_('Package Registry')) visit group_path(group) diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index b7598f05d18..e967c1be3bc 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -15,7 +15,6 @@ RSpec.describe 'Project navbar', :with_license, feature_category: :groups_and_pr sign_in(user) stub_config(registry: { enabled: false }) - stub_feature_flags(harbor_registry_integration: false) stub_feature_flags(ml_experiment_tracking: false) insert_package_nav(_('Deployments')) insert_infrastructure_registry_nav @@ -87,8 +86,6 @@ RSpec.describe 'Project navbar', :with_license, feature_category: :groups_and_pr let_it_be(:harbor_integration) { create(:harbor_integration, project: project) } before do - stub_feature_flags(harbor_registry_integration: true) - insert_harbor_registry_nav(_('Terraform modules')) visit project_path(project) diff --git a/spec/features/users/email_verification_on_login_spec.rb b/spec/features/users/email_verification_on_login_spec.rb index 758d5cdc571..d83040efd72 100644 --- a/spec/features/users/email_verification_on_login_spec.rb +++ b/spec/features/users/email_verification_on_login_spec.rb @@ -234,8 +234,7 @@ RSpec.describe 'Email Verification On Login', :clean_gitlab_redis_rate_limiting, describe 'when failing to login the maximum allowed number of times' do before do - # See comment in RequireEmailVerification::MAXIMUM_ATTEMPTS on why this is divided by 2 - (RequireEmailVerification::MAXIMUM_ATTEMPTS / 2).times do + RequireEmailVerification::MAXIMUM_ATTEMPTS.times do gitlab_sign_in(user, password: 'wrong_password') end end @@ -345,7 +344,7 @@ RSpec.describe 'Email Verification On Login', :clean_gitlab_redis_rate_limiting, before do perform_enqueued_jobs do - (User.maximum_attempts / 2).times do + User.maximum_attempts.times do gitlab_sign_in(user, password: 'wrong_password') end end diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 2b174d7b9f1..e32f37dd641 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -286,6 +286,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ enter_code(code, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled) expect(page).to have_content('Invalid two-factor code.') + expect(user.reload.failed_attempts).to eq(1) end end end @@ -576,7 +577,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end end - context 'with invalid username and password' do + context 'with correct username and invalid password' do let(:user) { create(:user, :no_super_sidebar) } it 'blocks invalid login' do @@ -588,6 +589,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ expect_single_session_with_short_ttl expect(page).to have_content('Invalid login or password.') + expect(user.reload.failed_attempts).to eq(1) end end end diff --git a/spec/frontend/ci/job_details/components/sidebar/external_links_block_spec.js b/spec/frontend/ci/job_details/components/sidebar/external_links_block_spec.js new file mode 100644 index 00000000000..1f2c448f1c6 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/external_links_block_spec.js @@ -0,0 +1,49 @@ +import { GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ExternalLinksBlock from '~/ci/job_details/components/sidebar/external_links_block.vue'; + +describe('External links block', () => { + let wrapper; + + const createWrapper = (propsData) => { + wrapper = mountExtended(ExternalLinksBlock, { + propsData: { + ...propsData, + }, + }); + }; + + const findAllLinks = () => wrapper.findAllComponents(GlLink); + const findLink = () => findAllLinks().at(0); + + it('renders a list of links', () => { + createWrapper({ + externalLinks: [ + { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + ], + }); + + expect(findAllLinks()).toHaveLength(2); + }); + + it('renders a link', () => { + createWrapper({ + externalLinks: [ + { + label: 'Example URL', + url: 'https://example.com/', + }, + ], + }); + + expect(findLink().text()).toBe('Example URL'); + expect(findLink().attributes('href')).toBe('https://example.com/'); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js index 31767b1d0df..88e1f41b270 100644 --- a/spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js @@ -5,6 +5,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import ArtifactsBlock from '~/ci/job_details/components/sidebar/artifacts_block.vue'; +import ExternalLinksBlock from '~/ci/job_details/components/sidebar/external_links_block.vue'; import JobRetryForwardDeploymentModal from '~/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue'; import JobsContainer from '~/ci/job_details/components/sidebar/jobs_container.vue'; import Sidebar from '~/ci/job_details/components/sidebar/sidebar.vue'; @@ -20,6 +21,7 @@ describe('Sidebar details block', () => { const forwardDeploymentFailure = 'forward_deployment_failure'; const findModal = () => wrapper.findComponent(JobRetryForwardDeploymentModal); const findArtifactsBlock = () => wrapper.findComponent(ArtifactsBlock); + const findExternalLinksBlock = () => wrapper.findComponent(ExternalLinksBlock); const findJobStagesDropdown = () => wrapper.findComponent(StagesDropdown); const findJobsContainer = () => wrapper.findComponent(JobsContainer); @@ -181,4 +183,40 @@ describe('Sidebar details block', () => { expect(findArtifactsBlock().exists()).toBe(true); }); }); + + describe('external links', () => { + beforeEach(() => { + createWrapper(); + }); + + it('external links block is not shown if there are no external links', () => { + expect(findExternalLinksBlock().exists()).toBe(false); + }); + + it('external links block is shown if there are external links', async () => { + store.state.job.annotations = [ + { + name: 'external_links', + data: [ + { + external_link: { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + }, + { + external_link: { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + }, + ], + }, + ]; + + await nextTick(); + + expect(findExternalLinksBlock().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/ci/job_details/utils_spec.js b/spec/frontend/ci/job_details/utils_spec.js index 37c3e60f3cf..7b5a97f3939 100644 --- a/spec/frontend/ci/job_details/utils_spec.js +++ b/spec/frontend/ci/job_details/utils_spec.js @@ -1,4 +1,4 @@ -import { compactJobLog } from '~/ci/job_details/utils'; +import { compactJobLog, filterAnnotations } from '~/ci/job_details/utils'; import { mockJobLog } from 'jest/ci/jobs_mock_data'; describe('Job utils', () => { @@ -219,4 +219,47 @@ describe('Job utils', () => { expect(compactJobLog(mockJobLog)).toStrictEqual(expectedResults); }); }); + + describe('filterAnnotations', () => { + it('filters annotations by type', () => { + const data = [ + { + name: 'b', + data: [ + { + dummy: {}, + }, + { + external_link: { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + }, + ], + }, + { + name: 'a', + data: [ + { + external_link: { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + }, + ], + }, + ]; + + expect(filterAnnotations(data, 'external_link')).toEqual([ + { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + ]); + }); + }); }); diff --git a/spec/frontend/ci/jobs_mock_data.js b/spec/frontend/ci/jobs_mock_data.js index 253e669e889..c428de3b9d8 100644 --- a/spec/frontend/ci/jobs_mock_data.js +++ b/spec/frontend/ci/jobs_mock_data.js @@ -989,6 +989,7 @@ export default { }, erase_path: '/root/ci-mock/-/jobs/4757/erase', artifacts: [null], + annotations: [], runner: { id: 1, short_sha: 'ABCDEFGH', diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index e1966908452..6422856ba22 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -20,7 +20,7 @@ describe('ProtectedBranchEdit', () => { let mock; beforeEach(() => { - jest.spyOn(ProtectedBranchEdit.prototype, 'buildDropdowns').mockImplementation(); + jest.spyOn(ProtectedBranchEdit.prototype, 'initDropdowns').mockImplementation(); mock = new MockAdapter(axios); }); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index a063f20aca6..7bddc4b1c48 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -194,7 +194,7 @@ export const MOCK_DATA_FOR_NAVIGATION_ACTION_MUTATION = { label: 'Projects', scope: 'projects', link: '/search?scope=projects&search=et', - count_link: '/search/count?scope=projects&search=et', + count_link: null, }, }; diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index 61a87af476e..3944ba86942 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -4,13 +4,18 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { SEARCH_TYPE_ZOEKT, SEARCH_TYPE_ADVANCED } from '~/search/sidebar/constants'; import { MOCK_QUERY } from 'jest/search/mock_data'; +import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; import GlobalSearchSidebar from '~/search/sidebar/components/app.vue'; import IssuesFilters from '~/search/sidebar/components/issues_filters.vue'; import MergeRequestsFilters from '~/search/sidebar/components/merge_requests_filters.vue'; import BlobsFilters from '~/search/sidebar/components/blobs_filters.vue'; import ProjectsFilters from '~/search/sidebar/components/projects_filters.vue'; import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; +import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; + +jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager'); Vue.use(Vuex); @@ -41,13 +46,16 @@ describe('GlobalSearchSidebar', () => { const findBlobsFilters = () => wrapper.findComponent(BlobsFilters); const findProjectsFilters = () => wrapper.findComponent(ProjectsFilters); const findScopeLegacyNavigation = () => wrapper.findComponent(ScopeLegacyNavigation); + const findSmallScreenDrawerNavigation = () => wrapper.findComponent(SmallScreenDrawerNavigation); const findScopeSidebarNavigation = () => wrapper.findComponent(ScopeSidebarNavigation); + const findDomElementListener = () => wrapper.findComponent(DomElementListener); describe('renders properly', () => { describe('always', () => { beforeEach(() => { createComponent(); }); + it(`shows section`, () => { expect(findSidebarSection().exists()).toBe(true); }); @@ -104,6 +112,7 @@ describe('GlobalSearchSidebar', () => { it(`${!legacyNavShown ? 'hides' : 'shows'} the legacy navigation`, () => { expect(findScopeLegacyNavigation().exists()).toBe(legacyNavShown); + expect(findSmallScreenDrawerNavigation().exists()).toBe(legacyNavShown); }); it(`${!sidebarNavShown ? 'hides' : 'shows'} the sidebar navigation`, () => { @@ -111,4 +120,21 @@ describe('GlobalSearchSidebar', () => { }); }); }); + + describe('when useSidebarNavigation=true', () => { + beforeEach(() => { + createComponent({ useSidebarNavigation: true }); + }); + + it('toggles super sidebar when button is clicked', () => { + const elListener = findDomElementListener(); + + expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled(); + + elListener.vm.$emit('click'); + + expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1); + expect(elListener.props('selector')).toBe('#js-open-mobile-filters'); + }); + }); }); diff --git a/spec/frontend/search/sidebar/components/issues_filters_spec.js b/spec/frontend/search/sidebar/components/issues_filters_spec.js index 84c4258cbdb..e6188436c81 100644 --- a/spec/frontend/search/sidebar/components/issues_filters_spec.js +++ b/spec/frontend/search/sidebar/components/issues_filters_spec.js @@ -7,6 +7,8 @@ import IssuesFilters from '~/search/sidebar/components/issues_filters.vue'; import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter/index.vue'; import StatusFilter from '~/search/sidebar/components/status_filter/index.vue'; import LabelFilter from '~/search/sidebar/components/label_filter/index.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import { SEARCH_TYPE_ADVANCED, SEARCH_TYPE_BASIC } from '~/search/sidebar/constants'; Vue.use(Vuex); @@ -17,10 +19,16 @@ describe('GlobalSearch IssuesFilters', () => { currentScope: () => 'issues', }; - const createComponent = (initialState, ff = true) => { + const createComponent = ({ + initialState = {}, + searchIssueLabelAggregation = true, + searchIssuesHideArchivedProjects = true, + } = {}) => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, + useSidebarNavigation: false, + searchType: SEARCH_TYPE_ADVANCED, ...initialState, }, getters: defaultGetters, @@ -30,7 +38,8 @@ describe('GlobalSearch IssuesFilters', () => { store, provide: { glFeatures: { - searchIssueLabelAggregation: ff, + searchIssueLabelAggregation, + searchIssuesHideArchivedProjects, }, }, }); @@ -39,11 +48,87 @@ describe('GlobalSearch IssuesFilters', () => { const findStatusFilter = () => wrapper.findComponent(StatusFilter); const findConfidentialityFilter = () => wrapper.findComponent(ConfidentialityFilter); const findLabelFilter = () => wrapper.findComponent(LabelFilter); + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); const findDividers = () => wrapper.findAll('hr'); - describe('Renders correctly with FF enabled', () => { + describe.each` + description | searchIssueLabelAggregation | searchIssuesHideArchivedProjects + ${'Renders correctly with Label Filter disabled'} | ${false} | ${true} + ${'Renders correctly with Archived Filter disabled'} | ${true} | ${false} + ${'Renders correctly with Archived Filter and Label Filter disabled'} | ${false} | ${false} + ${'Renders correctly with Archived Filter and Label Filter enabled'} | ${true} | ${true} + `('$description', ({ searchIssueLabelAggregation, searchIssuesHideArchivedProjects }) => { beforeEach(() => { - createComponent({ urlQuery: MOCK_QUERY }); + createComponent({ + searchIssueLabelAggregation, + searchIssuesHideArchivedProjects, + }); + }); + + it('renders StatusFilter', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it('renders ConfidentialityFilter', () => { + expect(findConfidentialityFilter().exists()).toBe(true); + }); + + it(`renders correctly LabelFilter when searchIssueLabelAggregation is ${searchIssueLabelAggregation}`, () => { + expect(findLabelFilter().exists()).toBe(searchIssueLabelAggregation); + }); + + it(`renders correctly ArchivedFilter when searchIssuesHideArchivedProjects is ${searchIssuesHideArchivedProjects}`, () => { + expect(findArchivedFilter().exists()).toBe(searchIssuesHideArchivedProjects); + }); + + it('renders divider correctly', () => { + // one divider can't be disabled + let dividersCount = 1; + if (searchIssueLabelAggregation) { + dividersCount += 1; + } + if (searchIssuesHideArchivedProjects) { + dividersCount += 1; + } + expect(findDividers()).toHaveLength(dividersCount); + }); + }); + + describe('Renders correctly with basic search', () => { + beforeEach(() => { + createComponent({ initialState: { searchType: SEARCH_TYPE_BASIC } }); + }); + it('renders StatusFilter', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it('renders ConfidentialityFilter', () => { + expect(findConfidentialityFilter().exists()).toBe(true); + }); + + it("doesn't render LabelFilter", () => { + expect(findLabelFilter().exists()).toBe(false); + }); + + it("doesn't render ArchivedFilter", () => { + expect(findArchivedFilter().exists()).toBe(false); + }); + + it('renders 1 divider', () => { + expect(findDividers()).toHaveLength(1); + }); + }); + + describe('Renders correctly in new nav', () => { + beforeEach(() => { + createComponent({ + initialState: { + searchType: SEARCH_TYPE_ADVANCED, + useSidebarNavigation: true, + }, + searchIssueLabelAggregation: true, + searchIssuesHideArchivedProjects: true, + }); }); it('renders StatusFilter', () => { expect(findStatusFilter().exists()).toBe(true); @@ -57,36 +142,19 @@ describe('GlobalSearch IssuesFilters', () => { expect(findLabelFilter().exists()).toBe(true); }); - it('renders dividers correctly', () => { - expect(findDividers()).toHaveLength(2); - }); - }); - - describe('Renders correctly with FF disabled', () => { - beforeEach(() => { - createComponent({ urlQuery: MOCK_QUERY }, false); - }); - it('renders StatusFilter', () => { - expect(findStatusFilter().exists()).toBe(true); + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); }); - it('renders ConfidentialityFilter', () => { - expect(findConfidentialityFilter().exists()).toBe(true); - }); - - it("doesn't render LabelFilter", () => { - expect(findLabelFilter().exists()).toBe(false); - }); - - it('renders divider correctly', () => { - expect(findDividers()).toHaveLength(1); + it("doesn't render dividers", () => { + expect(findDividers()).toHaveLength(0); }); }); describe('Renders correctly with wrong scope', () => { beforeEach(() => { defaultGetters.currentScope = () => 'blobs'; - createComponent({ urlQuery: MOCK_QUERY }); + createComponent(); }); it("doesn't render StatusFilter", () => { expect(findStatusFilter().exists()).toBe(false); @@ -100,6 +168,10 @@ describe('GlobalSearch IssuesFilters', () => { expect(findLabelFilter().exists()).toBe(false); }); + it("doesn't render ArchivedFilter", () => { + expect(findArchivedFilter().exists()).toBe(false); + }); + it("doesn't render dividers", () => { expect(findDividers()).toHaveLength(0); }); diff --git a/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js b/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js new file mode 100644 index 00000000000..5ab4afba7f0 --- /dev/null +++ b/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js @@ -0,0 +1,68 @@ +import { nextTick } from 'vue'; +import { GlDrawer } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; + +describe('ScopeLegacyNavigation', () => { + let wrapper; + let closeSpy; + let toggleSpy; + + const createComponent = () => { + wrapper = shallowMountExtended(SmallScreenDrawerNavigation, { + slots: { + default: 'test', + }, + }); + }; + + const findGlDrawer = () => wrapper.findComponent(GlDrawer); + const findTitle = () => wrapper.findComponent('h2'); + const findSlot = () => wrapper.findByTestId('default-slot-content'); + const findDomElementListener = () => wrapper.findComponent(DomElementListener); + + describe('small screen navigation', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders drawer', () => { + expect(findGlDrawer().exists()).toBe(true); + expect(findGlDrawer().attributes('zindex')).toBe(DRAWER_Z_INDEX.toString()); + expect(findGlDrawer().attributes('headerheight')).toBe('0'); + }); + + it('renders title', () => { + expect(findTitle().exists()).toBe(true); + }); + + it('renders slots', () => { + expect(findSlot().exists()).toBe(true); + }); + }); + + describe('actions', () => { + beforeEach(() => { + closeSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'closeSmallScreenFilters'); + toggleSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'toggleSmallScreenFilters'); + createComponent(); + }); + + it('calls onClose', () => { + findGlDrawer().vm.$emit('close'); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('calls toggleSmallScreenFilters', async () => { + expect(findGlDrawer().props('open')).toBe(false); + + findDomElementListener().vm.$emit('click'); + await nextTick(); + + expect(toggleSpy).toHaveBeenCalled(); + expect(findGlDrawer().props('open')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index cc9c555b6c7..889260fc478 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { mapValues } from 'lodash'; import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; import { createAlert } from '~/alert'; @@ -312,6 +313,21 @@ describe('Global Search Store Actions', () => { }); }); + describe('fetchSidebarCount with no count_link', () => { + beforeEach(() => { + state.navigation = mapValues(MOCK_NAVIGATION_DATA, (navItem) => ({ + ...navItem, + count_link: null, + })); + }); + + it('should not request anything', async () => { + await testAction({ action: actions.fetchSidebarCount, state, expectedMutations: [] }); + + expect(mock.history.get.length).toBe(0); + }); + }); + describe.each` action | axiosMock | type | expectedMutations | errorLogs ${actions.fetchAllAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION} | ${0} diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb index baeafe6b7e7..16c9b8a85ec 100644 --- a/spec/helpers/projects/pipeline_helper_spec.rb +++ b/spec/helpers/projects/pipeline_helper_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Projects::PipelineHelper do blob_path: project_blob_path(project, pipeline.sha), has_test_report: pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)), empty_dag_svg_path: match_asset_path('illustrations/empty-state/empty-dag-md.svg'), - empty_state_image_path: match_asset_path('illustrations/empty-state/empty-test-cases-lg.svg'), + empty_state_image_path: match_asset_path('illustrations/empty-todos-md.svg'), artifacts_expired_image_path: match_asset_path('illustrations/pipeline.svg'), tests_count: pipeline.test_report_summary.total[:count] }) diff --git a/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb index 34f97bc7e2a..af8bce47c3d 100644 --- a/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb +++ b/spec/lib/bulk_imports/projects/pipelines/references_pipeline_spec.rb @@ -51,7 +51,7 @@ RSpec.describe BulkImports::Projects::Pipelines::ReferencesPipeline, feature_cat :note, project: project, noteable: mr, - note: '@manuelgrabowski-admin' + note: '@manuelgrabowski-admin, @boaty-mc-boatface' ) end @@ -91,8 +91,10 @@ RSpec.describe BulkImports::Projects::Pipelines::ReferencesPipeline, feature_cat 'source_username' => 'destination_username', 'bob' => 'alice-gdk', 'alice' => 'bob-gdk', + 'manuelgrabowski' => 'manuelgrabowski-admin', 'manuelgrabowski-admin' => 'manuelgrabowski', - 'manuelgrabowski' => 'manuelgrabowski-admin' + 'boaty-mc-boatface' => 'boatymcboatface', + 'boatymcboatface' => 'boaty-mc-boatface' }) end @@ -179,7 +181,9 @@ RSpec.describe BulkImports::Projects::Pipelines::ReferencesPipeline, feature_cat transformed_interchanged_usernames = subject.transform(context, interchanged_usernames) expect(transformed_interchanged_usernames.note).to include("@manuelgrabowski") + expect(transformed_interchanged_usernames.note).to include("@boatymcboatface") expect(transformed_interchanged_usernames.note).not_to include("@manuelgrabowski-admin") + expect(transformed_interchanged_usernames.note).not_to include("@boaty-mc-boatface") end context 'when object does not have reference or username' do diff --git a/spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb index f415e892818..79c2c9e32d2 100644 --- a/spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb +++ b/spec/lib/gitlab/database/partitioning/ci_sliding_list_strategy_spec.rb @@ -175,4 +175,30 @@ RSpec.describe Gitlab::Database::Partitioning::CiSlidingListStrategy, feature_ca end.not_to raise_error end end + + describe 'attributes' do + let(:partitioning_key) { :partition } + let(:next_partition_if) { -> { true } } + let(:detach_partition_if) { -> { false } } + let(:analyze_interval) { 1.week } + + subject(:strategy) do + described_class.new( + model, partitioning_key, + next_partition_if: next_partition_if, + detach_partition_if: detach_partition_if, + analyze_interval: analyze_interval + ) + end + + specify do + expect(strategy).to have_attributes({ + model: model, + partitioning_key: partitioning_key, + next_partition_if: next_partition_if, + detach_partition_if: detach_partition_if, + analyze_interval: analyze_interval + }) + end + end end diff --git a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb index 50115a6f3dd..3afa338fdf7 100644 --- a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb +++ b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do +RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy, feature_category: :database do let(:connection) { ActiveRecord::Base.connection } describe '#current_partitions' do @@ -273,4 +273,32 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do end end end + + describe 'attributes' do + let(:partitioning_key) { :partition } + let(:retain_non_empty_partitions) { true } + let(:retain_for) { 12.months } + let(:analyze_interval) { 1.week } + let(:model) { class_double(ApplicationRecord, table_name: table_name, connection: connection) } + let(:table_name) { :_test_partitioned_test } + + subject(:strategy) do + described_class.new( + model, partitioning_key, + retain_for: retain_for, + retain_non_empty_partitions: retain_non_empty_partitions, + analyze_interval: analyze_interval + ) + end + + specify do + expect(strategy).to have_attributes({ + model: model, + partitioning_key: partitioning_key, + retain_for: retain_for, + retain_non_empty_partitions: retain_non_empty_partitions, + analyze_interval: analyze_interval + }) + end + end end diff --git a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb index eac4a162879..f2c309e8e88 100644 --- a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb +++ b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb @@ -2,7 +2,8 @@ require 'spec_helper' -RSpec.describe Gitlab::Database::Partitioning::PartitionManager do +RSpec.describe Gitlab::Database::Partitioning::PartitionManager, feature_category: :database do + include ActiveSupport::Testing::TimeHelpers include Database::PartitioningHelpers include ExclusiveLeaseHelpers @@ -256,6 +257,141 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do end end + describe 'analyze partitioned table' do + let(:analyze_table) { partitioned_table_name } + let(:analyze_partition) { "#{partitioned_table_name}_1" } + let(:analyze_regex) { /ANALYZE VERBOSE "#{analyze_table}"/ } + let(:analyze_interval) { 1.week } + let(:connection) { my_model.connection } + let(:create_partition) { true } + let(:my_model) do + interval = analyze_interval + Class.new(ApplicationRecord) do + include PartitionedTable + + partitioned_by :partition_id, + strategy: :ci_sliding_list, + next_partition_if: proc { false }, + detach_partition_if: proc { false }, + analyze_interval: interval + end + end + + shared_examples_for 'run only once analyze within interval' do + it 'runs only once analyze within interval' do + control = ActiveRecord::QueryRecorder.new { described_class.new(my_model, connection: connection).sync_partitions } + expect(control.occurrences).to include(analyze_regex) + + control = ActiveRecord::QueryRecorder.new { described_class.new(my_model, connection: connection).sync_partitions } + expect(control.occurrences).not_to include(analyze_regex) + + travel_to((analyze_interval * 1.1).since) do + control = ActiveRecord::QueryRecorder.new { described_class.new(my_model, connection: connection).sync_partitions } + expect(control.occurrences).to include(analyze_regex) + end + end + end + + shared_examples_for 'not to run the analyze at all' do + it 'does not run the analyze at all' do + control = ActiveRecord::QueryRecorder.new { described_class.new(my_model, connection: connection).sync_partitions } + expect(control.occurrences).not_to include(analyze_regex) + + control = ActiveRecord::QueryRecorder.new { described_class.new(my_model, connection: connection).sync_partitions } + expect(control.occurrences).not_to include(analyze_regex) + + travel_to((analyze_interval * 2).since) do + control = ActiveRecord::QueryRecorder.new { described_class.new(my_model, connection: connection).sync_partitions } + expect(control.occurrences).not_to include(analyze_regex) + end + end + end + + before do + my_model.table_name = partitioned_table_name + + connection.execute(<<~SQL) + CREATE TABLE #{analyze_table}(id serial) PARTITION BY LIST (id); + SQL + + connection.execute(<<~SQL) if create_partition + CREATE TABLE IF NOT EXISTS #{analyze_partition} PARTITION OF #{analyze_table} FOR VALUES IN (1); + SQL + + allow(connection).to receive(:select_value).and_return(nil, Time.current, Time.current) + end + + context 'when feature flag database_analyze_on_partitioned_tables is enabled' do + before do + stub_feature_flags(database_analyze_on_partitioned_tables: true) + end + + it_behaves_like 'run only once analyze within interval' + + context 'when model does not set analyze_interval' do + let(:my_model) do + Class.new(ApplicationRecord) do + include PartitionedTable + + partitioned_by :partition_id, + strategy: :ci_sliding_list, + next_partition_if: proc { false }, + detach_partition_if: proc { false } + end + end + + it_behaves_like 'not to run the analyze at all' + end + + context 'when no partition is created' do + let(:create_partition) { false } + + it_behaves_like 'run only once analyze within interval' + end + end + + context 'when feature flag database_analyze_on_partitioned_tables is disabled' do + before do + stub_feature_flags(database_analyze_on_partitioned_tables: false) + end + + it_behaves_like 'not to run the analyze at all' + + context 'when model does not set analyze_interval' do + let(:my_model) do + Class.new(ApplicationRecord) do + include PartitionedTable + + partitioned_by :partition_id, + strategy: :ci_sliding_list, + next_partition_if: proc { false }, + detach_partition_if: proc { false } + end + end + + it_behaves_like 'not to run the analyze at all' + end + + context 'when no partition is created' do + let(:create_partition) { false } + + it_behaves_like 'not to run the analyze at all' + end + end + end + + describe 'strategies that support analyze_interval' do + [ + ::Gitlab::Database::Partitioning::MonthlyStrategy, + ::Gitlab::Database::Partitioning::SlidingListStrategy, + ::Gitlab::Database::Partitioning::CiSlidingListStrategy + ].each do |klass| + specify "#{klass} supports analyze_interval" do + expect(klass).to be_method_defined(:analyze_interval) + end + end + end + context 'creating and then detaching partitions for a table' do let(:connection) { ActiveRecord::Base.connection } let(:my_model) do diff --git a/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb index 5b6967c2d14..ac4d345271e 100644 --- a/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb +++ b/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb @@ -290,4 +290,30 @@ RSpec.describe Gitlab::Database::Partitioning::SlidingListStrategy, feature_cate expect(partition_3_model.partition).to eq(3) end end + + describe 'attributes' do + let(:partitioning_key) { :partition } + let(:next_partition_if) { -> { puts "next_partition_if" } } + let(:detach_partition_if) { -> { puts "detach_partition_if" } } + let(:analyze_interval) { 1.week } + + subject(:strategy) do + described_class.new( + model, partitioning_key, + next_partition_if: next_partition_if, + detach_partition_if: detach_partition_if, + analyze_interval: analyze_interval + ) + end + + specify do + expect(strategy).to have_attributes({ + model: model, + partitioning_key: partitioning_key, + next_partition_if: next_partition_if, + detach_partition_if: detach_partition_if, + analyze_interval: analyze_interval + }) + end + end end diff --git a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb index 382ee07e458..713e22e2e76 100644 --- a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb +++ b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb @@ -24,29 +24,16 @@ RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu, feature_category expect(menu.render?).to eq true end end - - context 'when menu does not have any menu item to show' do - it 'returns false' do - stub_feature_flags(harbor_registry_integration: false) - stub_container_registry_config(enabled: false) - stub_config(packages: { enabled: false }) - stub_config(dependency_proxy: { enabled: false }) - - expect(menu.render?).to eq false - end - end end describe '#link' do let(:registry_enabled) { true } let(:packages_enabled) { true } - let(:harbor_registry_integration) { true } before do stub_container_registry_config(enabled: registry_enabled) stub_config(packages: { enabled: packages_enabled }) stub_config(dependency_proxy: { enabled: true }) - stub_feature_flags(harbor_registry_integration: harbor_registry_integration) end subject { menu.link } @@ -70,14 +57,6 @@ RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu, feature_category it 'menu link points to Harbor Registry page' do expect(subject).to eq find_menu(menu, :harbor_registry).link end - - context 'when Harbor Registry is not visible' do - let(:harbor_registry_integration) { false } - - it 'menu link points to Dependency Proxy page' do - expect(subject).to eq find_menu(menu, :dependency_proxy).link - end - end end end end @@ -194,29 +173,13 @@ RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu, feature_category describe 'Harbor Registry' do let(:item_id) { :harbor_registry } - before do - stub_feature_flags(harbor_registry_integration: harbor_registry_enabled) - end - - context 'when config harbor registry setting is disabled' do - let(:harbor_registry_enabled) { false } - - it_behaves_like 'the menu entry is not available' - end - - context 'when config harbor registry setting is enabled' do - let(:harbor_registry_enabled) { true } - - it_behaves_like 'the menu entry is available' - end + it_behaves_like 'the menu entry is available' context 'when config harbor registry setting is not activated' do before do harbor_integration.update!(active: false) end - let(:harbor_registry_enabled) { true } - it_behaves_like 'the menu entry is not available' end end diff --git a/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb b/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb index b917208bac1..0cf95391a26 100644 --- a/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb @@ -39,7 +39,7 @@ RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu, feature_catego before do stub_container_registry_config(enabled: registry_enabled) stub_config(packages: { enabled: packages_enabled }) - stub_feature_flags(harbor_registry_integration: false, ml_experiment_tracking: false) + stub_feature_flags(ml_experiment_tracking: false) end context 'when Packages Registry is visible' do @@ -58,8 +58,8 @@ RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu, feature_catego context 'when Container Registry is not visible' do let(:registry_enabled) { false } - it 'does not display menu link' do - expect(subject.render?).to eq false + it 'displays menu link' do + expect(subject.render?).to eq true end end end @@ -155,26 +155,13 @@ RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu, feature_catego describe 'Harbor Registry' do let(:item_id) { :harbor_registry } - context 'when config harbor registry setting is disabled' do - it 'does not add the menu item to the list' do - stub_feature_flags(harbor_registry_integration: false) - - is_expected.to be_nil - end - end - - context 'when config harbor registry setting is enabled' do - it 'the menu item is added to list of menu items' do - stub_feature_flags(harbor_registry_integration: true) - - is_expected.not_to be_nil - expect(subject.active_routes[:controller]).to eq('projects/harbor/repositories') - end + it 'the menu item is added to list of menu items' do + is_expected.not_to be_nil + expect(subject.active_routes[:controller]).to eq('projects/harbor/repositories') end context 'when config harbor registry setting is not activated' do it 'does not add the menu item to the list' do - stub_feature_flags(harbor_registry_integration: true) project.harbor_integration.update!(active: false) is_expected.to be_nil diff --git a/spec/models/concerns/require_email_verification_spec.rb b/spec/models/concerns/require_email_verification_spec.rb index 1fb54e4276f..63312d4e1f1 100644 --- a/spec/models/concerns/require_email_verification_spec.rb +++ b/spec/models/concerns/require_email_verification_spec.rb @@ -51,7 +51,7 @@ RSpec.describe RequireEmailVerification, feature_category: :insider_threat do context 'when failed_attempts is LT overridden amount' do before do - instance.failed_attempts = 5 + instance.failed_attempts = 2 end it { is_expected.to eq(false) } diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb index 86eaf160b38..a9d58b20861 100644 --- a/spec/serializers/build_details_entity_spec.rb +++ b/spec/serializers/build_details_entity_spec.rb @@ -298,5 +298,21 @@ RSpec.describe BuildDetailsEntity do end end end + + context 'when the build has annotations' do + let!(:build) { create(:ci_build) } + let!(:annotation) { create(:ci_job_annotation, job: build, name: 'external_links', data: [{ external_link: { label: 'URL', url: 'https://example.com/' } }]) } + + it 'exposes job URLs' do + expect(subject[:annotations].count).to eq(1) + expect(subject[:annotations].first[:name]).to eq('external_links') + expect(subject[:annotations].first[:data]).to include(a_hash_including( + 'external_link' => a_hash_including( + 'label' => 'URL', + 'url' => 'https://example.com/' + ) + )) + end + end end end diff --git a/spec/serializers/ci/job_annotation_entity_spec.rb b/spec/serializers/ci/job_annotation_entity_spec.rb new file mode 100644 index 00000000000..8aef6e8cce3 --- /dev/null +++ b/spec/serializers/ci/job_annotation_entity_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobAnnotationEntity, feature_category: :build_artifacts do + let(:entity) { described_class.new(annotation) } + + let(:job) { build(:ci_build) } + let(:annotation) do + build(:ci_job_annotation, job: job, name: 'external_links', data: + [{ external_link: { label: 'URL', url: 'https://example.com/' } }]) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains valid name' do + expect(subject[:name]).to eq 'external_links' + end + + it 'contains external links' do + expect(subject[:data]).to include(a_hash_including( + 'external_link' => a_hash_including( + 'label' => 'URL', + 'url' => 'https://example.com/' + ) + )) + end + end +end diff --git a/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb b/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb index fb882ef8a23..10e0fe8c40e 100644 --- a/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb +++ b/spec/support/shared_examples/features/protected_branches_access_control_ce_shared_examples.rb @@ -2,6 +2,7 @@ RSpec.shared_examples "protected branches > access control > CE" do let(:no_one) { ProtectedRef::AccessLevel.humanize(::Gitlab::Access::NO_ACCESS) } + let_it_be(:edit_form) { '.js-protected-branch-edit-form' } ProtectedRef::AccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| it "allows creating protected branches that #{access_type_name} can push to" do @@ -41,12 +42,7 @@ RSpec.shared_examples "protected branches > access control > CE" do expect(ProtectedBranch.count).to eq(1) - within(".protected-branches-list") do - within_select(".js-allowed-to-push") do - click_on(access_type_name) - end - end - + set_allowed_to('push', access_type_name, form: edit_form) wait_for_requests expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id) @@ -63,12 +59,7 @@ RSpec.shared_examples "protected branches > access control > CE" do expect(ProtectedBranch.count).to eq(1) - within(".protected-branches-list") do - within_select(".js-allowed-to-merge") do - click_on(access_type_name) - end - end - + set_allowed_to('merge', access_type_name, form: edit_form) wait_for_requests expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id) diff --git a/spec/support/shared_examples/harbor/artifacts_controller_shared_examples.rb b/spec/support/shared_examples/harbor/artifacts_controller_shared_examples.rb index 85fcd426e3d..16e25bf96dd 100644 --- a/spec/support/shared_examples/harbor/artifacts_controller_shared_examples.rb +++ b/spec/support/shared_examples/harbor/artifacts_controller_shared_examples.rb @@ -87,17 +87,7 @@ RSpec.shared_examples 'a harbor artifacts controller' do |args| get harbor_artifact_url(container, repository_id), headers: json_header end - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status with json' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end + it_behaves_like 'responds with 200 status with json' context 'with anonymous user' do before do diff --git a/spec/support/shared_examples/harbor/repositories_controller_shared_examples.rb b/spec/support/shared_examples/harbor/repositories_controller_shared_examples.rb index b35595a10b2..a0d47d1a2d1 100644 --- a/spec/support/shared_examples/harbor/repositories_controller_shared_examples.rb +++ b/spec/support/shared_examples/harbor/repositories_controller_shared_examples.rb @@ -87,17 +87,7 @@ RSpec.shared_examples 'a harbor repositories controller' do |args| get harbor_repository_url(container) end - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status with html' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end + it_behaves_like 'responds with 200 status with html' context 'with anonymous user' do before do @@ -121,17 +111,7 @@ RSpec.shared_examples 'a harbor repositories controller' do |args| get harbor_repository_url(container), headers: json_header end - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status with json' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end + it_behaves_like 'responds with 200 status with json' context 'with valid params' do context 'with valid page params' do diff --git a/spec/support/shared_examples/harbor/tags_controller_shared_examples.rb b/spec/support/shared_examples/harbor/tags_controller_shared_examples.rb index 46fea7fdff6..aee728295de 100644 --- a/spec/support/shared_examples/harbor/tags_controller_shared_examples.rb +++ b/spec/support/shared_examples/harbor/tags_controller_shared_examples.rb @@ -76,17 +76,7 @@ RSpec.shared_examples 'a harbor tags controller' do |args| headers: json_header) end - context 'with harbor registry feature flag enabled' do - it_behaves_like 'responds with 200 status with json' - end - - context 'with harbor registry feature flag disabled' do - before do - stub_feature_flags(harbor_registry_integration: false) - end - - it_behaves_like 'responds with 404 status' - end + it_behaves_like 'responds with 200 status with json' context 'with anonymous user' do before do