diff --git a/.rubocop.yml b/.rubocop.yml index 16c528bc984..f2dbc1e5db1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -538,6 +538,7 @@ Gitlab/RSpec/AvoidSetup: - 'ee/spec/features/registrations/saas/**/*' - 'ee/spec/features/trials/saas/**/*' - 'ee/spec/features/gitlab_subscriptions/trials/duo_pro/**/*' + - 'ee/spec/features/gitlab_subscriptions/trials/duo_enterprise/**/*' RSpec/DuplicateSpecLocation: Enabled: true diff --git a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml index 65597fc27e1..1d50f26bd6c 100644 --- a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml +++ b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml @@ -134,7 +134,6 @@ Layout/LineEndStringConcatenationIndentation: - 'ee/app/components/namespaces/storage/user_pre_enforcement_alert_component.rb' - 'ee/app/controllers/concerns/insights_actions.rb' - 'ee/app/controllers/ee/ldap/omniauth_callbacks_controller.rb' - - 'ee/app/controllers/gitlab_subscriptions/trials/duo_pro_controller.rb' - 'ee/app/finders/geo/framework_registry_finder.rb' - 'ee/app/graphql/ee/mutations/ci/project_ci_cd_settings_update.rb' - 'ee/app/graphql/ee/mutations/issues/create.rb' diff --git a/.rubocop_todo/rails/strong_params.yml b/.rubocop_todo/rails/strong_params.yml index 61a62f78b2e..33dc6ea5142 100644 --- a/.rubocop_todo/rails/strong_params.yml +++ b/.rubocop_todo/rails/strong_params.yml @@ -335,7 +335,6 @@ Rails/StrongParams: - 'ee/app/controllers/smartcard_controller.rb' - 'ee/app/controllers/subscriptions/groups_controller.rb' - 'ee/app/controllers/subscriptions/hand_raise_leads_controller.rb' - - 'ee/app/controllers/gitlab_subscriptions/trials/duo_pro_controller.rb' - 'ee/app/controllers/subscriptions/trials_controller.rb' - 'ee/app/controllers/subscriptions_controller.rb' - 'ee/app/controllers/users/base_identity_verification_controller.rb' diff --git a/Gemfile b/Gemfile index b8cfb9ed6df..84226428261 100644 --- a/Gemfile +++ b/Gemfile @@ -626,7 +626,7 @@ gem 'ssh_data', '~> 1.3' # rubocop:todo Gemfile/MissingFeatureCategory gem 'spamcheck', '~> 1.3.0' # rubocop:todo Gemfile/MissingFeatureCategory # Gitaly GRPC protocol definitions -gem 'gitaly', '~> 17.2.0', feature_category: :gitaly +gem 'gitaly', '~> 17.4.0.pre.rc1', feature_category: :gitaly # KAS GRPC protocol definitions gem 'gitlab-kas-grpc', '~> 17.4.0.pre.rc1', feature_category: :deployment_management diff --git a/Gemfile.checksum b/Gemfile.checksum index 5289e013119..50afbeeab76 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -207,7 +207,7 @@ {"name":"gettext","version":"3.4.9","platform":"ruby","checksum":"292864fe6a15c224cee4125a4a72fab426fdbb280e4cff3cfe44935f549b009a"}, {"name":"gettext_i18n_rails","version":"1.12.0","platform":"ruby","checksum":"6ac4817731a9e2ce47e1e83381ac34f9142263bc2911aaaafb2526d2f1afc1be"}, {"name":"git","version":"1.18.0","platform":"ruby","checksum":"c9b80462e4565cd3d7a9ba8440c41d2c52244b17b0dad0bfddb46de70630c465"}, -{"name":"gitaly","version":"17.2.0","platform":"ruby","checksum":"48eee8883c43bb2f8fedbb43e4543439cfe37c33becebaec9ea1d425f9cce865"}, +{"name":"gitaly","version":"17.4.0.pre.rc1","platform":"ruby","checksum":"72c69dfa77871be78dd2e017be3131b9515b396d779df5785f77df291219f7b3"}, {"name":"gitlab","version":"4.19.0","platform":"ruby","checksum":"3f645e3e195dbc24f0834fbf83e8ccfb2056d8e9712b01a640aad418a6949679"}, {"name":"gitlab-chronic","version":"0.10.5","platform":"ruby","checksum":"f80f18dc699b708870a80685243331290bc10cfeedb6b99c92219722f729c875"}, {"name":"gitlab-dangerfiles","version":"4.8.0","platform":"ruby","checksum":"b327d079552ec974a63bf34d749a0308425af6ebf51d01064f1a6ff216a523db"}, diff --git a/Gemfile.lock b/Gemfile.lock index b107134d32a..9c22f141600 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -701,7 +701,7 @@ GEM git (1.18.0) addressable (~> 2.8) rchardet (~> 1.8) - gitaly (17.2.0) + gitaly (17.4.0.pre.rc1) grpc (~> 1.0) gitlab (4.19.0) httparty (~> 0.20) @@ -2053,7 +2053,7 @@ DEPENDENCIES gdk-toogle (~> 0.9, >= 0.9.5) gettext (~> 3.4, >= 3.4.9) gettext_i18n_rails (~> 1.12.0) - gitaly (~> 17.2.0) + gitaly (~> 17.4.0.pre.rc1) gitlab-backup-cli! gitlab-chronic (~> 0.10.5) gitlab-dangerfiles (~> 4.8.0) diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 78a47f46887..cddccef3819 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -259,6 +259,10 @@ "markdownDescription": "Reports will be uploaded as artifacts, and often displayed in the Gitlab UI, such as in merge requests. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactsreports).", "additionalProperties": false, "properties": { + "annotations": { + "type": "string", + "description": "Path to JSON file with annotations report." + }, "junit": { "description": "Path for file(s) that should be parsed as JUnit XML result", "oneOf": [ diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 15a705fbf95..f3982f90383 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -186,7 +186,7 @@ class UsersController < ApplicationController def exists if Gitlab::CurrentSettings.signup_enabled? || current_user - render json: { exists: !!Namespace.without_project_namespaces.find_by_path_or_name(params[:username]) } + render json: { exists: Namespace.username_reserved?(params[:username]) } else render json: { error: _('You must be authenticated to access this path.') }, status: :unauthorized end diff --git a/app/finders/concerns/namespaces/groups_filter.rb b/app/finders/concerns/namespaces/groups_filter.rb index a654ff84416..ee8fb5c8192 100644 --- a/app/finders/concerns/namespaces/groups_filter.rb +++ b/app/finders/concerns/namespaces/groups_filter.rb @@ -37,5 +37,10 @@ module Namespaces groups.by_min_access_level(current_user, params[:min_access_level]) end + + def filter_groups(groups) + by_search(groups) + .then { |filtered_groups| by_min_access_level(filtered_groups) } + end end end diff --git a/app/finders/namespaces/groups/invited_groups_finder.rb b/app/finders/namespaces/groups/invited_groups_finder.rb index 64ddeb7d849..4f92656fa2f 100644 --- a/app/finders/namespaces/groups/invited_groups_finder.rb +++ b/app/finders/namespaces/groups/invited_groups_finder.rb @@ -7,7 +7,7 @@ # group # current_user # params: -# relations: string +# relation: string - groups by relation (direct or inherited) # search: string # min_access_level: integer # @@ -18,8 +18,6 @@ module Namespaces include Namespaces::GroupsFilter include Gitlab::Allowable - attr_reader :group, :current_user, :params - def initialize(group, current_user = nil, params = {}) @group = group @current_user = current_user @@ -31,19 +29,16 @@ module Namespaces group_links = group_group_links(group, include_relations) groups = Group.id_in(group_links.select(:shared_with_group_id)).public_or_visible_to_user(current_user) - groups = filter_invited_groups(groups) + groups = filter_groups(groups) sort(groups).with_route end private - def filter_invited_groups(groups) - by_search(groups) - .then { |filtered_groups| by_min_access_level(filtered_groups) } - end + attr_reader :group, :current_user, :params def include_relations - [params[:relation].try(:to_sym)] + Array(params[:relation]).map(&:to_sym) end end end diff --git a/app/finders/namespaces/projects/invited_groups_finder.rb b/app/finders/namespaces/projects/invited_groups_finder.rb new file mode 100644 index 00000000000..1192111bc2f --- /dev/null +++ b/app/finders/namespaces/projects/invited_groups_finder.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Projects::InvitedGroupsFinder +# +# Used to get the list of invited groups in the given project +# Arguments: +# group +# current_user +# params: +# relation: string - groups by relation (direct or inherited) +# search: string +# min_access_level: integer +# +module Namespaces + module Projects + class InvitedGroupsFinder + include Namespaces::GroupsFilter + include Gitlab::Allowable + + def initialize(project, current_user = nil, params = {}) + @project = project + @current_user = current_user + @params = params + end + + def execute + return Group.none unless can?(current_user, :read_project, project) + + groups = group_links(include_relations).public_or_visible_to_user(current_user) + groups = filter_groups(groups) + sort(groups).with_route + end + + private + + attr_reader :project, :current_user, :params + + def include_relations + Array(params[:relation]).map(&:to_sym) + end + + def group_links(include_relations) + case include_relations + when [:direct] + direct + when [:inherited] + inherited + else + Group.from_union(direct, inherited) + end + end + + def direct + Group.id_in(project.project_group_links.select(:group_id)) + end + + def inherited + Group.id_in(project.group_group_links.distinct_on_shared_with_group_id_with_group_access + .select(:shared_with_group_id)) + end + end + end +end + +Namespaces::Projects::InvitedGroupsFinder.prepend_mod diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index a9f51f7f88e..0c8424560ae 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -513,6 +513,7 @@ module ApplicationSettingsHelper :group_projects_api_limit, :groups_api_limit, :project_api_limit, + :project_invited_groups_api_limit, :projects_api_limit, :user_contributed_projects_api_limit, :user_projects_api_limit, diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 9645600d46b..e50a3f32738 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -598,6 +598,7 @@ class ApplicationSetting < ApplicationRecord :packages_cleanup_package_file_worker_capacity, :pipeline_limit_per_project_user_sha, :project_api_limit, + :project_invited_groups_api_limit, :projects_api_limit, :projects_api_rate_limit_unauthenticated, :raw_blob_request_limit, @@ -624,6 +625,7 @@ class ApplicationSetting < ApplicationRecord groups_api_limit: [:integer, { default: 200 }], members_delete_limit: [:integer, { default: 60 }], project_api_limit: [:integer, { default: 400 }], + project_invited_groups_api_limit: [:integer, { default: 60 }], projects_api_limit: [:integer, { default: 2000 }], user_contributed_projects_api_limit: [:integer, { default: 100 }], user_projects_api_limit: [:integer, { default: 300 }], diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 5295c22d6ff..ecfa25ec63d 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -286,6 +286,7 @@ module ApplicationSettingImplementation group_shared_groups_api_limit: 60, groups_api_limit: 200, project_api_limit: 400, + project_invited_groups_api_limit: 60, projects_api_limit: 2000, user_contributed_projects_api_limit: 100, user_projects_api_limit: 300, diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 404eaa88fe6..75a4efbd895 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -362,6 +362,10 @@ class Namespace < ApplicationRecord ensure Gitlab::SafeRequestStore[:require_organization] = current_value end + + def username_reserved?(username) + without_project_namespaces.where(parent_id: nil).find_by_path_or_name(username).present? + end end def to_reference_base(from = nil, full: false, absolute_path: false) diff --git a/app/validators/json_schemas/application_setting_rate_limits.json b/app/validators/json_schemas/application_setting_rate_limits.json index e449559f0e9..04d8deedb37 100644 --- a/app/validators/json_schemas/application_setting_rate_limits.json +++ b/app/validators/json_schemas/application_setting_rate_limits.json @@ -49,6 +49,11 @@ "minimum": 0, "description": "Number of requests allowed to the GET /api/v4/projects/:id API." }, + "project_invited_groups_api_limit": { + "type": "integer", + "minimum": 0, + "description": "Number of requests allowed to the GET /api/v4/projects/:id/invited_groups API." + }, "projects_api_limit": { "type": "integer", "minimum": 0, diff --git a/app/views/admin/application_settings/_projects_api_limits.html.haml b/app/views/admin/application_settings/_projects_api_limits.html.haml index 0e7af022eae..aaf423d0f84 100644 --- a/app/views/admin/application_settings/_projects_api_limits.html.haml +++ b/app/views/admin/application_settings/_projects_api_limits.html.haml @@ -42,4 +42,9 @@ = f.label :user_starred_projects_api_limit, format(_('Maximum requests to the %{api_name} API per %{timeframe} per user or IP address'), api_name: 'GET /users/:user_id/starred_projects', timeframe: 'minute'), class: 'label-bold' = f.number_field :user_starred_projects_api_limit, min: 0, class: 'form-control gl-form-input' + %fieldset + .form-group + = f.label :project_invited_groups_api_limit, format(_('Maximum requests to the %{api_name} API per %{timeframe} per user or IP address'), api_name: 'GET /projects/:id/invited_groups', timeframe: 'minute'), class: 'label-bold' + = f.number_field :project_invited_groups_api_limit, min: 0, class: 'form-control gl-form-input' + = f.submit _('Save changes'), pajamas_button: true diff --git a/config/feature_flags/development/summarize_notes_with_anthropic.yml b/config/feature_flags/development/summarize_notes_with_anthropic.yml deleted file mode 100644 index 145d75e2781..00000000000 --- a/config/feature_flags/development/summarize_notes_with_anthropic.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: summarize_notes_with_anthropic -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134731 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/work_items/430196 -milestone: '16.6' -type: development -group: group::duo chat -default_enabled: true diff --git a/db/docs/batched_background_migrations/copy_taggings_to_p_ci_build_tags.yml b/db/docs/batched_background_migrations/copy_taggings_to_p_ci_build_tags.yml new file mode 100644 index 00000000000..7e2b5cfd376 --- /dev/null +++ b/db/docs/batched_background_migrations/copy_taggings_to_p_ci_build_tags.yml @@ -0,0 +1,9 @@ +--- +migration_job_name: CopyTaggingsToPCiBuildTags +description: Move jobs data from taggings into p_ci_build_tags +feature_category: continuous_integration +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162706 +milestone: '17.4' +queued_migration_version: 20240814075849 +finalize_after: '2024-10-15' +finalized_by: # version of the migration that finalized this BBM diff --git a/db/docs/batched_background_migrations/rerun_epic_dates_to_work_item_dates_sources_sync.yml b/db/docs/batched_background_migrations/rerun_epic_dates_to_work_item_dates_sources_sync.yml new file mode 100644 index 00000000000..e6292d0dfe5 --- /dev/null +++ b/db/docs/batched_background_migrations/rerun_epic_dates_to_work_item_dates_sources_sync.yml @@ -0,0 +1,10 @@ +--- +migration_job_name: RerunEpicDatesToWorkItemDatesSourcesSync +description: > + We backfilled work_item_dates_sources with epic dates data in 17.1, + but we now need to re-do the migration due to a fix in the syncing mechanism in 17.4. +feature_category: team_planning +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162876 +milestone: '17.4' +queued_migration_version: 20240816110844 +finalize_after: '2024-08-20' diff --git a/db/post_migrate/20240814075849_queue_copy_taggings_to_p_ci_build_tags.rb b/db/post_migrate/20240814075849_queue_copy_taggings_to_p_ci_build_tags.rb new file mode 100644 index 00000000000..73eac0bc714 --- /dev/null +++ b/db/post_migrate/20240814075849_queue_copy_taggings_to_p_ci_build_tags.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class QueueCopyTaggingsToPCiBuildTags < Gitlab::Database::Migration[2.2] + milestone '17.4' + restrict_gitlab_migration gitlab_schema: :gitlab_ci + + MIGRATION = "CopyTaggingsToPCiBuildTags" + DELAY_INTERVAL = 2.minutes + BATCH_SIZE = 25000 + SUB_BATCH_SIZE = 150 + GITLAB_OPTIMIZED_BATCH_SIZE = 75_000 + GITLAB_OPTIMIZED_SUB_BATCH_SIZE = 250 + + def up + queue_batched_background_migration( + MIGRATION, + :taggings, + :id, + job_interval: DELAY_INTERVAL, + **batch_sizes + ) + end + + def down + delete_batched_background_migration(MIGRATION, :taggings, :id, []) + end + + private + + def batch_sizes + if Gitlab.com_except_jh? + { + batch_size: GITLAB_OPTIMIZED_BATCH_SIZE, + sub_batch_size: GITLAB_OPTIMIZED_SUB_BATCH_SIZE + } + else + { + batch_size: BATCH_SIZE, + sub_batch_size: SUB_BATCH_SIZE + } + end + end +end diff --git a/db/post_migrate/20240816110844_queue_rerun_epic_dates_to_work_item_dates_sources_sync.rb b/db/post_migrate/20240816110844_queue_rerun_epic_dates_to_work_item_dates_sources_sync.rb new file mode 100644 index 00000000000..da617cc5f80 --- /dev/null +++ b/db/post_migrate/20240816110844_queue_rerun_epic_dates_to_work_item_dates_sources_sync.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class QueueRerunEpicDatesToWorkItemDatesSourcesSync < Gitlab::Database::Migration[2.2] + milestone '17.4' + restrict_gitlab_migration gitlab_schema: :gitlab_main + + MIGRATION = "RerunEpicDatesToWorkItemDatesSourcesSync" + DELAY_INTERVAL = 2.minutes + BATCH_SIZE = 500 + SUB_BATCH_SIZE = 10 + + def up + queue_batched_background_migration( + MIGRATION, + :epics, + :id, + job_interval: DELAY_INTERVAL, + batch_size: BATCH_SIZE, + sub_batch_size: SUB_BATCH_SIZE + ) + end + + def down + delete_batched_background_migration(MIGRATION, :epics, :id, []) + end +end diff --git a/db/schema_migrations/20240814075849 b/db/schema_migrations/20240814075849 new file mode 100644 index 00000000000..90b1da5be3f --- /dev/null +++ b/db/schema_migrations/20240814075849 @@ -0,0 +1 @@ +374990ac1a789d6dd0570cd9d03b1d2f9367cd93d1e7afd1ca865817819d3313 \ No newline at end of file diff --git a/db/schema_migrations/20240816110844 b/db/schema_migrations/20240816110844 new file mode 100644 index 00000000000..ca0a50edc7c --- /dev/null +++ b/db/schema_migrations/20240816110844 @@ -0,0 +1 @@ +4e328702ccd50d7062d82fec133e3acf66f4ca3e7bda2dd4a5f56c9e27baaf0f \ No newline at end of file diff --git a/doc/api/groups.md b/doc/api/groups.md index 7dadd159a4b..ea6f3d8753f 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -557,6 +557,7 @@ Parameters: | `id` | integer/string | yes | The ID or [URL-encoded path of the group](rest/index.md#namespaced-path-encoding) owned by the authenticated user | | `search` | string | no | Return the list of authorized groups matching the search criteria | | `min_access_level` | integer | no | Limit to groups where current user has at least the specified [role (`access_level`)](members.md#roles) | +| `relation` | array of strings | no | Filter the groups by relation (direct or inherited) | | `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (administrators only) | ```plaintext diff --git a/doc/api/projects.md b/doc/api/projects.md index 7a3b3f44a04..b334471742e 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -2105,6 +2105,41 @@ Example response: } ``` +## List a project's invited groups + +Get a list of invited groups in the given project. When accessed without authentication, only public invited groups are returned. + +By default, this request returns 20 results at a time because the API results [are paginated](rest/index.md#pagination). + +Parameters: + +| Attribute | Type | Required | Description | +| ------------------------------------- | ----------------- | -------- | ---------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](rest/index.md#namespaced-path-encoding) owned by the authenticated user | +| `search` | string | no | Return the list of authorized groups matching the search criteria | +| `min_access_level` | integer | no | Limit to groups where current user has at least the specified [role (`access_level`)](members.md#roles) | +| `relation` | array of strings | no | Filter the groups by relation (direct or inherited) | +| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (administrators only) | + +```plaintext +GET /projects/:id/invited_groups +``` + +Example response: + +```json +[ + { + "id": 35, + "web_url": "https://gitlab.example.com/groups/twitter", + "name": "Twitter", + "avatar_url": null, + "full_name": "Twitter", + "full_path": "twitter" + } +] +``` + ## Unstar a project Unstars a given project. Returns status code `304` if the project is not starred. diff --git a/lib/api/groups.rb b/lib/api/groups.rb index b26b3418154..9876b0ecd58 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -376,7 +376,7 @@ module API tags %w[groups] end params do - optional :relation, type: String, values: %w[direct inherited], desc: 'Include group relations' + optional :relation, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, values: %w[direct inherited], desc: 'Include group relations' optional :search, type: String, desc: 'Search for a specific group' optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Minimum access level of authenticated user' @@ -384,12 +384,10 @@ module API use :with_custom_attributes end get ":id/invited_groups", feature_category: :groups_and_projects do - if Feature.enabled?(:rate_limit_groups_and_projects_api, current_user) - check_rate_limit_by_user_or_ip!(:group_invited_groups_api) - end + check_rate_limit_by_user_or_ip!(:group_invited_groups_api) group = find_group!(params[:id]) - groups = ::Namespaces::Groups::InvitedGroupsFinder.new(group, current_user, declared(params)).execute + groups = ::Namespaces::Groups::InvitedGroupsFinder.new(group, current_user, declared_params).execute present_groups params, groups end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 6e95571299e..3c33a2c3791 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -894,6 +894,27 @@ module API present_groups groups end + desc 'Get a list of invited groups in this project' do + success Entities::Group + is_array true + tags %w[projects] + end + params do + optional :relation, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, values: %w[direct inherited], desc: 'Filter by group relation' + optional :search, type: String, desc: 'Search for a specific group' + optional :min_access_level, type: Integer, values: Gitlab::Access.all_values, desc: 'Limit by minimum access level of authenticated user' + + use :pagination + use :with_custom_attributes + end + get ':id/invited_groups', feature_category: :groups_and_projects do + check_rate_limit_by_user_or_ip!(:project_invited_groups_api) + + project = find_project!(params[:id]) + groups = ::Namespaces::Projects::InvitedGroupsFinder.new(project, current_user, declared_params).execute + present_groups groups + end + desc 'Start the housekeeping task for a project' do detail 'This feature was introduced in GitLab 9.0.' success code: 201 diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb index b5c5c983ad5..048c9956005 100644 --- a/lib/gitlab/application_rate_limiter.rb +++ b/lib/gitlab/application_rate_limiter.rb @@ -34,6 +34,7 @@ module Gitlab group_projects_api: { threshold: -> { application_settings.group_projects_api_limit }, interval: 1.minute }, groups_api: { threshold: -> { application_settings.groups_api_limit }, interval: 1.minute }, project_api: { threshold: -> { application_settings.project_api_limit }, interval: 1.minute }, + project_invited_groups_api: { threshold: -> { application_settings.project_invited_groups_api_limit }, interval: 1.minute }, projects_api: { threshold: -> { application_settings.projects_api_limit }, interval: 10.minutes }, user_contributed_projects_api: { threshold: -> { application_settings.user_contributed_projects_api_limit }, interval: 1.minute }, user_projects_api: { threshold: -> { application_settings.user_projects_api_limit }, interval: 1.minute }, diff --git a/lib/gitlab/background_migration/copy_taggings_to_p_ci_build_tags.rb b/lib/gitlab/background_migration/copy_taggings_to_p_ci_build_tags.rb new file mode 100644 index 00000000000..6f51134ab9b --- /dev/null +++ b/lib/gitlab/background_migration/copy_taggings_to_p_ci_build_tags.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + class CopyTaggingsToPCiBuildTags < BatchedMigrationJob + operation_name :copy_taggings + feature_category :continuous_integration + + COLUMN_NAMES = [:tag_id, :build_id, :partition_id, :project_id].freeze + + def perform + each_sub_batch do |sub_batch| + scope = sub_batch + .where(taggable_type: 'CommitStatus') + .joins('inner join p_ci_builds on p_ci_builds.id = taggings.taggable_id') + .select(:tag_id, 'taggable_id as build_id', :partition_id, :project_id) + + connection.execute(<<~SQL.squish) + INSERT INTO p_ci_build_tags(tag_id, build_id, partition_id, project_id) + (#{scope.to_sql}) + ON CONFLICT DO NOTHING; + SQL + end + end + end + end +end diff --git a/lib/gitlab/background_migration/rerun_epic_dates_to_work_item_dates_sources_sync.rb b/lib/gitlab/background_migration/rerun_epic_dates_to_work_item_dates_sources_sync.rb new file mode 100644 index 00000000000..dc777f2f3f0 --- /dev/null +++ b/lib/gitlab/background_migration/rerun_epic_dates_to_work_item_dates_sources_sync.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Migration/BatchedMigrationBaseClass -- rerun existing migration + class RerunEpicDatesToWorkItemDatesSourcesSync < BackfillEpicDatesToWorkItemDatesSources + operation_name :rerun_epic_dates_to_work_item_dates_sources_sync + feature_category :team_planning + end + # rubocop: enable Migration/BatchedMigrationBaseClass + end +end diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb index ef4c6dee4f2..6797890f8d0 100644 --- a/lib/gitlab/gitaly_client/operation_service.rb +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -237,8 +237,25 @@ module Gitlab end Gitlab::Git::OperationService::BranchUpdate.from_gitaly(response.branch_update) - rescue GRPC::FailedPrecondition => e - raise Gitlab::Git::CommitError, e + rescue GRPC::BadStatus => e + detailed_error = GitalyClient.decode_detailed_error(e) + + case detailed_error.try(:error) + when :custom_hook + raise Gitlab::Git::PreReceiveError.new(custom_hook_error_message(detailed_error.custom_hook), + fallback_message: CUSTOM_HOOK_FALLBACK_MESSAGE) + when :reference_update + # Historically UserFFBranch returned a successful response with a missing BranchUpdate if + # updating the reference failed. The RPC has been updated to return a bad status when the + # reference update fails. Match the previous behavior until call sites have been adapted. + nil + else + if e.code == GRPC::Core::StatusCodes::FAILED_PRECONDITION + raise Gitlab::Git::CommitError, e + end + + raise + end end # rubocop:disable Metrics/ParameterLists diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0e838d02aef..be43146f8bf 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19806,6 +19806,12 @@ msgstr "" msgid "DuoCodeReview|I have encountered some issues while I was reviewing. Please try again later." msgstr "" +msgid "DuoEnterpriseTrial|Activate my trial" +msgstr "" + +msgid "DuoEnterpriseTrial|Apply your GitLab Duo Enterprise trial to an existing group" +msgstr "" + msgid "DuoEnterpriseTrial|Congratulations, your free GitLab Duo Enterprise trial is activated and will expire on %{exp_date}. The new license might take a minute to show on the page. To give members access to new GitLab Duo Enterprise features, %{assign_link_start}assign them%{assign_link_end} to GitLab Duo Enterprise seats." msgstr "" @@ -19836,6 +19842,9 @@ msgstr "" msgid "DuoEnterpriseTrial|Start your free GitLab Duo Enterprise trial on %{group_name}" msgstr "" +msgid "DuoEnterpriseTrial|Start your free GitLab Duo Pro trial" +msgstr "" + msgid "DuoEnterpriseTrial|Stay on top of regulatory requirements with self-hosted model deployment" msgstr "" diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index a9a34119c78..da013289d1c 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -965,6 +965,16 @@ RSpec.describe 'Admin updates settings', feature_category: :shared do it_behaves_like 'API rate limit setting' end + context 'for GET /projects/:id/invited_groups API requests' do + let_it_be(:rate_limit_field) do + format(_('Maximum requests to the %{api_name} API per %{timeframe} per user or IP address'), api_name: 'GET /projects/:id/invited_groups', timeframe: 'minute') + end + + let_it_be(:application_setting_key) { :project_invited_groups_api_limit } + + it_behaves_like 'API rate limit setting' + end + context 'for GET /users/:user_id/projects API requests' do let_it_be(:rate_limit_field) do format(_('Maximum requests to the %{api_name} API per %{timeframe} per user or IP address'), api_name: 'GET /users/:user_id/projects', timeframe: 'minute') diff --git a/spec/finders/namespaces/groups/invited_groups_finder_spec.rb b/spec/finders/namespaces/groups/invited_groups_finder_spec.rb index bea96814f6b..c52b1b0355a 100644 --- a/spec/finders/namespaces/groups/invited_groups_finder_spec.rb +++ b/spec/finders/namespaces/groups/invited_groups_finder_spec.rb @@ -63,16 +63,18 @@ RSpec.describe Namespaces::Groups::InvitedGroupsFinder, feature_category: :group let(:new_group) { create(:group) } let(:direct_group) { create(:group) } let(:sub_group) { create(:group, parent: new_group) } + let(:direct_group_2) { create(:group) } before do create(:group_group_link, shared_group: new_group, shared_with_group: direct_group) create(:group_group_link, shared_group: new_group, shared_with_group: sub_group) + create(:group_group_link, shared_group: sub_group, shared_with_group: direct_group_2) end subject(:results) { described_class.new(new_group, current_user, params).execute } context 'when relation is direct' do - let(:params) { { relation: "direct" } } + let(:params) { { relation: ["direct"] } } it 'returns only direct invited groups' do expect(results).to contain_exactly(direct_group, sub_group) @@ -80,7 +82,7 @@ RSpec.describe Namespaces::Groups::InvitedGroupsFinder, feature_category: :group end context 'when no inherited relation is present' do - let(:params) { { relation: "inherited" } } + let(:params) { { relation: ["inherited"] } } it 'returns no invited groups' do expect(results).to be_empty @@ -88,14 +90,24 @@ RSpec.describe Namespaces::Groups::InvitedGroupsFinder, feature_category: :group end context 'when inherited relation is present with respect to sub group' do - let(:params) { { relation: "inherited" } } + let(:params) { { relation: %w[inherited] } } subject(:results) { described_class.new(sub_group, current_user, params).execute } - it 'returns no invited groups' do + it 'returns invited groups' do expect(results).to contain_exactly(sub_group, direct_group) end end + + context 'when direct and inherited relation is present with respect to sub group' do + let(:params) { { relation: %w[inherited direct] } } + + subject(:results) { described_class.new(sub_group, current_user, params).execute } + + it 'returns all invited groups' do + expect(results).to contain_exactly(sub_group, direct_group, direct_group_2) + end + end end end end diff --git a/spec/finders/namespaces/projects/invited_groups_finder_spec.rb b/spec/finders/namespaces/projects/invited_groups_finder_spec.rb new file mode 100644 index 00000000000..93572969fdd --- /dev/null +++ b/spec/finders/namespaces/projects/invited_groups_finder_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespaces::Projects::InvitedGroupsFinder, feature_category: :groups_and_projects do + let_it_be(:user) { create(:user) } + let_it_be(:current_user) { user } + let_it_be(:another_user) { create(:user) } + let_it_be(:group) { create(:group, owners: user) } + let_it_be(:other_group) { create(:group, owners: user, name: "other group") } + let_it_be(:private_group) { create(:group, :private) } + let_it_be(:project) { create(:project, owners: user) } + let(:group_access) { Gitlab::Access::DEVELOPER } + let(:params) { {} } + + subject(:results) { described_class.new(project, current_user, params).execute } + + before do + create(:project_group_link, group: group, project: project) + create(:project_group_link, group: other_group, project: project) + create(:project_group_link, group: private_group, project: project) + end + + describe '#execute' do + context 'when the user has permission to read the group' do + let(:current_user) { user } + + it 'returns the shared groups which is public or visible to the user' do + expect(results).to contain_exactly(group, other_group) + end + end + + context 'when the user does not have permission to read the group' do + let(:current_user) { another_user } + + it 'returns no groups' do + expect(results).to be_empty + end + end + + context 'with search filter' do + let(:params) { { search: "other group" } } + + it 'filters by search term' do + expect(results).to contain_exactly(other_group) + end + end + + context 'with min_access_level filter' do + before_all do + group.add_owner(current_user) + other_group.add_maintainer(current_user) + end + + let(:params) { { min_access_level: Gitlab::Access::OWNER } } + + it 'filters by minimum access level' do + expect(results).to contain_exactly(group) + end + end + + context 'with include relations filter' do + let_it_be(:direct_group1) { create(:group, owners: current_user) } + let_it_be(:direct_group2) { create(:group, owners: current_user) } + let_it_be(:inherited_group1) { create(:group, owners: current_user) } + let_it_be(:inherited_group2) { create(:group, owners: current_user) } + let_it_be(:project1) { create(:project, group: direct_group1, owners: current_user) } + + before do + create(:project_group_link, group: direct_group2, project: project1) + create(:group_group_link, shared_group: direct_group1, shared_with_group: inherited_group2) + create(:group_group_link, shared_group: direct_group1, shared_with_group: inherited_group1) + end + + subject(:results) { described_class.new(project1, current_user, params).execute } + + context 'when relation is direct' do + let(:params) { { relation: ["direct"] } } + + it 'returns only direct invited groups' do + expect(results).to contain_exactly(direct_group2) + end + end + + context 'when relation is inherited' do + let(:params) { { relation: ["inherited"] } } + + it 'returns inherited invited groups' do + expect(results).to contain_exactly(inherited_group1, inherited_group2) + end + end + + context 'when no relation params is present' do + it 'returns all invited groups' do + expect(results).to contain_exactly(direct_group2, inherited_group1, inherited_group2) + end + end + + context 'when direct and inherited relation params is present' do + let(:params) { { relation: %w[direct inherited] } } + + it 'returns all invited groups' do + expect(results).to contain_exactly(direct_group2, inherited_group1, inherited_group2) + end + end + end + end +end diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml index 5b80511cbec..51531717d13 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml @@ -76,3 +76,8 @@ artifacts-access-all: artifacts-access-invalid-value: artifacts: access: random + +annotations-report-annotations-not-string: + artifacts: + reports: + annotations: 1 diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml index f37de22551d..4e495120143 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml @@ -63,3 +63,8 @@ artifacts-access-all: artifacts-access-none: artifacts: access: none + +annotations-report-annotations-normal: + artifacts: + reports: + annotations: upload_report.json diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 6ebdadb18ab..e899bf5aaee 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -70,6 +70,7 @@ RSpec.describe ApplicationSettingsHelper do user_contributed_projects_api_limit user_projects_api_limit user_starred_projects_api_limit group_shared_groups_api_limit group_invited_groups_api_limit + project_invited_groups_api_limit ]) end diff --git a/spec/lib/gitlab/background_migration/copy_taggings_to_p_ci_build_tags_spec.rb b/spec/lib/gitlab/background_migration/copy_taggings_to_p_ci_build_tags_spec.rb new file mode 100644 index 00000000000..9a1012fbdc5 --- /dev/null +++ b/spec/lib/gitlab/background_migration/copy_taggings_to_p_ci_build_tags_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::CopyTaggingsToPCiBuildTags, feature_category: :continuous_integration do + let(:ci_pipelines_table) { table(:ci_pipelines, database: :ci, primary_key: :id) } + let(:ci_builds_table) { table(:p_ci_builds, database: :ci, primary_key: :id) } + let(:ci_build_tags_table) { table(:p_ci_build_tags, database: :ci, primary_key: :id) } + let(:taggings_table) { table(:taggings, database: :ci) } + let(:tags_table) { table(:tags, database: :ci) } + + let(:pipeline1) { ci_pipelines_table.create!(partition_id: 100, project_id: 1) } + let(:pipeline2) { ci_pipelines_table.create!(partition_id: 101, project_id: 2) } + + let(:job1) { ci_builds_table.create!(partition_id: 100, project_id: 1, commit_id: pipeline1.id) } + let(:job2) { ci_builds_table.create!(partition_id: 100, project_id: 2, commit_id: pipeline1.id) } + + let(:tag1) { tags_table.create!(name: 'docker') } + let(:tag2) { tags_table.create!(name: 'postgres') } + let(:tag3) { tags_table.create!(name: 'ruby') } + let(:tag4) { tags_table.create!(name: 'golang') } + + let(:migration_attrs) do + { + start_id: taggings_table.minimum(:id), + end_id: taggings_table.maximum(:id), + batch_table: :taggings, + batch_column: :id, + sub_batch_size: 1, + pause_ms: 0, + connection: connection + } + end + + let(:migration) { described_class.new(**migration_attrs) } + let(:connection) { Ci::ApplicationRecord.connection } + + before do + taggings_table.create!(tag_id: tag1.id, taggable_id: job1.id, taggable_type: 'CommitStatus', context: :tags) + taggings_table.create!(tag_id: tag2.id, taggable_id: job1.id, taggable_type: 'CommitStatus', context: :tags) + taggings_table.create!(tag_id: tag3.id, taggable_id: job1.id, taggable_type: 'CommitStatus', context: :tags) + taggings_table.create!(tag_id: tag1.id, taggable_id: job2.id, taggable_type: 'CommitStatus', context: :tags) + taggings_table.create!(tag_id: tag2.id, taggable_id: job2.id, taggable_type: 'CommitStatus', context: :tags) + taggings_table.create!(tag_id: tag4.id, taggable_id: job2.id, taggable_type: 'CommitStatus', context: :tags) + taggings_table.create!(tag_id: tag3.id, taggable_id: 5, taggable_type: 'Ci::Runner', context: :tags) + end + + describe '#perform' do + it 'copies records over into p_ci_build_tags' do + expect { migration.perform } + .to change { ci_build_tags_table.count } + .from(0) + .to(6) + + expect(taggings_table.where(taggable_id: job1).pluck(:tag_id)) + .to match_array(ci_build_tags_table.where(build_id: job1).pluck(:tag_id)) + + expect(taggings_table.where(taggable_id: job2).pluck(:tag_id)) + .to match_array(ci_build_tags_table.where(build_id: job2).pluck(:tag_id)) + + expect(ci_build_tags_table.where(build_id: job1).pluck(:project_id).uniq) + .to contain_exactly(1) + + expect(ci_build_tags_table.where(build_id: job2).pluck(:project_id).uniq) + .to contain_exactly(2) + end + end +end diff --git a/spec/lib/gitlab/background_migration/rerun_epic_dates_to_work_item_dates_sources_sync_spec.rb b/spec/lib/gitlab/background_migration/rerun_epic_dates_to_work_item_dates_sources_sync_spec.rb new file mode 100644 index 00000000000..6a47f648a4b --- /dev/null +++ b/spec/lib/gitlab/background_migration/rerun_epic_dates_to_work_item_dates_sources_sync_spec.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::RerunEpicDatesToWorkItemDatesSourcesSync, + feature_category: :team_planning do + let!(:epic_type_id) { table(:work_item_types).find_by(base_type: 7).id } + let!(:author) { table(:users).create!(username: 'tester', projects_limit: 100) } + let!(:namespace) { table(:namespaces).create!(name: 'my test group1', path: 'my-test-group1') } + + let(:milestone) do + table(:milestones).create!( + title: 'Milestone', + start_date: DateTime.parse('2024-01-01'), + due_date: DateTime.parse('2024-01-31') + ) + end + + let(:epics) { table(:epics) } + let(:issues) { table(:issues) } + let(:work_item_dates_sources) { table(:work_item_dates_sources) } + let(:start_id) { epics.minimum(:id) } + let(:end_id) { epics.maximum(:id) } + + let!(:fixed_epic_1) do + create_epic_with_work_item(title: 'Epic 5', iid: 5, date_attrs: with_fixed_dates('2024-02-01', '2024-02-29')) + end + + let!(:fixed_epic_2) do + create_epic_with_work_item(title: 'Epic 6', iid: 6, date_attrs: with_fixed_dates('2024-03-01', '2024-03-31')) + end + + let!(:fixed_epic_3) do + create_epic_with_work_item(title: 'Epic 7', iid: 7, date_attrs: with_fixed_dates('2024-04-01', '2024-04-30')) + end + + let!(:fixed_epic_4) do + create_epic_with_work_item(title: 'Epic 8', iid: 8, date_attrs: with_fixed_dates('2024-05-01', '2024-05-31')) + end + + let!(:fixed_epic_5) do + create_epic_with_work_item(title: 'Epic 9', iid: 9, date_attrs: with_fixed_dates('2024-06-01', '2024-06-30')) + end + + let!(:rolledup_epic_1) do + create_epic_with_work_item( + title: 'Epic 10', + iid: 10, + date_attrs: { + start_date_is_fixed: false, + due_date_is_fixed: false, + start_date: fixed_epic_1.start_date, + end_date: milestone.due_date, + start_date_sourcing_milestone_id: nil, + due_date_sourcing_milestone_id: milestone.id, + start_date_sourcing_epic_id: fixed_epic_1.id, + due_date_sourcing_epic_id: nil + } + ) + end + + let!(:rolledup_epic_2) do + create_epic_with_work_item( + title: 'Epic 11', + iid: 11, + date_attrs: { + start_date_is_fixed: false, + due_date_is_fixed: false, + start_date: fixed_epic_2.start_date, + end_date: fixed_epic_3.end_date, + start_date_sourcing_milestone_id: nil, + due_date_sourcing_milestone_id: nil, + start_date_sourcing_epic_id: fixed_epic_2.id, + due_date_sourcing_epic_id: fixed_epic_3.id + } + ) + end + + let!(:rolledup_epic_3) do + create_epic_with_work_item( + title: 'Epic 12', + iid: 12, + date_attrs: { + start_date_is_fixed: false, + due_date_is_fixed: nil, + start_date_fixed: DateTime.parse('2024-07-01'), + due_date_fixed: DateTime.parse('2024-07-31'), + start_date: fixed_epic_4.start_date, + end_date: fixed_epic_4.end_date, + start_date_sourcing_milestone_id: nil, + due_date_sourcing_milestone_id: nil, + start_date_sourcing_epic_id: fixed_epic_4.id, + due_date_sourcing_epic_id: fixed_epic_4.id + } + ) + end + + let!(:rolledup_epic_4) do + create_epic_with_work_item( + title: 'Epic 13', + iid: 13, + date_attrs: { + start_date_is_fixed: false, + due_date_is_fixed: false, + start_date_fixed: DateTime.parse('2024-08-01'), + due_date_fixed: DateTime.parse('2024-08-31'), + start_date: fixed_epic_5.start_date, + end_date: fixed_epic_5.end_date, + start_date_sourcing_milestone_id: nil, + due_date_sourcing_milestone_id: nil, + start_date_sourcing_epic_id: fixed_epic_5.id, + due_date_sourcing_epic_id: fixed_epic_5.id + } + ) + end + + let!(:rolledup_epic_5) do + create_epic_with_work_item( + title: 'Epic 14', + iid: 14, + date_attrs: { + start_date_is_fixed: nil, + due_date_is_fixed: true, + start_date_fixed: DateTime.parse('2024-09-01'), + due_date_fixed: DateTime.parse('2024-09-30'), + start_date: fixed_epic_5.start_date, + end_date: DateTime.parse('2024-09-30'), + start_date_sourcing_milestone_id: nil, + due_date_sourcing_milestone_id: nil, + start_date_sourcing_epic_id: fixed_epic_5.id, + due_date_sourcing_epic_id: nil + } + ) + end + + # Existing date_source for fixed_epic_1 that is not in sync + let!(:not_synced_date_source) do + work_item_dates_sources.create!(namespace_id: namespace.id, issue_id: fixed_epic_1.issue_id) + end + + # Existing date_source for rolledup_epic_4 that is fully synced + let!(:synced_date_source) do + work_item_dates_sources.create!( + namespace_id: namespace.id, + issue_id: rolledup_epic_4.issue_id, + start_date_is_fixed: rolledup_epic_4.start_date_is_fixed, + due_date_is_fixed: rolledup_epic_4.due_date_is_fixed, + start_date_fixed: rolledup_epic_4.start_date_fixed, + due_date_fixed: rolledup_epic_4.due_date_fixed, + start_date: rolledup_epic_4.start_date, + due_date: rolledup_epic_4.end_date, + start_date_sourcing_work_item_id: fixed_epic_5.issue_id, + due_date_sourcing_work_item_id: fixed_epic_5.issue_id + ) + end + + context 'when backfilling all epics', :aggregate_failures do + subject(:migration) do + described_class.new( + start_id: start_id, + end_id: end_id, + batch_table: :epics, + batch_column: :id, + job_arguments: [nil], + sub_batch_size: 2, + pause_ms: 2, + connection: ::ApplicationRecord.connection + ) + end + + RSpec::Matchers.define :match_synced_work_item_dates do + match do |epic| + date_source = work_item_dates_sources.find_by_issue_id(epic.issue_id) + + expect(date_source.start_date).to eq epic.start_date + expect(date_source.start_date_is_fixed).to eq epic.start_date_is_fixed.present? + expect(date_source.due_date_is_fixed).to eq epic.due_date_is_fixed.present? + expect(date_source.start_date_fixed).to eq epic.start_date_fixed + expect(date_source.due_date_fixed).to eq epic.due_date_fixed + expect(date_source.namespace_id).to eq(epic.group_id) + expect(date_source.due_date).to eq(epic.end_date) + expect(date_source.start_date_sourcing_milestone_id).to eq(epic.start_date_sourcing_milestone_id) + expect(date_source.due_date_sourcing_milestone_id).to eq(epic.due_date_sourcing_milestone_id) + expect(date_source.start_date_sourcing_work_item_id) + .to eq(epics.find_by_id(epic.start_date_sourcing_epic_id)&.issue_id) + expect(date_source.due_date_sourcing_work_item_id) + .to eq(epics.find_by_id(epic.due_date_sourcing_epic_id)&.issue_id) + end + end + + it 'backfills data correctly' do + expect { migration.perform } + .to change { work_item_dates_sources.count }.from(2).to(10) + .and not_change { synced_date_source } + + expect(epics.all).to all(match_synced_work_item_dates) + end + end + + def create_epic_with_work_item(iid:, title:, date_attrs: {}) + wi = issues.create!( + iid: iid, + author_id: author.id, + work_item_type_id: epic_type_id, + namespace_id: namespace.id, + lock_version: 1, + title: title + ) + + epic_attributes = { + iid: iid, + title: title, + title_html: title, + group_id: namespace.id, + author_id: author.id, + issue_id: wi.id + } + + epics.create!(epic_attributes.merge!(date_attrs)) + end + + def with_fixed_dates(start_date, due_date) + { + start_date: DateTime.parse(start_date), + end_date: DateTime.parse(due_date), + start_date_fixed: DateTime.parse(start_date), + due_date_fixed: DateTime.parse(due_date), + start_date_is_fixed: true, + due_date_is_fixed: true + } + end + end diff --git a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb index 64cc9f5644e..a84a3294465 100644 --- a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb @@ -518,12 +518,6 @@ RSpec.describe Gitlab::GitalyClient::OperationService, feature_category: :source let(:response) { Gitaly::UserFFBranchResponse.new(branch_update: branch_update) } - before do - expect_any_instance_of(Gitaly::OperationService::Stub) - .to receive(:user_ff_branch).with(request, kind_of(Hash)) - .and_return(response) - end - subject do client.user_ff_branch(user, source_sha: source_sha, @@ -532,30 +526,109 @@ RSpec.describe Gitlab::GitalyClient::OperationService, feature_category: :source ) end - it 'sends a user_ff_branch message and returns a BranchUpdate object' do - expect(subject).to be_a(Gitlab::Git::OperationService::BranchUpdate) - expect(subject.newrev).to eq(source_sha) - expect(subject.repo_created).to be(false) - expect(subject.branch_created).to be(false) - end - - context 'when the response has no branch_update' do - let(:response) { Gitaly::UserFFBranchResponse.new } - - it { expect(subject).to be_nil } - end - - context "when the pre-receive hook fails" do - let(:response) do - Gitaly::UserFFBranchResponse.new( - branch_update: nil, - pre_receive_error: "pre-receive hook error message\n" - ) + context 'with response' do + before do + expect_any_instance_of(Gitaly::OperationService::Stub) + .to receive(:user_ff_branch).with(request, kind_of(Hash)) + .and_return(response) end - it "raises the error" do - # the PreReceiveError class strips the GL-HOOK-ERR prefix from this error - expect { subject }.to raise_error(Gitlab::Git::PreReceiveError, "pre-receive hook failed.") + it 'sends a user_ff_branch message and returns a BranchUpdate object' do + expect(subject).to be_a(Gitlab::Git::OperationService::BranchUpdate) + expect(subject.newrev).to eq(source_sha) + expect(subject.repo_created).to be(false) + expect(subject.branch_created).to be(false) + end + + context 'when the response has no branch_update' do + let(:response) { Gitaly::UserFFBranchResponse.new } + + it { expect(subject).to be_nil } + end + + context "when the pre-receive hook fails" do + let(:response) do + Gitaly::UserFFBranchResponse.new( + branch_update: nil, + pre_receive_error: "pre-receive hook error message\n" + ) + end + + it "raises the error" do + # the PreReceiveError class strips the GL-HOOK-ERR prefix from this error + expect { subject }.to raise_error(Gitlab::Git::PreReceiveError, "pre-receive hook failed.") + end + end + end + + context 'with exception' do + before do + expect_any_instance_of(Gitaly::OperationService::Stub) + .to receive(:user_ff_branch).with(request, kind_of(Hash)) + .and_raise(exception) + end + + context 'with CustomHookError' do + let(:exception) do + new_detailed_error( + GRPC::Core::StatusCodes::PERMISSION_DENIED, + "custom hook error", + Gitaly::UserFFBranchError.new( + custom_hook: Gitaly::CustomHookError.new( + stdout: "some stdout", + stderr: "GitLab: some custom hook error message", + hook_type: Gitaly::CustomHookError::HookType::HOOK_TYPE_PRERECEIVE + ))) + end + + it 'raises a PreReceiveError' do + expect { subject }.to raise_error do |error| + expect(error).to be_a(Gitlab::Git::PreReceiveError) + expect(error.message).to eq("some custom hook error message") + end + end + end + + context 'with ReferenceUpdateError' do + let(:exception) do + new_detailed_error(GRPC::Core::StatusCodes::FAILED_PRECONDITION, + "some ignored error message", + Gitaly::UserFFBranchError.new(reference_update: Gitaly::ReferenceUpdateError.new)) + end + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with FailedPrecondition' do + let(:exception) do + GRPC::FailedPrecondition.new('failed precondition error') + end + + it 'returns CommitError' do + expect { subject }.to raise_error(Gitlab::Git::CommitError, exception.message) + end + end + + context 'with a bad status' do + let(:exception) do + GRPC::Internal.new('internal error') + end + + it 'raises the exception' do + expect { subject }.to raise_error(GRPC::Internal, exception.message) + end + end + + context 'with unhandled exception' do + let(:exception) do + RuntimeError.new('unhandled exception') + end + + it 'raises the exception' do + expect { subject }.to raise_error(RuntimeError, exception.message) + end end end end diff --git a/spec/migrations/20240814075849_queue_copy_taggings_to_p_ci_build_tags_spec.rb b/spec/migrations/20240814075849_queue_copy_taggings_to_p_ci_build_tags_spec.rb new file mode 100644 index 00000000000..1a547387850 --- /dev/null +++ b/spec/migrations/20240814075849_queue_copy_taggings_to_p_ci_build_tags_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe QueueCopyTaggingsToPCiBuildTags, migration: :gitlab_ci, feature_category: :continuous_integration do + let!(:batched_migration) { described_class::MIGRATION } + + it 'schedules a new batched migration' do + reversible_migration do |migration| + migration.before -> { + expect(batched_migration).not_to have_scheduled_batched_migration + } + + migration.after -> { + expect(batched_migration).to have_scheduled_batched_migration( + table_name: :taggings, + column_name: :id, + interval: described_class::DELAY_INTERVAL, + batch_size: described_class::BATCH_SIZE, + sub_batch_size: described_class::SUB_BATCH_SIZE, + gitlab_schema: :gitlab_ci + ) + } + end + end + + context 'when executed on .com' do + before do + allow(Gitlab).to receive(:com_except_jh?).and_return(true) + end + + it 'schedules a new batched migration' do + reversible_migration do |migration| + migration.before -> { + expect(batched_migration).not_to have_scheduled_batched_migration + } + + migration.after -> { + expect(batched_migration).to have_scheduled_batched_migration( + gitlab_schema: :gitlab_ci, + table_name: :taggings, + column_name: :id, + interval: described_class::DELAY_INTERVAL, + batch_size: described_class::GITLAB_OPTIMIZED_BATCH_SIZE, + sub_batch_size: described_class::GITLAB_OPTIMIZED_SUB_BATCH_SIZE + ) + } + end + end + end +end diff --git a/spec/migrations/20240816110844_queue_rerun_epic_dates_to_work_item_dates_sources_sync_spec.rb b/spec/migrations/20240816110844_queue_rerun_epic_dates_to_work_item_dates_sources_sync_spec.rb new file mode 100644 index 00000000000..0e8c63cbe34 --- /dev/null +++ b/spec/migrations/20240816110844_queue_rerun_epic_dates_to_work_item_dates_sources_sync_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe QueueRerunEpicDatesToWorkItemDatesSourcesSync, feature_category: :team_planning do + let!(:batched_migration) { described_class::MIGRATION } + + it 'schedules a new batched migration' do + reversible_migration do |migration| + migration.before -> { + expect(batched_migration).not_to have_scheduled_batched_migration + } + + migration.after -> { + expect(batched_migration).to have_scheduled_batched_migration( + table_name: :epics, + column_name: :id, + interval: described_class::DELAY_INTERVAL, + batch_size: described_class::BATCH_SIZE, + sub_batch_size: described_class::SUB_BATCH_SIZE + ) + } + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 8dce476e0b0..d0f82acebc0 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -42,6 +42,7 @@ RSpec.describe ApplicationSetting, feature_category: :shared, type: :model do it { expect(setting.group_shared_groups_api_limit).to eq(60) } it { expect(setting.groups_api_limit).to eq(200) } it { expect(setting.project_api_limit).to eq(400) } + it { expect(setting.project_invited_groups_api_limit).to eq(60) } it { expect(setting.projects_api_limit).to eq(2000) } it { expect(setting.receptive_cluster_agents_enabled).to eq(false) } it { expect(setting.user_contributed_projects_api_limit).to eq(100) } diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 63f39e6ff8a..3583535a23c 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -1480,6 +1480,47 @@ RSpec.describe Namespace, feature_category: :groups_and_projects do end end + describe ".username_reserved?" do + subject(:username_reserved) { described_class.username_reserved?(username) } + + let(:username) { 'capyabra' } + + let_it_be(:user) { create(:user, name: 'capybara') } + let_it_be(:group) { create(:group, name: 'capybara-group') } + let_it_be(:subgroup) { create(:group, parent: group, name: 'capybara-subgroup') } + let_it_be(:project) { create(:project, group: group, name: 'capybara-project') } + + context 'when given a project name' do + let(:username) { 'capyabra-project' } + + it { is_expected.to eq(false) } + end + + context 'when given a sub-group name' do + let(:username) { 'capybara-subgroup' } + + it { is_expected.to eq(false) } + end + + context 'when given a top-level group' do + let(:username) { 'capybara-group' } + + it { is_expected.to eq(true) } + end + + context 'when given an existing username' do + let(:username) { 'capybara' } + + it { is_expected.to eq(true) } + end + + context 'when given a username with varying capitalization' do + let(:username) { 'CaPyBaRa' } + + it { is_expected.to eq(true) } + end + end + describe "#default_branch_protection" do let(:namespace) { create(:namespace) } let(:default_branch_protection) { nil } diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index d20175ff14d..c516eb79b7b 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -2231,18 +2231,6 @@ RSpec.describe API::Groups, feature_category: :groups_and_projects do end end - context 'when rate_limit_groups_and_projects_api feature flag is disabled' do - before do - stub_feature_flags(rate_limit_groups_and_projects_api: false) - end - - it_behaves_like 'unthrottled endpoint' - - def request - get api(path) - end - end - context 'when authenticated as user' do it 'returns the invited groups in the group', :aggregate_failures do expect_log_keys(caller_id: "GET /api/:version/groups/:id/invited_groups", @@ -2354,7 +2342,7 @@ RSpec.describe API::Groups, feature_category: :groups_and_projects do end it 'filters the invited groups in the group based on relation params', :aggregate_failures do - get api("/groups/#{relation_main_group.id}/invited_groups", user1), params: { relation: 'direct' } + get api("/groups/#{relation_main_group.id}/invited_groups", user1), params: { relation: ['direct'] } expect(response).to have_gitlab_http_status(:ok) expect(response).to include_pagination_headers @@ -2363,7 +2351,7 @@ RSpec.describe API::Groups, feature_category: :groups_and_projects do end it 'returns error message when include relation is invalid' do - get api("/groups/#{relation_main_group.id}/invited_groups", user1), params: { relation: 'some random' } + get api("/groups/#{relation_main_group.id}/invited_groups", user1), params: { relation: ['some random'] } expect(response).to have_gitlab_http_status(:bad_request) expect(json_response['error']).to eq("relation does not have a valid value") diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 0a527c95155..ac1ee4151a4 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -52,6 +52,7 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :groups_and include StubRequests let_it_be(:user) { create(:user) } + let_it_be(:user1) { create(:user) } let_it_be(:user2) { create(:user) } let_it_be(:user3) { create(:user) } let_it_be(:admin) { create(:admin) } @@ -3749,6 +3750,160 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :groups_and end end + describe 'GET /projects/:id/invited_groups' do + let_it_be(:main_group) { create(:group, :private, owners: user1) } + let_it_be(:direct_group1) { create(:group, :private, owners: user1) } + let_it_be(:direct_group2) { create(:group, :private, owners: user1) } + let_it_be(:inherited_group) { create(:group, :private, owners: user1) } + let_it_be(:main_project) { create(:project, group: main_group, owners: user1) } + + let(:path) { "/projects/#{main_project.id}/invited_groups" } + + before do + create(:group_group_link, shared_group: main_group, shared_with_group: inherited_group) + create(:project_group_link, group: direct_group1, project: main_project) + create(:project_group_link, group: direct_group2, project: main_project) + end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :project_invited_groups_api do + def request + get api(path) + end + end + + context 'when authenticated as user' do + it 'returns the invited groups in the project', :aggregate_failures do + get api(path, user1) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response.length).to eq(3) + group_ids = json_response.map { |group| group['id'] } + expect(group_ids).to contain_exactly(direct_group1.id, direct_group2.id, inherited_group.id) + end + end + + context 'when authenticated and user does not have the access' do + it 'does not return the invited groups in the project', :aggregate_failures do + get api(path, user2) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when unauthenticated as user' do + let_it_be(:main_group) { create(:group, :public, owners: user2) } + let_it_be(:direct_group_1) { create(:group, :public, owners: user2) } + let_it_be(:direct_group_2) { create(:group, :private, owners: user2) } + let_it_be(:new_project) { create(:project, :public, group: main_group, owners: user2) } + + let(:path) { "/projects/#{new_project.id}/invited_groups" } + + before do + create(:project_group_link, group: direct_group_1, project: new_project) + create(:project_group_link, group: direct_group_2, project: new_project) + end + + it 'only returns the invited public groups in the project', :aggregate_failures do + get api(path) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response.length).to eq(1) + group_ids = json_response.map { |group| group['id'] } + expect(group_ids).to contain_exactly(direct_group_1.id) + end + end + + context "when search is present in request" do + let_it_be(:direct_group_1) { create(:group, :public, name: "new direct", owners: user1) } + let_it_be(:direct_group_2) { create(:group, :private, name: "other direct", owners: user1) } + let_it_be(:new_project) { create(:project, :public, owners: user1) } + + let(:path) { "/projects/#{new_project.id}/invited_groups" } + + before do + create(:project_group_link, group: direct_group_1, project: new_project) + create(:project_group_link, group: direct_group_2, project: new_project) + end + + it 'filters the invited groups in the group based on search params', :aggregate_failures do + get api(path, user1), params: { search: 'new' } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an(Array) + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(direct_group_1.id) + end + end + + context 'when using min_access_level in the request' do + let_it_be(:new_direct_group) { create(:group, :public, name: "new direct") } + let_it_be(:other_direct_group) { create(:group, :private, name: "other direct") } + let_it_be(:new_project) { create(:project, :public) } + + let(:path) { "/projects/#{new_project.id}/invited_groups" } + + before do + new_direct_group.add_developer(user1) + other_direct_group.add_owner(user1) + create(:project_group_link, group: new_direct_group, project: new_project) + create(:project_group_link, group: other_direct_group, project: new_project) + end + + context 'with min_access_level parameter' do + it 'returns an array of groups the user has at least owner access', :aggregate_failures do + get api(path, user1), params: { min_access_level: Gitlab::Access::OWNER } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |group| group['id'] }).to contain_exactly(other_direct_group.id) + end + end + end + + context "when include_relation is present in request" do + let_it_be(:relation_main_group) { create(:group, :private, owners: user1) } + let_it_be(:direct_group) { create(:group, owners: user1) } + let_it_be(:inherited_group) { create(:group, owners: user1) } + let_it_be(:new_relation_project) { create(:project, group: relation_main_group) } + + let(:path) { "/projects/#{new_relation_project.id}/invited_groups" } + + before do + create(:project_group_link, group: direct_group, project: new_relation_project) + create(:group_group_link, shared_group: relation_main_group, shared_with_group: inherited_group) + end + + it 'filters the invited groups in the project based on direct relation params', :aggregate_failures do + get api(path, user1), params: { relation: ['direct'] } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an(Array) + expect(json_response.map { |group| group['id'] }).to contain_exactly(direct_group.id) + end + + it 'filters the invited groups in the project based on inherited relation params', :aggregate_failures do + get api(path, user1), params: { relation: ['inherited'] } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).to be_an(Array) + expect(json_response.map { |group| group['id'] }).to contain_exactly(inherited_group.id) + end + + it 'returns error message when include relation is invalid' do + get api(path, user1), params: { relation: ['some random'] } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq("relation does not have a valid value") + end + end + end + describe 'DELETE /projects/:id/share/:group_id' do context 'for a valid group' do let_it_be(:group) { create(:group, :private) } diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index f66f309ca9b..541bff304f7 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -754,11 +754,12 @@ RSpec.describe UsersController, feature_category: :user_management do allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(false) end + let(:exists_true_response_body) { { exists: true }.to_json } + it 'returns JSON indicating the user exists' do get user_exists_url user.username - expected_json = { exists: true }.to_json - expect(response.body).to eq(expected_json) + expect(response.body).to eq(exists_true_response_body) end context 'when the casing is different' do @@ -767,8 +768,24 @@ RSpec.describe UsersController, feature_category: :user_management do it 'returns JSON indicating the user exists' do get user_exists_url user.username.downcase - expected_json = { exists: true }.to_json - expect(response.body).to eq(expected_json) + expect(response.body).to eq(exists_true_response_body) + end + end + + context 'when a group with the username exists' do + let_it_be(:group) { create(:group, name: 'get-user-exists') } + let_it_be(:subgroup) { create(:group, name: 'get-user-exists-child', parent: group) } + + it 'treats the top-level group as a reserved name' do + get user_exists_url 'get-user-exists' + + expect(response.body).to eq(exists_true_response_body) + end + + it 'treats the sub-group as not a reserved name' do + get user_exists_url 'get-user-exists-child' + + expect(response.body).to eq({ exists: false }.to_json) end end end