diff --git a/.gitlab/issue_templates/Feature Flag Roll Out.md b/.gitlab/issue_templates/Feature Flag Roll Out.md index 788b610a982..a0b64b53250 100644 --- a/.gitlab/issue_templates/Feature Flag Roll Out.md +++ b/.gitlab/issue_templates/Feature Flag Roll Out.md @@ -34,7 +34,9 @@ If applicable, any groups/projects that are happy to have this feature turned on - [ ] Test on staging - [ ] Ensure that documentation has been updated - [ ] Enable on GitLab.com for individual groups/projects listed above and verify behaviour (`/chatops run feature set --project=gitlab-org/gitlab feature_name true`) -- [ ] Coordinate a time to enable the flag with `#production` and `#g_delivery` on slack. +- [ ] Coordinate a time to enable the flag with the SRE oncall and release managers + - In `#production` by pinging `@sre-oncall` + - In `#g_delivery` by pinging `@release-managers` - [ ] Announce on the issue an estimated time this will be enabled on GitLab.com - [ ] Enable on GitLab.com by running chatops command in `#production` (`/chatops run feature set feature_name true`) - [ ] Cross post chatops Slack command to `#support_gitlab-com` ([more guidance when this is necessary in the dev docs](https://docs.gitlab.com/ee/development/feature_flags/controls.html#where-to-run-commands)) and in your team channel @@ -42,4 +44,12 @@ If applicable, any groups/projects that are happy to have this feature turned on - [ ] Remove feature flag and add changelog entry - [ ] After the flag removal is deployed, [clean up the feature flag](https://docs.gitlab.com/ee/development/feature_flags/controls.html#cleaning-up) by running chatops command in `#production` channel +## Rollback Steps + +- [ ] This feature can be disabled by running the following Chatops command: + +``` +/chatops run feature set --project=gitlab-org/gitlab feature_name false +``` + /label ~"feature flag" diff --git a/.gitlab/issue_templates/Lean Feature Proposal.md b/.gitlab/issue_templates/Lean Feature Proposal.md index dcb01fb8216..bc9620e9e46 100644 --- a/.gitlab/issue_templates/Lean Feature Proposal.md +++ b/.gitlab/issue_templates/Lean Feature Proposal.md @@ -8,7 +8,6 @@ - ### Proposal @@ -46,14 +45,14 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma ### User experience goal - What is the single user experience workflow this problem addresses? +What is the single user experience workflow this problem addresses? For example, "The user should be able to use the UI/API/.gitlab-ci.yml with GitLab to " https://about.gitlab.com/handbook/engineering/ux/ux-research-training/user-story-mapping/ ### Further details -nclude use cases, benefits, goals, or any other details that will help us understand the problem better. +Include use cases, benefits, goals, or any other details that will help us understand the problem better. ### Permissions and Security @@ -75,7 +74,7 @@ Consider adding checkboxes and expectations of users with certain levels of memb ### Availability & Testing -his section needs to be retained and filled in during the workflow planning breakdown phase of this feature proposal, if not earlier. +This section needs to be retained and filled in during the workflow planning breakdown phase of this feature proposal, if not earlier. What risks does this change pose to our availability? How might it affect the quality of the product? What additional test coverage or changes to tests will be needed? Will it require cross-browser testing? @@ -98,6 +97,3 @@ In which enterprise tier should this feature go? See https://about.gitlab.com/ha ### Is this a cross-stage feature? Communicate if this change will affect multiple Stage Groups or product areas. We recommend always start with the assumption that a feature request will have an impact into another Group. Loop in the most relevant PM and Product Designer from that Group to provide strategic support to help align the Group's broader plan and vision, as well as to avoid UX and technical debt. https://about.gitlab.com/handbook/product/#cross-stage-features --> - - - diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue index 2533854119f..abe9b45ed79 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue +++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue @@ -5,6 +5,7 @@ import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue' import UsersChart from './users_chart.vue'; import pipelinesStatsQuery from '../graphql/queries/pipeline_stats.query.graphql'; import issuesAndMergeRequestsQuery from '../graphql/queries/issues_and_merge_requests.query.graphql'; +import ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; const PIPELINES_KEY_TO_NAME_MAP = { @@ -32,6 +33,7 @@ export default { InstanceCounts, InstanceStatisticsCountChart, UsersChart, + ProjectsAndGroupsChart, }, TOTAL_DAYS_TO_SHOW, START_DATE, @@ -69,6 +71,11 @@ export default { :end-date="$options.TODAY" :total-data-points="$options.TOTAL_DAYS_TO_SHOW" /> + +import { GlAlert } from '@gitlab/ui'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import produce from 'immer'; +import { sortBy } from 'lodash'; +import * as Sentry from '~/sentry/wrapper'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { s__, __ } from '~/locale'; +import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; +import latestGroupsQuery from '../graphql/queries/groups.query.graphql'; +import latestProjectsQuery from '../graphql/queries/projects.query.graphql'; +import { getAverageByMonth } from '../utils'; + +const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime()); + +const averageAndSortData = (data = [], maxDataPoints) => { + const averaged = getAverageByMonth( + data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data, + { shouldRound: true }, + ); + return sortByDate(averaged); +}; + +export default { + name: 'ProjectsAndGroupsChart', + components: { GlAlert, GlLineChart, ChartSkeletonLoader }, + props: { + startDate: { + type: Date, + required: true, + }, + endDate: { + type: Date, + required: true, + }, + totalDataPoints: { + type: Number, + required: true, + }, + }, + data() { + return { + loadingError: false, + errorMessage: '', + groups: [], + projects: [], + groupsPageInfo: null, + projectsPageInfo: null, + }; + }, + apollo: { + groups: { + query: latestGroupsQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.groups?.nodes || []; + }, + result({ data }) { + const { + groups: { pageInfo }, + } = data; + this.groupsPageInfo = pageInfo; + this.fetchNextPage({ + query: this.$apollo.queries.groups, + pageInfo: this.groupsPageInfo, + dataKey: 'groups', + errorMessage: this.$options.i18n.loadGroupsDataError, + }); + }, + error(error) { + this.handleError({ + message: this.$options.i18n.loadGroupsDataError, + error, + dataKey: 'groups', + }); + }, + }, + projects: { + query: latestProjectsQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.projects?.nodes || []; + }, + result({ data }) { + const { + projects: { pageInfo }, + } = data; + this.projectsPageInfo = pageInfo; + this.fetchNextPage({ + query: this.$apollo.queries.projects, + pageInfo: this.projectsPageInfo, + dataKey: 'projects', + errorMessage: this.$options.i18n.loadProjectsDataError, + }); + }, + error(error) { + this.handleError({ + message: this.$options.i18n.loadProjectsDataError, + error, + dataKey: 'projects', + }); + }, + }, + }, + i18n: { + yAxisTitle: s__('InstanceStatistics|Total projects & groups'), + xAxisTitle: __('Month'), + loadChartError: s__( + 'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.', + ), + loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'), + loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'), + noDataMessage: s__('InstanceStatistics|No data available.'), + }, + computed: { + isLoadingGroups() { + return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage; + }, + isLoadingProjects() { + return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage; + }, + isLoading() { + return this.isLoadingProjects && this.isLoadingGroups; + }, + groupChartData() { + return averageAndSortData(this.groups, this.totalDataPoints); + }, + projectChartData() { + return averageAndSortData(this.projects, this.totalDataPoints); + }, + hasNoData() { + const { projectChartData, groupChartData } = this; + return Boolean(!projectChartData.length && !groupChartData.length); + }, + options() { + return { + xAxis: { + name: this.$options.i18n.xAxisTitle, + type: 'category', + axisLabel: { + formatter: value => { + return formatDateAsMonth(value); + }, + }, + }, + yAxis: { + name: this.$options.i18n.yAxisTitle, + }, + }; + }, + chartData() { + return [ + { + name: s__('InstanceStatistics|Total projects'), + data: this.projectChartData, + }, + { + name: s__('InstanceStatistics|Total groups'), + data: this.groupChartData, + }, + ]; + }, + }, + methods: { + handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) { + this.loadingError = true; + this.errorMessage = message; + if (!dataKey) { + this.projects = []; + this.groups = []; + } else { + this[dataKey] = []; + } + Sentry.captureException(error); + }, + fetchNextPage({ pageInfo, query, dataKey, errorMessage }) { + if (pageInfo?.hasNextPage) { + query + .fetchMore({ + variables: { first: this.totalDataPoints, after: pageInfo.endCursor }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const results = produce(fetchMoreResult, newData => { + // eslint-disable-next-line no-param-reassign + newData[dataKey].nodes = [ + ...previousResult[dataKey].nodes, + ...newData[dataKey].nodes, + ]; + }); + return results; + }, + }) + .catch(error => { + this.handleError({ error, message: errorMessage, dataKey }); + }); + } + }, + }, +}; + + diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql new file mode 100644 index 00000000000..ec56d91ffaa --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getGroupsCount($first: Int, $after: String) { + groups: instanceStatisticsMeasurements(identifier: GROUPS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql new file mode 100644 index 00000000000..0845b703435 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getProjectsCount($first: Int, $after: String) { + projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 686ca5fcab1..32c4c1039f5 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -6,8 +6,8 @@ import { GlModal, GlAlert, GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, GlButton, GlTooltipDirective, } from '@gitlab/ui'; @@ -28,8 +28,8 @@ export default { GlModal, GlAlert, GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, TimeAgoTooltip, GlButton, }, @@ -231,17 +231,17 @@ export default { diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 195c6be63d2..5c60fc42ff0 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -119,7 +119,7 @@ class Namespace < ApplicationRecord # Returns an ActiveRecord::Relation. def search(query, include_parents: false) if include_parents - where(id: Route.fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]]).select(:source_id)) + where(id: Route.for_routable_type(Namespace.name).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]]).select(:source_id)) else fuzzy_search(query, [:path, :name]) end diff --git a/app/models/route.rb b/app/models/route.rb index 706589e79b8..fe4846b3be5 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -20,6 +20,7 @@ class Route < ApplicationRecord scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } scope :for_routable, -> (routable) { where(source: routable) } + scope :for_routable_type, -> (routable_type) { where(source_type: routable_type) } scope :sort_by_path_length, -> { order('LENGTH(routes.path)', :path) } def rename_descendants diff --git a/changelogs/unreleased/258215-improve-fuzzy-search-on-full-path-in-groups.yml b/changelogs/unreleased/258215-improve-fuzzy-search-on-full-path-in-groups.yml new file mode 100644 index 00000000000..f2fd36bb68e --- /dev/null +++ b/changelogs/unreleased/258215-improve-fuzzy-search-on-full-path-in-groups.yml @@ -0,0 +1,5 @@ +--- +title: Fix problems with Groups API search query parameter +merge_request: 46394 +author: +type: fixed diff --git a/changelogs/unreleased/266946-minimal-access-members-are-not-listed-in-saml.yml b/changelogs/unreleased/266946-minimal-access-members-are-not-listed-in-saml.yml new file mode 100644 index 00000000000..d5a18be688c --- /dev/null +++ b/changelogs/unreleased/266946-minimal-access-members-are-not-listed-in-saml.yml @@ -0,0 +1,5 @@ +--- +title: Add minimal access users to group members api endpoints +merge_request: 46238 +author: +type: changed diff --git a/changelogs/unreleased/Replace-GlDeprecatedDropdown-with-GlDropdown-in-app-assets-javascripts-sn.yml b/changelogs/unreleased/Replace-GlDeprecatedDropdown-with-GlDropdown-in-app-assets-javascripts-sn.yml new file mode 100644 index 00000000000..30935278591 --- /dev/null +++ b/changelogs/unreleased/Replace-GlDeprecatedDropdown-with-GlDropdown-in-app-assets-javascripts-sn.yml @@ -0,0 +1,5 @@ +--- +title: Replace-GlDeprecatedDropdown-with-GlDropdown-in-app/assets/javascripts/snippets/components/snippet_header.vue +merge_request: 41428 +author: nuwe1 +type: other diff --git a/config/feature_categories.yml b/config/feature_categories.yml index edf7bba27a3..fb261377532 100644 --- a/config/feature_categories.yml +++ b/config/feature_categories.yml @@ -18,7 +18,6 @@ - authentication_and_authorization - auto_devops - backup_restore -- behavior_analytics - boards - chatops - cloud_native_installation @@ -56,7 +55,6 @@ - gitaly - gitlab_docs - gitlab_handbook -- gitter - global_search - helm_chart_registry - importers @@ -101,6 +99,8 @@ - secret_detection - secrets_management - security_benchmarking +- security_orchestration +- self_monitoring - serverless - service_desk - snippets @@ -108,12 +108,13 @@ - static_application_security_testing - static_site_editor - subgroups +- synthetic_monitoring - templates - time_tracking - tracing - usability_testing - users -- value_stream_management +- value_stream_analytics - vulnerability_database - vulnerability_management - web_firewall diff --git a/doc/api/boards.md b/doc/api/boards.md index b36a4d2bbc8..60df1a62173 100644 --- a/doc/api/boards.md +++ b/doc/api/boards.md @@ -171,7 +171,7 @@ Example response: } ``` -## Create an issue board **(STARTER)** +## Create an issue board Creates a project issue board. @@ -248,7 +248,7 @@ Example response: } ``` -## Update an issue board **(STARTER)** +## Update an issue board > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.1. @@ -258,15 +258,15 @@ Updates a project issue board. PUT /projects/:id/boards/:board_id ``` -| Attribute | Type | Required | Description | -| ------------------- | -------------- | -------- | ----------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `board_id` | integer | yes | The ID of a board | -| `name` | string | no | The new name of the board | -| `assignee_id` | integer | no | The assignee the board should be scoped to | -| `milestone_id` | integer | no | The milestone the board should be scoped to | -| `labels` | string | no | Comma-separated list of label names which the board should be scoped to | -| `weight` | integer | no | The weight range from 0 to 9, to which the board should be scoped to | +| Attribute | Type | Required | Description | +| ---------------------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `board_id` | integer | yes | The ID of a board | +| `name` | string | no | The new name of the board | +| `assignee_id` **(STARTER)** | integer | no | The assignee the board should be scoped to | +| `milestone_id` **(STARTER)** | integer | no | The milestone the board should be scoped to | +| `labels` **(STARTER)** | string | no | Comma-separated list of label names which the board should be scoped to | +| `weight` **(STARTER)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to | ```shell curl --request PUT --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/projects/5/boards/1?name=new_name&milestone_id=43&assignee_id=1&labels=Doing&weight=4" @@ -329,7 +329,7 @@ Example response: } ``` -## Delete an issue board **(STARTER)** +## Delete an issue board Deletes a project issue board. diff --git a/doc/user/group/index.md b/doc/user/group/index.md index 35e6f3b4136..7a816c96f59 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -422,7 +422,7 @@ For updates, you can follow: - [The epic tracking feature parity with project wikis](https://gitlab.com/groups/gitlab-org/-/epics/2782). - [The issue for adding the ability to move group wikis using the API](https://gitlab.com/gitlab-org/gitlab/-/issues/219003). -### Enable or disable group wikis **(CORE ONLY)** +### Enable or disable group wikis **(PREMIUM ONLY)** Group wikis are under development but ready for production use. It is deployed behind a feature flag that is **enabled by default**. diff --git a/lib/api/api.rb b/lib/api/api.rb index 5e8fdda5a52..973ce8c63c1 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -282,7 +282,7 @@ module API end end - route :any, '*path' do + route :any, '*path', feature_category: :not_owned do error!('404 Not Found', 404) end end diff --git a/lib/api/container_repositories.rb b/lib/api/container_repositories.rb index e7c82e30025..c84527f26e7 100644 --- a/lib/api/container_repositories.rb +++ b/lib/api/container_repositories.rb @@ -7,6 +7,8 @@ module API before { authenticate! } + feature_category :container_registry + namespace 'registry' do params do requires :id, type: String, desc: 'The ID of a project' diff --git a/lib/api/helpers/members_helpers.rb b/lib/api/helpers/members_helpers.rb index 5cc435e6801..cdc0893a2e7 100644 --- a/lib/api/helpers/members_helpers.rb +++ b/lib/api/helpers/members_helpers.rb @@ -20,12 +20,16 @@ module API # rubocop: disable CodeReuse/ActiveRecord def retrieve_members(source, params:, deep: false) - members = deep ? find_all_members(source) : source.members.where.not(user_id: nil) + members = deep ? find_all_members(source) : source_members(source).where.not(user_id: nil) members = members.includes(:user) members = members.references(:user).merge(User.search(params[:query])) if params[:query].present? members = members.where(user_id: params[:user_ids]) if params[:user_ids].present? members end + + def source_members(source) + source.members + end # rubocop: enable CodeReuse/ActiveRecord def find_all_members(source) diff --git a/lib/api/internal/base.rb b/lib/api/internal/base.rb index 6934ed5b7b3..e09d074d749 100644 --- a/lib/api/internal/base.rb +++ b/lib/api/internal/base.rb @@ -128,13 +128,13 @@ module API # changes - changes as "oldrev newrev ref", see Gitlab::ChangesList # check_ip - optional, only in EE version, may limit access to # group resources based on its IP restrictions - post "/allowed" do + post "/allowed", feature_category: :source_code_management do # It was moved to a separate method so that EE can alter its behaviour more # easily. check_allowed(params) end - post "/lfs_authenticate" do + post "/lfs_authenticate", feature_category: :source_code_management do status 200 unless actor.key_or_user @@ -152,7 +152,7 @@ module API # Get a ssh key using the fingerprint # # rubocop: disable CodeReuse/ActiveRecord - get '/authorized_keys' do + get '/authorized_keys', feature_category: :source_code_management do fingerprint = params.fetch(:fingerprint) do Gitlab::InsecureKeyFingerprint.new(params.fetch(:key)).fingerprint end @@ -165,11 +165,11 @@ module API # # Discover user by ssh key, user id or username # - get '/discover' do + get '/discover', feature_category: :authentication_and_authorization do present actor.user, with: Entities::UserSafe end - get '/check' do + get '/check', feature_category: :not_owned do { api_version: API.version, gitlab_version: Gitlab::VERSION, @@ -178,7 +178,7 @@ module API } end - post '/two_factor_recovery_codes' do + post '/two_factor_recovery_codes', feature_category: :authentication_and_authorization do status 200 actor.update_last_used_at! @@ -207,7 +207,7 @@ module API { success: true, recovery_codes: codes } end - post '/personal_access_token' do + post '/personal_access_token', feature_category: :authentication_and_authorization do status 200 actor.update_last_used_at! @@ -257,7 +257,7 @@ module API { success: true, token: access_token.token, scopes: access_token.scopes, expires_at: access_token.expires_at } end - post '/pre_receive' do + post '/pre_receive', feature_category: :source_code_management do status 200 reference_counter_increased = Gitlab::ReferenceCounter.new(params[:gl_repository]).increase @@ -265,7 +265,7 @@ module API { reference_counter_increased: reference_counter_increased } end - post '/post_receive' do + post '/post_receive', feature_category: :source_code_management do status 200 response = PostReceiveService.new(actor.user, repository, project, params).execute @@ -273,7 +273,7 @@ module API present response, with: Entities::InternalPostReceive::Response end - post '/two_factor_config' do + post '/two_factor_config', feature_category: :authentication_and_authorization do status 200 break { success: false } unless Feature.enabled?(:two_factor_for_cli) @@ -295,7 +295,7 @@ module API end end - post '/two_factor_otp_check' do + post '/two_factor_otp_check', feature_category: :authentication_and_authorization do status 200 break { success: false } unless Feature.enabled?(:two_factor_for_cli) diff --git a/lib/api/internal/kubernetes.rb b/lib/api/internal/kubernetes.rb index 8175b81f900..a28b053f8d8 100644 --- a/lib/api/internal/kubernetes.rb +++ b/lib/api/internal/kubernetes.rb @@ -4,6 +4,8 @@ module API # Kubernetes Internal API module Internal class Kubernetes < ::API::Base + feature_category :kubernetes_management + before do check_feature_enabled authenticate_gitlab_kas_request! diff --git a/lib/api/internal/lfs.rb b/lib/api/internal/lfs.rb index 630f0ec77a8..66baa4f1034 100644 --- a/lib/api/internal/lfs.rb +++ b/lib/api/internal/lfs.rb @@ -7,6 +7,8 @@ module API before { authenticate_by_gitlab_shell_token! } + feature_category :source_code_management + helpers do def find_lfs_object(lfs_oid) LfsObject.find_by_oid(lfs_oid) diff --git a/lib/api/internal/pages.rb b/lib/api/internal/pages.rb index 51136144c19..690f52d89f3 100644 --- a/lib/api/internal/pages.rb +++ b/lib/api/internal/pages.rb @@ -4,6 +4,8 @@ module API # Pages Internal API module Internal class Pages < ::API::Base + feature_category :pages + before do authenticate_gitlab_pages_request! end diff --git a/lib/api/members.rb b/lib/api/members.rb index f2405589280..803de51651a 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -136,7 +136,7 @@ module API source = find_source(source_type, params.delete(:id)) authorize_admin_source!(source_type, source) - member = source.members.find_by!(user_id: params[:user_id]) + member = source_members(source).find_by!(user_id: params[:user_id]) updated_member = ::Members::UpdateService .new(current_user, declared_params(include_missing: false)) @@ -159,7 +159,7 @@ module API # rubocop: disable CodeReuse/ActiveRecord delete ":id/members/:user_id" do source = find_source(source_type, params[:id]) - member = source.members.find_by!(user_id: params[:user_id]) + member = source_members(source).find_by!(user_id: params[:user_id]) destroy_conditionally!(member) do ::Members::DestroyService.new(current_user).execute(member, unassign_issuables: params[:unassign_issuables]) diff --git a/lib/api/personal_access_tokens.rb b/lib/api/personal_access_tokens.rb index 774bc935284..599b3ee034e 100644 --- a/lib/api/personal_access_tokens.rb +++ b/lib/api/personal_access_tokens.rb @@ -4,6 +4,8 @@ module API class PersonalAccessTokens < ::API::Base include ::API::PaginationParams + feature_category :authentication_and_authorization + desc 'Get all Personal Access Tokens' do detail 'This feature was added in GitLab 13.3' success Entities::PersonalAccessToken diff --git a/lib/api/v3/github.rb b/lib/api/v3/github.rb index aed88e6091c..327335aec2d 100644 --- a/lib/api/v3/github.rb +++ b/lib/api/v3/github.rb @@ -22,6 +22,8 @@ module API include PaginationParams + feature_category :integrations + before do authorize_jira_user_agent!(request) authenticate! diff --git a/lib/gitlab/middleware/read_only/controller.rb b/lib/gitlab/middleware/read_only/controller.rb index cfea4aaddf3..18566859285 100644 --- a/lib/gitlab/middleware/read_only/controller.rb +++ b/lib/gitlab/middleware/read_only/controller.rb @@ -9,20 +9,20 @@ module Gitlab APPLICATION_JSON_TYPES = %W{#{APPLICATION_JSON} application/vnd.git-lfs+json}.freeze ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance' - WHITELISTED_GIT_ROUTES = { + ALLOWLISTED_GIT_ROUTES = { 'repositories/git_http' => %w{git_upload_pack git_receive_pack} }.freeze - WHITELISTED_GIT_LFS_ROUTES = { + ALLOWLISTED_GIT_LFS_ROUTES = { 'repositories/lfs_api' => %w{batch}, 'repositories/lfs_locks_api' => %w{verify create unlock} }.freeze - WHITELISTED_GIT_REVISION_ROUTES = { + ALLOWLISTED_GIT_REVISION_ROUTES = { 'projects/compare' => %w{create} }.freeze - WHITELISTED_SESSION_ROUTES = { + ALLOWLISTED_SESSION_ROUTES = { 'sessions' => %w{destroy}, 'admin/sessions' => %w{create destroy} }.freeze @@ -55,7 +55,7 @@ module Gitlab def disallowed_request? DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) && - !whitelisted_routes + !allowlisted_routes end def json_request? @@ -87,7 +87,7 @@ module Gitlab end # Overridden in EE module - def whitelisted_routes + def allowlisted_routes workhorse_passthrough_route? || internal_route? || lfs_route? || compare_git_revisions_route? || sidekiq_route? || session_route? || graphql_query? end @@ -98,7 +98,7 @@ module Gitlab return false unless request.post? && request.path.end_with?('.git/git-upload-pack', '.git/git-receive-pack') - WHITELISTED_GIT_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) + ALLOWLISTED_GIT_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) end def internal_route? @@ -109,7 +109,7 @@ module Gitlab # Calling route_hash may be expensive. Only do it if we think there's a possible match return false unless request.post? && request.path.end_with?('compare') - WHITELISTED_GIT_REVISION_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) + ALLOWLISTED_GIT_REVISION_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) end def lfs_route? @@ -120,7 +120,7 @@ module Gitlab return false end - WHITELISTED_GIT_LFS_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) + ALLOWLISTED_GIT_LFS_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) end def session_route? @@ -128,7 +128,7 @@ module Gitlab return false unless request.post? && request.path.end_with?('/users/sign_out', '/admin/session', '/admin/session/destroy') - WHITELISTED_SESSION_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) + ALLOWLISTED_SESSION_ROUTES[route_hash[:controller]]&.include?(route_hash[:action]) end def sidekiq_route? diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 383500ebcd3..4292cc2173c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14334,6 +14334,9 @@ msgstr "" msgid "InstanceAnalytics|Total" msgstr "" +msgid "InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again." +msgstr "" + msgid "InstanceStatistics|Groups" msgstr "" @@ -14343,12 +14346,30 @@ msgstr "" msgid "InstanceStatistics|Merge Requests" msgstr "" +msgid "InstanceStatistics|No data available." +msgstr "" + msgid "InstanceStatistics|Pipelines" msgstr "" msgid "InstanceStatistics|Projects" msgstr "" +msgid "InstanceStatistics|There was an error while loading the groups" +msgstr "" + +msgid "InstanceStatistics|There was an error while loading the projects" +msgstr "" + +msgid "InstanceStatistics|Total groups" +msgstr "" + +msgid "InstanceStatistics|Total projects" +msgstr "" + +msgid "InstanceStatistics|Total projects & groups" +msgstr "" + msgid "InstanceStatistics|Users" msgstr "" diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh index 9fe7d089d93..5f003d032b7 100644 --- a/scripts/rspec_helpers.sh +++ b/scripts/rspec_helpers.sh @@ -90,7 +90,7 @@ function rspec_simple_job() { export NO_KNAPSACK="1" - bin/rspec --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts} + bin/rspec -Ispec -rspec_helper --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts} } function rspec_paralellized_job() { @@ -143,7 +143,7 @@ function rspec_paralellized_job() { export MEMORY_TEST_PATH="tmp/memory_test/${report_name}_memory.csv" - knapsack rspec "-Ispec --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts}" + knapsack rspec "-Ispec -rspec_helper --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts}" date } diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb index 705eb84d18f..2f0bcd318d9 100644 --- a/spec/fast_spec_helper.rb +++ b/spec/fast_spec_helper.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -# When running in CI environment, we need to load a full `spec_helper` -if ENV['CI'] - require_relative 'spec_helper' +# $" is $LOADED_FEATURES, but RuboCop didn't like it +if $".include?(File.expand_path('spec_helper.rb', __dir__)) + # There's no need to load anything here if spec_helper is already loaded + # because spec_helper is more extensive than fast_spec_helper return end diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js index 2e4eaf3fc96..8697830672e 100644 --- a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js +++ b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js @@ -1,4 +1,9 @@ -const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null }; +const defaultPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, +}; export function getApolloResponse(options = {}) { const { @@ -28,3 +33,38 @@ export function getApolloResponse(options = {}) { }, }; } + +const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({ + data: { + [key]: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: data, + }, + }, +}); + +export const mockQueryResponse = ({ + key, + data = [], + loading = false, + hasNextPage = false, + additionalData = [], +}) => { + const response = mockApolloResponse({ hasNextPage, key, data }); + if (loading) { + return jest.fn().mockReturnValue(new Promise(() => {})); + } + if (hasNextPage) { + return jest + .fn() + .mockResolvedValueOnce(response) + .mockResolvedValueOnce( + mockApolloResponse({ + hasNextPage: false, + key, + data: additionalData, + }), + ); + } + return jest.fn().mockResolvedValue(response); +}; diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js index ea232616f3d..159a7763d11 100644 --- a/spec/frontend/analytics/instance_statistics/components/app_spec.js +++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js @@ -3,6 +3,7 @@ import InstanceStatisticsApp from '~/analytics/instance_statistics/components/ap import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue'; import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; +import ProjectsAndGroupsChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; describe('InstanceStatisticsApp', () => { let wrapper; @@ -34,4 +35,8 @@ describe('InstanceStatisticsApp', () => { it('displays the users chart component', () => { expect(wrapper.find(UsersChart).exists()).toBe(true); }); + + it('displays the projects and groups chart component', () => { + expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true); + }); }); diff --git a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js new file mode 100644 index 00000000000..265c867719b --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js @@ -0,0 +1,218 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import { useFakeDate } from 'helpers/fake_date'; +import ProjectsAndGroupChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import projectsQuery from '~/analytics/instance_statistics/graphql/queries/projects.query.graphql'; +import groupsQuery from '~/analytics/instance_statistics/graphql/queries/groups.query.graphql'; +import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data'; +import { mockQueryResponse } from '../apollo_mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('ProjectsAndGroupChart', () => { + let wrapper; + let queryResponses = { projects: null, groups: null }; + const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }]; + + const createComponent = ({ + loadingError = false, + projects = [], + groups = [], + projectsLoading = false, + groupsLoading = false, + projectsHasNextPage = false, + groupsHasNextPage = false, + } = {}) => { + queryResponses = { + projects: mockQueryResponse({ + key: 'projects', + data: projects, + loading: projectsLoading, + hasNextPage: projectsHasNextPage, + additionalData: mockAdditionalData, + }), + groups: mockQueryResponse({ + key: 'groups', + data: groups, + loading: groupsLoading, + hasNextPage: groupsHasNextPage, + additionalData: mockAdditionalData, + }), + }; + + return shallowMount(ProjectsAndGroupChart, { + props: { + startDate: useFakeDate(2020, 9, 26), + endDate: useFakeDate(2020, 10, 1), + totalDataPoints: mockCountsData2.length, + }, + localVue, + apolloProvider: createMockApollo([ + [projectsQuery, queryResponses.projects], + [groupsQuery, queryResponses.groups], + ]), + data() { + return { loadingError }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + queryResponses = { + projects: null, + groups: null, + }; + }); + + const findLoader = () => wrapper.find(ChartSkeletonLoader); + const findAlert = () => wrapper.find(GlAlert); + const findChart = () => wrapper.find(GlLineChart); + + describe('while loading', () => { + beforeEach(() => { + wrapper = createComponent({ projectsLoading: true, groupsLoading: true }); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('while loading 1 data set', () => { + beforeEach(async () => { + wrapper = createComponent({ + projects: mockCountsData2, + groupsLoading: true, + }); + + await wrapper.vm.$nextTick(); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + }); + + describe('without data', () => { + beforeEach(async () => { + wrapper = createComponent({ projects: [] }); + await wrapper.vm.$nextTick(); + }); + + it('renders a no data message', () => { + expect(findAlert().text()).toBe('No data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not render the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(async () => { + wrapper = createComponent({ projects: mockCountsData2 }); + await wrapper.vm.$nextTick(); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toEqual([ + { data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' }, + { data: [], name: 'Total groups' }, + ]); + }); + }); + + describe('with errors', () => { + beforeEach(async () => { + wrapper = createComponent({ loadingError: true }); + await wrapper.vm.$nextTick(); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe('No data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe.each` + metric | loadingState | newData + ${'projects'} | ${{ projectsHasNextPage: true }} | ${{ projects: mockCountsData2 }} + ${'groups'} | ${{ groupsHasNextPage: true }} | ${{ groups: mockCountsData2 }} + `('$metric - fetchMore', ({ metric, loadingState, newData }) => { + describe('when the fetchMore query returns data', () => { + beforeEach(async () => { + wrapper = createComponent({ + ...loadingState, + ...newData, + }); + + jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore'); + await wrapper.vm.$nextTick(); + }); + + it('requests data twice', () => { + expect(queryResponses[metric]).toBeCalledTimes(2); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the fetchMore query throws an error', () => { + beforeEach(() => { + wrapper = createComponent({ + ...loadingState, + ...newData, + }); + + jest + .spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore') + .mockImplementation(jest.fn().mockRejectedValue()); + return wrapper.vm.$nextTick(); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe('No data available.'); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js index 7509c1e6626..2942df95fdf 100644 --- a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js +++ b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js @@ -7,7 +7,8 @@ import { useFakeDate } from 'helpers/fake_date'; import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql'; -import { mockCountsData2, roundedSortedCountsMonthlyChartData2, mockPageInfo } from '../mock_data'; +import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data'; +import { mockQueryResponse } from '../apollo_mock_data'; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -16,43 +17,13 @@ describe('UsersChart', () => { let wrapper; let queryHandler; - const mockApolloResponse = ({ loading = false, hasNextPage = false, users }) => ({ - data: { - users: { - pageInfo: { ...mockPageInfo, hasNextPage }, - nodes: users, - loading, - }, - }, - }); - - const mockQueryResponse = ({ users, loading = false, hasNextPage = false }) => { - const apolloQueryResponse = mockApolloResponse({ loading, hasNextPage, users }); - if (loading) { - return jest.fn().mockReturnValue(new Promise(() => {})); - } - if (hasNextPage) { - return jest - .fn() - .mockResolvedValueOnce(apolloQueryResponse) - .mockResolvedValueOnce( - mockApolloResponse({ - loading, - hasNextPage: false, - users: [{ recordedAt: '2020-07-21', count: 5 }], - }), - ); - } - return jest.fn().mockResolvedValue(apolloQueryResponse); - }; - const createComponent = ({ loadingError = false, loading = false, users = [], hasNextPage = false, } = {}) => { - queryHandler = mockQueryResponse({ users, loading, hasNextPage }); + queryHandler = mockQueryResponse({ key: 'users', data: users, loading, hasNextPage }); return shallowMount(UsersChart, { props: { diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js index b737db4c55f..e86e552a952 100644 --- a/spec/frontend/analytics/instance_statistics/mock_data.js +++ b/spec/frontend/analytics/instance_statistics/mock_data.js @@ -33,10 +33,3 @@ export const roundedSortedCountsMonthlyChartData2 = [ ['2020-06-01', 21], // average of 2020-06-x items ['2020-07-01', 10], // average of 2020-07-x items ]; - -export const mockPageInfo = { - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - endCursor: null, -}; diff --git a/spec/lib/api/every_api_endpoint_spec.rb b/spec/lib/api/every_api_endpoint_spec.rb index 8bb4bfe3f3c..ebf75e733d0 100644 --- a/spec/lib/api/every_api_endpoint_spec.rb +++ b/spec/lib/api/every_api_endpoint_spec.rb @@ -18,46 +18,6 @@ RSpec.describe 'Every API endpoint' do api_endpoints.map do |(klass, path)| next if klass.try(:feature_category_for_action, path) - # We'll add the rest in https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/463 - completed_classes = [ - ::API::Users, ::API::Issues, ::API::AccessRequests, ::API::Admin::Ci::Variables, - ::API::Admin::InstanceClusters, ::API::Admin::Sidekiq, ::API::Appearance, - ::API::Applications, ::API::Avatar, ::API::AwardEmoji, API::Badges, - ::API::Boards, ::API::Branches, ::API::BroadcastMessages, ::API::Ci::Pipelines, - ::API::Ci::PipelineSchedules, ::API::Ci::Runners, ::API::Ci::Runner, - ::API::Commits, ::API::CommitStatuses, ::API::ContainerRegistryEvent, - ::API::DeployKeys, ::API::DeployTokens, ::API::Deployments, ::API::Environments, - ::API::ErrorTracking, ::API::Events, ::API::FeatureFlags, ::API::FeatureFlagScopes, - ::API::FeatureFlagsUserLists, ::API::Features, ::API::Files, ::API::FreezePeriods, - ::API::GroupBoards, ::API::GroupClusters, ::API::GroupExport, ::API::GroupImport, - ::API::GroupLabels, ::API::GroupMilestones, ::API::Groups, - ::API::GroupContainerRepositories, ::API::GroupVariables, - ::API::ImportBitbucketServer, ::API::ImportGithub, ::API::IssueLinks, - ::API::Issues, ::API::JobArtifacts, ::API::Jobs, ::API::Keys, ::API::Labels, - ::API::Lint, ::API::Markdown, ::API::Members, ::API::MergeRequestDiffs, - ::API::MergeRequests, ::API::MergeRequestApprovals, ::API::Metrics::Dashboard::Annotations, - ::API::Metrics::UserStarredDashboards, ::API::Namespaces, ::API::Notes, - ::API::Discussions, ::API::ResourceLabelEvents, ::API::ResourceMilestoneEvents, - ::API::ResourceStateEvents, ::API::NotificationSettings, ::API::ProjectPackages, - ::API::GroupPackages, ::API::PackageFiles, ::API::NugetPackages, ::API::PypiPackages, - ::API::ComposerPackages, ::API::ConanProjectPackages, ::API::ConanInstancePackages, - ::API::DebianGroupPackages, ::API::DebianProjectPackages, ::API::MavenPackages, - ::API::NpmPackages, ::API::GenericPackages, ::API::GoProxy, ::API::Pages, - ::API::PagesDomains, ::API::ProjectClusters, ::API::ProjectContainerRepositories, - ::API::ProjectEvents, ::API::ProjectExport, ::API::ProjectImport, ::API::ProjectHooks, - ::API::ProjectMilestones, ::API::ProjectRepositoryStorageMoves, ::API::Projects, - ::API::ProjectSnapshots, ::API::ProjectSnippets, ::API::ProjectStatistics, - ::API::ProjectTemplates, ::API::Terraform::State, ::API::Terraform::StateVersion, - ::API::ProtectedBranches, ::API::ProtectedTags, ::API::Releases, ::API::Release::Links, - ::API::RemoteMirrors, ::API::Repositories, ::API::Search, ::API::Services, - ::API::Settings, ::API::SidekiqMetrics, ::API::Snippets, ::API::Statistics, - ::API::Submodules, ::API::Subscriptions, ::API::Suggestions, ::API::SystemHooks, - ::API::Tags, ::API::Templates, ::API::Todos, ::API::Triggers, ::API::Unleash, - ::API::UsageData, ::API::UserCounts, ::API::Variables, ::API::Version, - ::API::Wikis - ] - next unless completed_classes.include?(klass) - "#{klass}##{path}" end.compact.uniq end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index a9f5c72c68a..00416787427 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -147,42 +147,45 @@ RSpec.describe Namespace do end describe '.search' do - let_it_be(:namespace) { create(:namespace) } + let_it_be(:first_namespace) { build(:namespace, name: 'my first namespace', path: 'old-path').tap(&:save!) } + let_it_be(:parent_namespace) { build(:namespace, name: 'my parent namespace', path: 'parent-path').tap(&:save!) } + let_it_be(:second_namespace) { build(:namespace, name: 'my second namespace', path: 'new-path', parent: parent_namespace).tap(&:save!) } + let_it_be(:project_with_same_path) { create(:project, id: second_namespace.id, path: first_namespace.path) } it 'returns namespaces with a matching name' do - expect(described_class.search(namespace.name)).to eq([namespace]) + expect(described_class.search('my first namespace')).to eq([first_namespace]) end it 'returns namespaces with a partially matching name' do - expect(described_class.search(namespace.name[0..2])).to eq([namespace]) + expect(described_class.search('first')).to eq([first_namespace]) end it 'returns namespaces with a matching name regardless of the casing' do - expect(described_class.search(namespace.name.upcase)).to eq([namespace]) + expect(described_class.search('MY FIRST NAMESPACE')).to eq([first_namespace]) end it 'returns namespaces with a matching path' do - expect(described_class.search(namespace.path)).to eq([namespace]) + expect(described_class.search('old-path')).to eq([first_namespace]) end it 'returns namespaces with a partially matching path' do - expect(described_class.search(namespace.path[0..2])).to eq([namespace]) + expect(described_class.search('old')).to eq([first_namespace]) end it 'returns namespaces with a matching path regardless of the casing' do - expect(described_class.search(namespace.path.upcase)).to eq([namespace]) + expect(described_class.search('OLD-PATH')).to eq([first_namespace]) end it 'returns namespaces with a matching route path' do - expect(described_class.search(namespace.route.path, include_parents: true)).to eq([namespace]) + expect(described_class.search('parent-path/new-path', include_parents: true)).to eq([second_namespace]) end it 'returns namespaces with a partially matching route path' do - expect(described_class.search(namespace.route.path[0..2], include_parents: true)).to eq([namespace]) + expect(described_class.search('parent-path/new', include_parents: true)).to eq([second_namespace]) end it 'returns namespaces with a matching route path regardless of the casing' do - expect(described_class.search(namespace.route.path.upcase, include_parents: true)).to eq([namespace]) + expect(described_class.search('PARENT-PATH/NEW-PATH', include_parents: true)).to eq([second_namespace]) end end diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index 0f1637016d6..eb81db95cd3 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -62,6 +62,15 @@ RSpec.describe Route do end end + describe '.for_routable_type' do + let!(:nested_group) { create(:group, path: 'foo', name: 'foo', parent: group) } + let!(:project) { create(:project, path: 'other-project') } + + it 'returns correct routes' do + expect(described_class.for_routable_type(Project.name)).to match_array([project.route]) + end + end + describe '#rename_descendants' do let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 047b9423906..919c8d29406 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -7,6 +7,7 @@ RSpec.describe API::Members do let(:developer) { create(:user) } let(:access_requester) { create(:user) } let(:stranger) { create(:user) } + let(:user_with_minimal_access) { create(:user) } let(:project) do create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project| @@ -20,6 +21,7 @@ RSpec.describe API::Members do create(:group, :public) do |group| group.add_developer(developer) group.add_owner(maintainer) + create(:group_member, :minimal_access, source: group, user: user_with_minimal_access) group.request_access(access_requester) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 98ce765100b..8611b4f0f5f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true +# $" is $LOADED_FEATURES, but RuboCop didn't like it +if $".include?(File.expand_path('fast_spec_helper.rb', __dir__)) + warn 'Detected fast_spec_helper is loaded first than spec_helper.' + warn 'If running test files using both spec_helper and fast_spec_helper,' + warn 'make sure test file with spec_helper is loaded first.' + abort 'Aborting...' +end + require './spec/simplecov_env' SimpleCovEnv.start!